feat: bedtypes is selectable again

This commit is contained in:
Simon Emanuelsson
2025-04-07 13:43:52 +02:00
committed by Michael Zetterberg
parent f62723c6e5
commit afb37d0cc5
69 changed files with 2135 additions and 2349 deletions

View File

@@ -149,9 +149,13 @@ export default function PriceDetailsTable({
<Fragment key={idx}>
<TableSection>
{rooms.length > 1 && (
<Body textTransform="bold">
{intl.formatMessage({ id: "Room" })} {idx + 1}
</Body>
<tr>
<th colSpan={2}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Room" })} {idx + 1}
</Body>
</th>
</tr>
)}
<TableSectionHeader title={room.roomType} subtitle={duration} />
{price && (

View File

@@ -1,6 +1,5 @@
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client"
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
@@ -44,24 +43,11 @@ export default function useModifyStay({
toast.error(intl.formatMessage({ id: "Failed to update your stay" }))
return
}
// Update room details with server response data
const originalCheckIn = dt(bookedRoom.checkInDate)
const originalCheckOut = dt(bookedRoom.checkOutDate)
updateBookedRoom({
...bookedRoom,
checkInDate: dt(updatedBooking.checkInDate)
.hour(originalCheckIn.hour())
.minute(originalCheckIn.minute())
.second(originalCheckIn.second())
.toDate(),
checkOutDate: dt(updatedBooking.checkOutDate)
.hour(originalCheckOut.hour())
.minute(originalCheckOut.minute())
.second(originalCheckOut.second())
.toDate(),
checkInDate: updatedBooking.checkInDate,
checkOutDate: updatedBooking.checkOutDate,
})
toast.success(intl.formatMessage({ id: "Your stay was updated" }))
@@ -90,22 +76,25 @@ export default function useModifyStay({
let totalNewPrice = 0
try {
const data = await utils.hotel.availability.room.fetch({
hotelId: bookedRoom.hotelId,
roomStayStartDate: formValues.checkInDate,
roomStayEndDate: formValues.checkOutDate,
adults: bookedRoom.adults,
children: bookedRoom.childrenAsString,
bookingCode: bookedRoom.bookingCode ?? undefined,
rateCode: bookedRoom.rateDefinition.rateCode,
roomTypeCode: bookedRoom.roomTypeCode,
const data = await utils.hotel.availability.myStay.fetch({
booking: {
fromDate: formValues.checkInDate,
hotelId: bookedRoom.hotelId,
room: {
adults: bookedRoom.adults,
bookingCode: bookedRoom.bookingCode ?? undefined,
childrenInRoom: bookedRoom.childrenInRoom,
rateCode: bookedRoom.rateDefinition.rateCode,
roomTypeCode: bookedRoom.roomTypeCode,
},
toDate: formValues.checkOutDate,
},
lang,
})
if (!data?.selectedRoom || data.selectedRoom.roomsLeft <= 0) {
return { success: false, noAvailability: true }
}
let roomPrice = 0
if (isLoggedIn && "member" in data.product && data.product.member) {
roomPrice = data.product.member.localPrice.pricePerStay
@@ -123,7 +112,6 @@ export default function useModifyStay({
) {
roomPrice = data.product.redemption.localPrice.additionalPricePerStay
}
totalNewPrice += roomPrice
availabilityResults.push(data)
} catch (error) {

View File

@@ -167,9 +167,7 @@ export default function ModifyStay({ isLoggedIn }: ModifyStayProps) {
label: isFirstStep
? intl.formatMessage({ id: "Check availability" })
: intl.formatMessage({ id: "Confirm" }),
onClick: isFirstStep
? () => void onCheckAvailability()
: () => void handleModifyStay(),
onClick: isFirstStep ? onCheckAvailability : handleModifyStay,
intent: isFirstStep ? "secondary" : "primary",
isLoading: isLoading,
disabled: isLoading,

View File

@@ -94,6 +94,8 @@ export function ReferenceCard({
const {
confirmationNumber,
cancellationNumber,
checkInDate,
checkOutDate,
isCancelled,
bookingCode,
rateDefinition,
@@ -222,7 +224,7 @@ export function ReferenceCard({
</Typography>
<Typography variant="Body/Paragraph/mdBold">
<p>
{`${dt(booking.checkInDate).locale(lang).format("dddd, D MMMM")} ${intl.formatMessage({ id: "from" })} ${hotel.hotelFacts.checkin.checkInTime}`}
{`${dt(checkInDate).locale(lang).format("dddd, D MMMM")} ${intl.formatMessage({ id: "from" })} ${hotel.hotelFacts.checkin.checkInTime}`}
</p>
</Typography>
</div>
@@ -232,7 +234,7 @@ export function ReferenceCard({
</Typography>
<Typography variant="Body/Paragraph/mdBold">
<p>
{`${dt(booking.checkOutDate).locale(lang).format("dddd, D MMMM")} ${intl.formatMessage({ id: "until" })} ${hotel.hotelFacts.checkin.checkOutTime}`}
{`${dt(checkOutDate).locale(lang).format("dddd, D MMMM")} ${intl.formatMessage({ id: "until" })} ${hotel.hotelFacts.checkin.checkOutTime}`}
</p>
</Typography>
</div>

View File

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

View File

@@ -11,10 +11,6 @@ export function mapRate(
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))
const rate = {
adults: bookingRooms[index].adults,
cancellationText: room.product.rateDefinition?.cancellationText ?? "",
@@ -39,7 +35,7 @@ export function mapRate(
},
roomRate: room.product,
roomType: room.roomType,
packages: roomPackages,
packages,
}
if ("corporateCheque" in room.product) {

View File

@@ -30,7 +30,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
bookingCode,
bookingRooms,
dates,
petRoomPackage,
isFetchingPackages,
rateSummary,
roomsAvailability,
searchParams,
@@ -41,7 +41,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
checkInDate: state.booking.fromDate,
checkOutDate: state.booking.toDate,
},
petRoomPackage: state.petRoomPackage,
isFetchingPackages: state.rooms.some((room) => room.isFetchingPackages),
rateSummary: state.rateSummary,
roomsAvailability: state.roomsAvailability,
searchParams: state.searchParams,
@@ -123,7 +123,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
})
}
if (!rateSummary.length) {
if (!rateSummary.length || isFetchingPackages) {
return null
}
@@ -149,8 +149,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
const totalPriceToShow = getTotalPrice(
mainRoomProduct,
rateSummary,
isUserLoggedIn,
petRoomPackage
isUserLoggedIn
)
const rateProduct = rateSummary.find((rate) => rate?.product)?.product
@@ -248,7 +247,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
return total
}
const { features, packages: roomPackages, product } = rate
const { packages: roomPackages, product } = rate
const memberExists = "member" in product && product.member
const publicExists = "public" in product && product.public
@@ -266,21 +265,15 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
return total
}
const hasSelectedPetRoom = roomPackages.includes(
RoomPackageCodeEnum.PET_ROOM
const hasSelectedPetRoom = roomPackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)
if (!hasSelectedPetRoom) {
return total + price
}
const isPetRoom = features.find(
(feature) =>
feature.code === RoomPackageCodeEnum.PET_ROOM
return (
total + price + hasSelectedPetRoom.localPrice.totalPrice
)
const petRoomPrice =
isPetRoom && petRoomPackage
? Number(petRoomPackage.localPrice.totalPrice)
: 0
return total + price + petRoomPrice
}, 0),
currency: mainRoomCurrency,
}}

View File

@@ -1,17 +1,11 @@
import type { Price } from "@/types/components/hotelReservation/price"
import {
type RoomPackage,
RoomPackageCodeEnum,
} 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(
selectedRateSummary: Rate[],
isUserLoggedIn: boolean,
petRoomPackage: RoomPackage | undefined
isUserLoggedIn: boolean
) {
return selectedRateSummary.reduce<Price>(
(total, room, idx) => {
@@ -32,35 +26,26 @@ export function calculateTotalPrice(
return total
}
const isPetRoom = room.features.find(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
const packagesPrice = room.packages.reduce(
(total, pkg) => {
total.local = total.local + pkg.localPrice.totalPrice
if (pkg.requestedPrice.totalPrice) {
total.requested = total.requested + pkg.requestedPrice.totalPrice
}
return total
},
{ local: 0, requested: 0 }
)
let petRoomPriceLocal = 0
if (
petRoomPackage &&
isPetRoom &&
room.packages.includes(RoomPackageCodeEnum.PET_ROOM)
) {
petRoomPriceLocal = Number(petRoomPackage.localPrice.totalPrice)
}
let petRoomPriceRequested = 0
if (
petRoomPackage &&
isPetRoom &&
room.packages.includes(RoomPackageCodeEnum.PET_ROOM)
) {
petRoomPriceRequested = Number(petRoomPackage.requestedPrice.totalPrice)
}
total.local.currency = rate.localPrice.currency
total.local.price =
total.local.price + rate.localPrice.pricePerStay + petRoomPriceLocal
total.local.price + rate.localPrice.pricePerStay + packagesPrice.local
if (rate.localPrice.regularPricePerStay) {
total.local.regularPrice =
(total.local.regularPrice || 0) +
rate.localPrice.regularPricePerStay +
petRoomPriceLocal
packagesPrice.local
}
if (rate.requestedPrice) {
@@ -78,13 +63,13 @@ export function calculateTotalPrice(
total.requested.price =
total.requested.price +
rate.requestedPrice.pricePerStay +
petRoomPriceRequested
packagesPrice.requested
if (rate.requestedPrice.regularPricePerStay) {
total.requested.regularPrice =
(total.requested.regularPrice || 0) +
rate.requestedPrice.regularPricePerStay +
petRoomPriceRequested
packagesPrice.requested
}
}
@@ -199,8 +184,7 @@ export function calculateCorporateChequePrice(selectedRateSummary: Rate[]) {
export function getTotalPrice(
mainRoomProduct: Rate | null,
rateSummary: Array<Rate | null>,
isUserLoggedIn: boolean,
petRoomPackage: NonNullable<Packages>[number] | undefined
isUserLoggedIn: boolean
): Price | null {
const summaryArray = rateSummary.filter((rate): rate is Rate => rate !== null)
@@ -209,7 +193,7 @@ export function getTotalPrice(
}
if (!mainRoomProduct) {
return calculateTotalPrice(summaryArray, isUserLoggedIn, petRoomPackage)
return calculateTotalPrice(summaryArray, isUserLoggedIn)
}
const { product } = mainRoomProduct
@@ -222,5 +206,5 @@ export function getTotalPrice(
return calculateVoucherPrice(summaryArray)
}
return calculateTotalPrice(summaryArray, isUserLoggedIn, petRoomPackage)
return calculateTotalPrice(summaryArray, isUserLoggedIn)
}

View File

@@ -3,6 +3,7 @@ import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { dt } from "@/lib/dt"
import { useRatesStore } from "@/stores/select-rate"
import Image from "@/components/Image"
@@ -15,22 +16,31 @@ import { useRoomContext } from "@/contexts/SelectRate/Room"
import styles from "./selectedRoomPanel.module.css"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { CurrencyEnum } from "@/types/enums/currency"
import { RateEnum } from "@/types/enums/rate"
export default function SelectedRoomPanel() {
const intl = useIntl()
const { isUserLoggedIn, roomCategories, rooms } = useRatesStore((state) => ({
isUserLoggedIn: state.isUserLoggedIn,
roomCategories: state.roomCategories,
rooms: state.rooms,
}))
const { dates, isUserLoggedIn, roomCategories, rooms } = useRatesStore(
(state) => ({
dates: {
from: state.booking.fromDate,
to: state.booking.toDate,
},
isUserLoggedIn: state.isUserLoggedIn,
roomCategories: state.roomCategories,
rooms: state.rooms,
})
)
const {
actions: { modifyRate },
isMainRoom,
roomNr,
selectedPackages,
selectedRate,
} = useRoomContext()
const nights = dt(dates.to).diff(dt(dates.from), "days")
const images = roomCategories.find((roomCategory) =>
roomCategory.roomTypes.some(
@@ -60,8 +70,16 @@ export default function SelectedRoomPanel() {
return null
}
let petRoomPrice = 0
const petRoomPackageSelected = selectedPackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)
if (petRoomPackageSelected) {
petRoomPrice = petRoomPackageSelected.localPrice.totalPrice / nights
}
const night = intl.formatMessage({ id: "night" })
let selectedProduct
let isPerNight = true
if (
isUserLoggedIn &&
isMainRoom &&
@@ -69,19 +87,17 @@ export default function SelectedRoomPanel() {
selectedRate.product.member
) {
const { localPrice } = selectedRate.product.member
selectedProduct = `${localPrice.pricePerNight} ${localPrice.currency}`
selectedProduct = `${localPrice.pricePerNight + petRoomPrice} ${localPrice.currency} / ${night}`
} else if ("public" in selectedRate.product && selectedRate.product.public) {
const { localPrice } = selectedRate.product.public
selectedProduct = `${localPrice.pricePerNight} ${localPrice.currency}`
selectedProduct = `${localPrice.pricePerNight + petRoomPrice} ${localPrice.currency} / ${night}`
} else if ("corporateCheque" in selectedRate.product) {
isPerNight = false
const { localPrice } = selectedRate.product.corporateCheque
selectedProduct = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
if (localPrice.additionalPricePerStay && localPrice.currency) {
selectedProduct = `${selectedProduct} + ${localPrice.additionalPricePerStay} ${localPrice.currency}`
}
} else if ("voucher" in selectedRate.product) {
isPerNight = false
selectedProduct = `${selectedRate.product.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
}
@@ -109,9 +125,7 @@ export default function SelectedRoomPanel() {
<Body color="uiTextMediumContrast">
{getRateTitle(selectedRate.product.rate)}
</Body>
<Body color="uiTextHighContrast">
{`${selectedProduct}${isPerNight ? "/" + intl.formatMessage({ id: "night" }) : ""}`}
</Body>
<Body color="uiTextHighContrast">{selectedProduct}</Body>
</div>
<div className={styles.imageContainer}>
{images?.[0]?.imageSizes?.tiny ? (

View File

@@ -17,12 +17,16 @@ export default function NoAvailabilityAlert() {
const lang = useLang()
const intl = useIntl()
const bookingCode = useRatesStore((state) => state.booking.bookingCode)
const { rooms } = useRoomContext()
const { isFetchingPackages, rooms } = useRoomContext()
const noAvailableRooms = rooms.every(
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
)
if (isFetchingPackages) {
return null
}
if (noAvailableRooms) {
const text = intl.formatMessage({
id: "There are no rooms available that match your request.",

View File

@@ -1,54 +0,0 @@
"use client"
import { Checkbox as AriaCheckbox } from "react-aria-components"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./checkbox.module.css"
import type { MaterialSymbolProps } from "react-material-symbols"
interface CheckboxProps {
name: string
value: string
isSelected: boolean
iconName: MaterialSymbolProps["icon"]
isDisabled: boolean
onChange: (value: string) => void
}
export default function Checkbox({
isSelected,
name,
value,
iconName,
isDisabled,
onChange,
}: CheckboxProps) {
return (
<AriaCheckbox
className={styles.checkboxWrapper}
isSelected={isSelected}
isDisabled={isDisabled}
onChange={() => onChange(value)}
>
{({ isSelected }) => (
<>
<span className={styles.checkbox}>
{isSelected && <MaterialIcon icon="check" color="Icon/Inverted" />}
</span>
<Typography
variant="Body/Paragraph/mdRegular"
className={styles.text}
>
<span>{name}</span>
</Typography>
{iconName ? (
<MaterialIcon icon={iconName} color="Icon/Default" />
) : null}
</>
)}
</AriaCheckbox>
)
}

View File

@@ -1,196 +0,0 @@
"use client"
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"
import { useRatesStore } from "@/stores/select-rate"
import Divider from "@/components/TempDesignSystem/Divider"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import Checkbox from "./Checkbox"
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: { togglePackages },
selectedPackages,
} = useRoomContext()
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) => (
<AriaButton
key={pkg}
onPress={() => {
const packages = selectedPackages.filter((s) => s !== pkg)
togglePackages(packages)
}}
className={styles.activeFilterButton}
>
<MaterialIcon
icon={getIconNameByPackageCode(pkg)}
size={16}
color="Icon/Interactive/Default"
/>
<MaterialIcon
icon="close"
size={16}
color="Icon/Interactive/Default"
/>
</AriaButton>
))}
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<ChipButton variant="Outlined">
{intl.formatMessage({ id: "Room preferences" })}
<MaterialIcon
icon="keyboard_arrow_down"
size={20}
color="CurrentColor"
/>
</ChipButton>
<Popover placement="bottom end">
<Dialog className={styles.dialog}>
<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>
</div>
)
}

View File

@@ -2,10 +2,12 @@
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { useRatesStore } from "@/stores/select-rate"
import Select from "@/components/TempDesignSystem/Select"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang"
import styles from "./bookingCodeFilter.module.css"
@@ -16,12 +18,16 @@ import { RateTypeEnum } from "@/types/enums/rateType"
export default function BookingCodeFilter() {
const intl = useIntl()
const lang = useLang()
const utils = trpc.useUtils()
const {
actions: { selectFilter },
selectedFilter,
actions: { appendRegularRates, selectFilter },
bookingRoom,
rooms,
selectedFilter,
selectedPackages,
} = useRoomContext()
const bookingCode = useRatesStore((state) => state.booking.bookingCode)
const booking = useRatesStore((state) => state.booking)
const bookingCodeFilterItems = [
{
@@ -38,25 +44,45 @@ export default function BookingCodeFilter() {
},
]
function handleChangeFilter(selectedFilter: Key) {
async function handleChangeFilter(selectedFilter: Key) {
selectFilter(selectedFilter as BookingCodeFilterEnum)
const room = await utils.hotel.availability.selectRate.room.fetch({
booking: {
...booking,
room: {
...bookingRoom,
bookingCode:
selectedFilter === BookingCodeFilterEnum.Discounted
? booking.bookingCode
: undefined,
packages: selectedPackages.map((pkg) => pkg.code),
},
},
lang,
})
appendRegularRates(room?.roomConfigurations)
}
const hideFilterDespiteBookingCode = rooms.every((room) =>
room.products.every((product) => {
const isRedemption = Array.isArray(product)
if (isRedemption) {
return true
}
const isCorporateCheque =
product.rateDefinition?.rateType === RateTypeEnum.CorporateCheque
const isVoucher =
product.rateDefinition?.rateType === RateTypeEnum.Voucher
return isCorporateCheque || isVoucher
})
)
const hideFilterDespiteBookingCode =
rooms.length &&
rooms.every((room) =>
room.products.every((product) => {
const isRedemption = Array.isArray(product)
if (isRedemption) {
return true
}
const isCorporateCheque =
product.rateDefinition?.rateType === RateTypeEnum.CorporateCheque
const isVoucher =
product.rateDefinition?.rateType === RateTypeEnum.Voucher
return isCorporateCheque || isVoucher
})
)
if ((bookingCode && hideFilterDespiteBookingCode) || !bookingCode) {
if (
(booking.bookingCode && hideFilterDespiteBookingCode) ||
!booking.bookingCode
) {
return null
}

View File

@@ -0,0 +1,40 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./petRoom.module.css"
export default function PetRoomMessage() {
const intl = useIntl()
const { petRoomPackage } = useRoomContext()
if (!petRoomPackage) {
return null
}
return (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.additionalInformation}>
{intl.formatMessage(
{
id: "Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>",
},
{
b: (str) => (
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.additionalInformationPrice}>{str}</span>
</Typography>
),
price: formatPrice(
intl,
petRoomPackage.localPrice.price,
petRoomPackage.localPrice.currency
),
}
)}
</p>
</Typography>
)
}

View File

@@ -0,0 +1,8 @@
.additionalInformation {
color: var(--Text-Tertiary);
padding: var(--Space-x1) var(--Space-x15);
}
.additionalInformationPrice {
color: var(--Text-Default);
}

View File

@@ -0,0 +1,80 @@
"use client"
import { Fragment } from "react"
import { Checkbox, CheckboxGroup } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRatesStore } from "@/stores/select-rate"
import { getIconNameByPackageCode } from "../../utils"
import PetRoomMessage from "./PetRoomMessage"
import {
checkIsAllergyRoom,
checkIsPetRoom,
includesAllergyRoom,
includesPetRoom,
} from "./utils"
import styles from "./checkbox.module.css"
import type { FormValues } from "../formValues"
export default function Checkboxes() {
const packageOptions = useRatesStore((state) => state.packageOptions)
const { control } = useFormContext<FormValues>()
return (
<Controller
control={control}
name="selectedPackages"
render={({ field }) => {
const allergyRoomSelected = includesAllergyRoom(field.value)
const petRoomSelected = includesPetRoom(field.value)
return (
<CheckboxGroup {...field}>
<div>
{packageOptions.map((option) => {
const isAllergyRoom = checkIsAllergyRoom(option.code)
const isPetRoom = checkIsPetRoom(option.code)
const isDisabled =
(isPetRoom && allergyRoomSelected) ||
(isAllergyRoom && petRoomSelected)
const isSelected = field.value.includes(option.code)
const iconName = getIconNameByPackageCode(option.code)
return (
<Fragment key={option.code}>
<Checkbox
key={option.code}
className={styles.checkboxWrapper}
isDisabled={isDisabled}
value={option.code}
>
<span className={styles.checkbox}>
{isSelected ? (
<MaterialIcon icon="check" color="Icon/Inverted" />
) : null}
</span>
<Typography
className={styles.text}
variant="Body/Paragraph/mdRegular"
>
<span>{option.description}</span>
</Typography>
{iconName ? (
<MaterialIcon icon={iconName} color="Icon/Default" />
) : null}
</Checkbox>
{isPetRoom ? <PetRoomMessage /> : null}
</Fragment>
)
})}
</div>
</CheckboxGroup>
)
}}
/>
)
}

View File

@@ -0,0 +1,18 @@
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { PackageEnum } from "@/types/requests/packages"
export function includesAllergyRoom(codes: PackageEnum[]) {
return codes.includes(RoomPackageCodeEnum.ALLERGY_ROOM)
}
export function includesPetRoom(codes: PackageEnum[]) {
return codes.includes(RoomPackageCodeEnum.PET_ROOM)
}
export function checkIsAllergyRoom(code: PackageEnum) {
return code === RoomPackageCodeEnum.ALLERGY_ROOM
}
export function checkIsPetRoom(code: PackageEnum) {
return code === RoomPackageCodeEnum.PET_ROOM
}

View File

@@ -0,0 +1,11 @@
.footer {
display: grid;
gap: var(--Space-x1);
padding: 0 var(--Space-x15);
}
.buttonContainer {
align-items: center;
display: flex;
justify-content: space-between;
}

View File

@@ -0,0 +1,5 @@
import type { PackageEnum } from "@/types/requests/packages"
export type FormValues = {
selectedPackages: PackageEnum[]
}

View File

@@ -0,0 +1,98 @@
"use client"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@/lib/trpc/client"
import { useRatesStore } from "@/stores/select-rate"
import Divider from "@/components/TempDesignSystem/Divider"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang"
import Checkboxes from "./Checkboxes"
import styles from "./form.module.css"
import type { PackageEnum } from "@/types/requests/packages"
import type { FormValues } from "./formValues"
export default function Form({ close }: { close: VoidFunction }) {
const intl = useIntl()
const lang = useLang()
const utils = trpc.useUtils()
const {
actions: { removeSelectedPackages, selectPackages, updateRooms },
bookingRoom,
selectedPackages,
} = useRoomContext()
const booking = useRatesStore((state) => state.booking)
const methods = useForm<FormValues>({
values: {
selectedPackages: selectedPackages.map((pkg) => pkg.code),
},
})
async function getFilteredRates(packages: PackageEnum[]) {
const filterRates = await utils.hotel.availability.selectRate.room.fetch({
booking: {
fromDate: booking.fromDate,
hotelId: booking.hotelId,
searchType: booking.searchType,
toDate: booking.toDate,
room: {
...bookingRoom,
bookingCode: bookingRoom.rateCode
? bookingRoom.bookingCode
: booking.bookingCode,
packages,
},
},
lang,
})
updateRooms(filterRates?.roomConfigurations)
}
function clearSelectedPackages() {
removeSelectedPackages()
close()
getFilteredRates([])
}
function onSubmit(data: FormValues) {
selectPackages(data.selectedPackages)
close()
getFilteredRates(data.selectedPackages)
}
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<Checkboxes />
<div className={styles.footer}>
<Divider color="borderDividerSubtle" />
<div className={styles.buttonContainer}>
<Typography variant="Body/Supporting text (caption)/smBold">
<Button
onPress={clearSelectedPackages}
size="Small"
variant="Text"
>
{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>
</FormProvider>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import { useState } from "react"
import {
Button as AriaButton,
Dialog,
DialogTrigger,
Popover,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { trpc } from "@/lib/trpc/client"
import { useRatesStore } from "@/stores/select-rate"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang"
import Form from "./Form"
import { getIconNameByPackageCode } from "./utils"
import styles from "./roomPackageFilter.module.css"
import type { PackageEnum } from "@/types/requests/packages"
export default function RoomPackageFilter() {
const intl = useIntl()
const lang = useLang()
const utils = trpc.useUtils()
const [isOpen, setIsOpen] = useState(false)
const {
actions: { removeSelectedPackage, updateRooms },
bookingRoom,
selectedPackages,
} = useRoomContext()
const booking = useRatesStore((state) => state.booking)
async function deleteSelectedPackage(code: PackageEnum) {
removeSelectedPackage(code)
const filterRates = await utils.hotel.availability.selectRate.room.fetch({
booking: {
fromDate: booking.fromDate,
hotelId: booking.hotelId,
searchType: booking.searchType,
toDate: booking.toDate,
room: {
...bookingRoom,
bookingCode: bookingRoom.rateCode
? bookingRoom.bookingCode
: booking.bookingCode,
packages: selectedPackages
.filter((pkg) => pkg.code !== code)
.map((pkg) => pkg.code),
},
},
lang,
})
updateRooms(filterRates?.roomConfigurations)
}
return (
<div className={styles.roomPackageFilter}>
{selectedPackages.map((pkg) => (
<AriaButton
key={pkg.code}
className={styles.activeFilterButton}
onPress={() => deleteSelectedPackage(pkg.code)}
>
<MaterialIcon
icon={getIconNameByPackageCode(pkg.code)}
size={16}
color="Icon/Interactive/Default"
/>
<MaterialIcon
icon="close"
size={16}
color="Icon/Interactive/Default"
/>
</AriaButton>
))}
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<ChipButton variant="Outlined">
{intl.formatMessage({ id: "Room preferences" })}
<MaterialIcon
icon="keyboard_arrow_down"
size={20}
color="CurrentColor"
/>
</ChipButton>
<Popover placement="bottom end">
<Dialog className={styles.dialog}>
<Form close={() => setIsOpen(false)} />
</Dialog>
</Popover>
</DialogTrigger>
</div>
)
}

View File

@@ -3,33 +3,6 @@
gap: var(--Space-x1);
}
.dialog {
display: grid;
gap: var(--Space-x1);
padding: var(--Space-x2);
flex-direction: column;
align-items: flex-end;
border-radius: var(--Corner-radius-md);
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
max-width: 340px;
}
.footer {
display: grid;
gap: var(--Space-x1);
padding: 0 var(--Space-x15);
}
.additionalInformation {
color: var(--Text-Tertiary);
padding: var(--Space-x1) var(--Space-x15);
}
.additionalInformationPrice {
color: var(--Text-Default);
}
.activeFilterButton {
display: flex;
justify-content: center;
@@ -42,8 +15,14 @@
cursor: pointer;
}
.buttonContainer {
display: flex;
justify-content: space-between;
align-items: center;
.dialog {
display: grid;
gap: var(--Space-x1);
padding: var(--Space-x2);
flex-direction: column;
align-items: flex-end;
border-radius: var(--Corner-radius-md);
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
max-width: 340px;
}

View File

@@ -1,10 +1,11 @@
import type { MaterialSymbolProps } from "react-material-symbols"
import type { SymbolCodepoints } from "react-material-symbols"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { PackageEnum } from "@/types/requests/packages"
export function getIconNameByPackageCode(
packageCode: RoomPackageCodeEnum
): MaterialSymbolProps["icon"] {
packageCode: PackageEnum
): SymbolCodepoints {
switch (packageCode) {
case RoomPackageCodeEnum.PET_ROOM:
return "pets"

View File

@@ -5,15 +5,15 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import BookingCodeFilter from "../BookingCodeFilter"
import RoomPackageFilter from "../RoomPackageFilter"
import BookingCodeFilter from "./BookingCodeFilter"
import RoomPackageFilter from "./RoomPackageFilter"
import styles from "./roomsHeader.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
export default function RoomsHeader() {
const { rooms, totalRooms } = useRoomContext()
const { isFetchingPackages, rooms, totalRooms } = useRoomContext()
const intl = useIntl()
const availableRooms = rooms.filter(
@@ -42,11 +42,15 @@ export default function RoomsHeader() {
return (
<div className={styles.container}>
<Typography variant="Title/Subtitle/md" className={styles.availableRooms}>
<p>
{availableRooms !== totalRooms
? notAllRoomsAvailableText
: allRoomsAvailableText}
</p>
{isFetchingPackages ? (
<p></p>
) : (
<p>
{availableRooms !== totalRooms
? notAllRoomsAvailableText
: allRoomsAvailableText}
</p>
)}
</Typography>
<div className={styles.filters}>
<RoomPackageFilter />

View File

@@ -37,24 +37,21 @@ export default function Rates({
selectedFilter,
selectedPackages,
} = useRoomContext()
const { nights, petRoomPackage } = useRatesStore((state) => ({
nights: dt(state.booking.toDate).diff(state.booking.fromDate, "days"),
petRoomPackage: state.petRoomPackage,
}))
const nights = useRatesStore((state) =>
dt(state.booking.toDate).diff(state.booking.fromDate, "days")
)
function handleSelectRate(product: Product) {
selectRate({ features, product, roomType, roomTypeCode })
}
const petRoomPackageSelected = selectedPackages.includes(
RoomPackageCodeEnum.PET_ROOM
const petRoomPackageSelected = selectedPackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)
const sharedProps = {
handleSelectRate,
nights,
petRoomPackage:
petRoomPackageSelected && petRoomPackage ? petRoomPackage : undefined,
petRoomPackage: petRoomPackageSelected,
roomTypeCode,
}
const showAllRates = selectedFilter === BookingCodeFilterEnum.All

View File

@@ -1,10 +1,10 @@
import type { RoomPackage } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { Package } from "@/types/requests/packages"
export function calculatePricePerNightPriceProduct(
pricePerNight: number,
requestedPricePerNight: number | undefined,
nights: number,
petRoomPackage?: RoomPackage
petRoomPackage?: Package
) {
const totalPrice = petRoomPackage?.localPrice
? Math.floor(pricePerNight + petRoomPackage.localPrice.price / nights)

View File

@@ -14,7 +14,7 @@ import styles from "./image.module.css"
import type { RoomListItemImageProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
export default function RoomImage({
features,
roomPackages,
roomsLeft,
roomType,
roomTypeCode,
@@ -44,11 +44,13 @@ export default function RoomImage({
</Footnote>
</span>
) : null}
{features
.filter((feature) => selectedPackages.includes(feature.code))
.map((feature) => (
<span className={styles.chip} key={feature.code}>
{IconForFeatureCode({ featureCode: feature.code, size: 16 })}
{roomPackages
.filter((pkg) =>
selectedPackages.find((spkg) => spkg.code === pkg.code)
)
.map((pkg) => (
<span className={styles.chip} key={pkg.code}>
{IconForFeatureCode({ featureCode: pkg.code, size: 16 })}
</span>
))}
</div>

View File

@@ -1,5 +1,7 @@
"use client"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import Details from "./Details"
import { listItemVariants } from "./listItemVariants"
import Rates from "./Rates"
@@ -12,6 +14,7 @@ import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHote
import type { RoomListItemProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
export default function RoomListItem({ roomConfiguration }: RoomListItemProps) {
const { roomPackages } = useRoomContext()
const classNames = listItemVariants({
availability:
roomConfiguration.status === AvailabilityEnum.NotAvailable
@@ -22,7 +25,7 @@ export default function RoomListItem({ roomConfiguration }: RoomListItemProps) {
return (
<li className={classNames}>
<RoomImage
features={roomConfiguration.features}
roomPackages={roomPackages}
roomType={roomConfiguration.roomType}
roomTypeCode={roomConfiguration.roomTypeCode}
roomsLeft={roomConfiguration.roomsLeft}

View File

@@ -8,12 +8,10 @@ import ScrollToList from "./ScrollToList"
import styles from "./rooms.module.css"
export default function RoomsList() {
const { rooms, isFetchingRoomFeatures } = useRoomContext()
if (isFetchingRoomFeatures) {
const { isFetchingPackages, rooms } = useRoomContext()
if (isFetchingPackages) {
return <RoomsListSkeleton />
}
return (
<>
<ScrollToList />

View File

@@ -1,11 +1,10 @@
"use client"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client"
import useLang from "@/hooks/useLang"
import RatesProvider from "@/providers/RatesProvider"
import { useHotelPackages, useRoomsAvailability } from "../utils"
import RateSummary from "./RateSummary"
import Rooms from "./Rooms"
import { RoomsContainerSkeleton } from "./RoomsContainerSkeleton"
@@ -13,57 +12,34 @@ import { RoomsContainerSkeleton } from "./RoomsContainerSkeleton"
import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer"
export function RoomsContainer({
adultArray,
booking,
childArray,
fromDate,
hotelData,
hotelType,
isUserLoggedIn,
toDate,
roomCategories,
vat,
}: RoomsContainerProps) {
const lang = useLang()
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
const toDateString = dt(toDate).format("YYYY-MM-DD")
const roomsAvailability = trpc.hotel.availability.selectRate.rooms.useQuery({
booking,
lang,
})
const { data: roomsAvailability, isPending: isLoadingAvailability } =
useRoomsAvailability(
adultArray,
hotelData.hotel.id,
fromDateString,
toDateString,
lang,
childArray,
booking
)
const { data: packages, isPending: isLoadingPackages } = useHotelPackages(
adultArray,
childArray,
fromDateString,
toDateString,
hotelData.hotel.id,
lang
)
if (isLoadingAvailability || isLoadingPackages) {
if (
(roomsAvailability.isFetching || !roomsAvailability.data) &&
!roomsAvailability.isFetched
) {
return <RoomsContainerSkeleton />
}
if (packages === null) {
// TODO: Log packages error
console.error("[RoomsContainer] unable to fetch packages")
}
return (
<RatesProvider
booking={booking}
hotelType={hotelData.hotel.hotelType}
hotelType={hotelType}
isUserLoggedIn={isUserLoggedIn}
packages={packages}
roomCategories={hotelData.roomCategories}
roomsAvailability={roomsAvailability}
vat={hotelData.hotel.vat}
roomCategories={roomCategories}
roomsAvailability={roomsAvailability.data}
vat={vat}
>
<Rooms />
<RateSummary isUserLoggedIn={isUserLoggedIn} />

View File

@@ -74,13 +74,11 @@ export default async function SelectRatePage({
<HotelInfoCard hotel={hotelData.hotel} />
<RoomsContainer
adultArray={adultsInRoom}
booking={booking}
childArray={childrenInRoom}
fromDate={arrivalDate}
hotelData={hotelData}
hotelType={hotelData.hotel.hotelType}
isUserLoggedIn={isUserLoggedIn}
toDate={departureDate}
roomCategories={hotelData.roomCategories}
vat={hotelData.hotel.vat}
/>
<Suspense key={`${suspenseKey}-tracking`} fallback={null}>

View File

@@ -1,63 +0,0 @@
import { REDEMPTION } from "@/constants/booking"
import { trpc } from "@/lib/trpc/client"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Lang } from "@/constants/languages"
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
export function useRoomsAvailability(
adultsCount: number[],
hotelId: string,
fromDateString: string,
toDateString: string,
lang: Lang,
childArray: ChildrenInRoom,
booking: SelectRateSearchParams
) {
const redemption = booking.searchType
? booking.searchType === REDEMPTION
: undefined
const roomFeatureCodesArray = booking.rooms.map(
(room) => room.packages ?? null
)
const roomsAvailability =
trpc.hotel.availability.roomsCombinedAvailability.useQuery({
adultsCount,
childArray,
hotelId,
lang,
redemption,
roomStayEndDate: toDateString,
roomStayStartDate: fromDateString,
bookingCode: booking.bookingCode,
roomFeatureCodesArray,
})
return roomsAvailability
}
export function useHotelPackages(
adultArray: number[],
childArray: ChildrenInRoom,
fromDateString: string,
toDateString: string,
hotelId: string,
lang: Lang
) {
return trpc.hotel.packages.get.useQuery({
adults: adultArray[0], // Using the first adult count
children: childArray?.[0]?.length, // Using the first children count
endDate: toDateString,
hotelId,
packageCodes: [
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
],
startDate: fromDateString,
lang: lang,
})
}