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

@@ -1,14 +1,12 @@
import { notFound, redirect } from "next/navigation" import { notFound, redirect } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { REDEMPTION } from "@/constants/booking"
import { selectRate } from "@/constants/routes/hotelReservation" import { selectRate } from "@/constants/routes/hotelReservation"
import { import {
getBreakfastPackages, getBreakfastPackages,
getHotel, getHotel,
getPackages,
getProfileSafely, getProfileSafely,
getSelectedRoomAvailability, getSelectedRoomsAvailability,
} from "@/lib/trpc/memoizedRequests" } from "@/lib/trpc/memoizedRequests"
import HotelHeader from "@/components/HotelReservation/EnterDetails/Header" import HotelHeader from "@/components/HotelReservation/EnterDetails/Header"
@@ -17,7 +15,6 @@ import Multiroom from "@/components/HotelReservation/EnterDetails/Room/Multiroom
import RoomOne from "@/components/HotelReservation/EnterDetails/Room/One" import RoomOne from "@/components/HotelReservation/EnterDetails/Room/One"
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop" import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile" import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import Alert from "@/components/TempDesignSystem/Alert" import Alert from "@/components/TempDesignSystem/Alert"
import TrackingSDK from "@/components/TrackingSDK" import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
@@ -29,7 +26,6 @@ import { getTracking } from "./tracking"
import styles from "./page.module.css" import styles from "./page.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { AlertTypeEnum } from "@/types/enums/alert" import { AlertTypeEnum } from "@/types/enums/alert"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
@@ -40,92 +36,65 @@ export default async function DetailsPage({
searchParams, searchParams,
}: PageArgs<LangParams, SelectRateSearchParams>) { }: PageArgs<LangParams, SelectRateSearchParams>) {
const selectRoomParams = new URLSearchParams(searchParams) const selectRoomParams = new URLSearchParams(searchParams)
selectRoomParams.delete("modifyRateIndex")
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams) const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
if ("modifyRateIndex" in booking) { if ("modifyRateIndex" in booking) {
selectRoomParams.delete("modifyRateIndex")
delete booking.modifyRateIndex delete booking.modifyRateIndex
} }
void getProfileSafely()
const breakfastInput = { const breakfastInput = {
adults: 1, adults: 1,
fromDate: booking.fromDate, fromDate: booking.fromDate,
hotelId: booking.hotelId, hotelId: booking.hotelId,
toDate: booking.toDate, toDate: booking.toDate,
} }
const breakfastPackages = await getBreakfastPackages(breakfastInput) const hotelInput = {
const rooms: Room[] = []
for (let room of booking.rooms) {
const childrenAsString =
room.childrenInRoom && generateChildrenString(room.childrenInRoom)
const packages = room.packages
? await getPackages({
adults: room.adults,
children: room.childrenInRoom?.length,
endDate: booking.toDate,
hotelId: booking.hotelId, hotelId: booking.hotelId,
packageCodes: room.packages, // TODO: Remove this from input since it forces
startDate: booking.fromDate, // waterfalls for no other reason than to
// set merchantInformationData.alternatePaymentOptions
// to an empty array
isCardOnlyPayment: false,
language: lang,
}
void getHotel(hotelInput)
void getBreakfastPackages(breakfastInput)
void getProfileSafely()
const roomsAvailability = await getSelectedRoomsAvailability({
booking,
lang, lang,
}) })
: null
const roomAvailability = await getSelectedRoomAvailability({ const rooms: Room[] = []
adults: room.adults, for (let room of roomsAvailability) {
bookingCode: booking.bookingCode, if (!room) {
children: childrenAsString, // TODO: This could be done in the route already.
counterRateCode: room.counterRateCode, // (possibly also add an error case to url?)
hotelId: booking.hotelId, // -------------------------------------------------------
packageCodes: room.packages,
rateCode: room.rateCode,
roomStayEndDate: booking.toDate,
roomStayStartDate: booking.fromDate,
roomTypeCode: room.roomTypeCode,
redemption: booking.searchType === REDEMPTION,
})
if (!roomAvailability) {
// redirect back to select-rate if availability call fails // redirect back to select-rate if availability call fails
redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`) redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`)
} }
rooms.push({ rooms.push(room)
bedTypes: roomAvailability.bedTypes,
breakfastIncluded: roomAvailability.breakfastIncluded,
cancellationText: roomAvailability.cancellationText,
isFlexRate: roomAvailability.isFlexRate,
mustBeGuaranteed: roomAvailability.mustBeGuaranteed,
memberMustBeGuaranteed: roomAvailability.memberMustBeGuaranteed,
packages,
rate: roomAvailability.rate,
rateDefinitionTitle: roomAvailability.rateDefinitionTitle,
rateDetails: roomAvailability.rateDetails ?? [],
rateTitle: roomAvailability.rateTitle,
rateType: roomAvailability.rateType,
roomType: roomAvailability.selectedRoom.roomType,
roomTypeCode: roomAvailability.selectedRoom.roomTypeCode,
roomRate: roomAvailability.product,
isAvailable:
roomAvailability.selectedRoom.status === AvailabilityEnum.Available,
})
} }
const isCardOnlyPayment = rooms.some((room) => room?.mustBeGuaranteed)
const hotelData = await getHotel({ const hotelData = await getHotel(hotelInput)
hotelId: booking.hotelId,
isCardOnlyPayment,
language: lang,
})
const user = await getProfileSafely()
if (!hotelData || !rooms) { if (!hotelData || !rooms.length) {
return notFound() return notFound()
} }
const breakfastPackages = await getBreakfastPackages(breakfastInput)
const user = await getProfileSafely()
const isCardOnlyPayment = rooms.some((room) => room.mustBeGuaranteed)
const { hotel } = hotelData const { hotel } = hotelData
// TODO: Temp fix to avoid waterfall fetch and moving this
// logic from the route here for now
if (isCardOnlyPayment) {
hotel.merchantInformationData.alternatePaymentOptions = []
}
const { hotelsTrackingData, pageTrackingData } = getTracking( const { hotelsTrackingData, pageTrackingData } = getTracking(
booking, booking,

View File

@@ -37,7 +37,7 @@ export default async function SelectRatePage({
// If someone tries to update the url with // If someone tries to update the url with
// a bookingCode also, then we need to remove it // a bookingCode also, then we need to remove it
if (isRedemption && searchParams.bookingCode) { if (isRedemption && searchParams.bookingCode) {
searchParams.bookingCode = "" delete searchParams.bookingCode
} }
return ( return (

View File

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

View File

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

View File

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

View File

@@ -94,6 +94,8 @@ export function ReferenceCard({
const { const {
confirmationNumber, confirmationNumber,
cancellationNumber, cancellationNumber,
checkInDate,
checkOutDate,
isCancelled, isCancelled,
bookingCode, bookingCode,
rateDefinition, rateDefinition,
@@ -222,7 +224,7 @@ export function ReferenceCard({
</Typography> </Typography>
<Typography variant="Body/Paragraph/mdBold"> <Typography variant="Body/Paragraph/mdBold">
<p> <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> </p>
</Typography> </Typography>
</div> </div>
@@ -232,7 +234,7 @@ export function ReferenceCard({
</Typography> </Typography>
<Typography variant="Body/Paragraph/mdBold"> <Typography variant="Body/Paragraph/mdBold">
<p> <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> </p>
</Typography> </Typography>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,11 @@
import type { Price } from "@/types/components/hotelReservation/price" 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 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(
selectedRateSummary: Rate[], selectedRateSummary: Rate[],
isUserLoggedIn: boolean, isUserLoggedIn: boolean
petRoomPackage: RoomPackage | undefined
) { ) {
return selectedRateSummary.reduce<Price>( return selectedRateSummary.reduce<Price>(
(total, room, idx) => { (total, room, idx) => {
@@ -32,35 +26,26 @@ export function calculateTotalPrice(
return total return total
} }
const isPetRoom = room.features.find( const packagesPrice = room.packages.reduce(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM (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.currency = rate.localPrice.currency
total.local.price = total.local.price =
total.local.price + rate.localPrice.pricePerStay + petRoomPriceLocal total.local.price + rate.localPrice.pricePerStay + packagesPrice.local
if (rate.localPrice.regularPricePerStay) { if (rate.localPrice.regularPricePerStay) {
total.local.regularPrice = total.local.regularPrice =
(total.local.regularPrice || 0) + (total.local.regularPrice || 0) +
rate.localPrice.regularPricePerStay + rate.localPrice.regularPricePerStay +
petRoomPriceLocal packagesPrice.local
} }
if (rate.requestedPrice) { if (rate.requestedPrice) {
@@ -78,13 +63,13 @@ export function calculateTotalPrice(
total.requested.price = total.requested.price =
total.requested.price + total.requested.price +
rate.requestedPrice.pricePerStay + rate.requestedPrice.pricePerStay +
petRoomPriceRequested packagesPrice.requested
if (rate.requestedPrice.regularPricePerStay) { if (rate.requestedPrice.regularPricePerStay) {
total.requested.regularPrice = total.requested.regularPrice =
(total.requested.regularPrice || 0) + (total.requested.regularPrice || 0) +
rate.requestedPrice.regularPricePerStay + rate.requestedPrice.regularPricePerStay +
petRoomPriceRequested packagesPrice.requested
} }
} }
@@ -199,8 +184,7 @@ export function calculateCorporateChequePrice(selectedRateSummary: Rate[]) {
export function getTotalPrice( export function getTotalPrice(
mainRoomProduct: Rate | null, mainRoomProduct: Rate | null,
rateSummary: Array<Rate | null>, rateSummary: Array<Rate | null>,
isUserLoggedIn: boolean, isUserLoggedIn: boolean
petRoomPackage: NonNullable<Packages>[number] | undefined
): Price | null { ): Price | null {
const summaryArray = rateSummary.filter((rate): rate is Rate => rate !== null) const summaryArray = rateSummary.filter((rate): rate is Rate => rate !== null)
@@ -209,7 +193,7 @@ export function getTotalPrice(
} }
if (!mainRoomProduct) { if (!mainRoomProduct) {
return calculateTotalPrice(summaryArray, isUserLoggedIn, petRoomPackage) return calculateTotalPrice(summaryArray, isUserLoggedIn)
} }
const { product } = mainRoomProduct const { product } = mainRoomProduct
@@ -222,5 +206,5 @@ export function getTotalPrice(
return calculateVoucherPrice(summaryArray) 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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { dt } from "@/lib/dt"
import { useRatesStore } from "@/stores/select-rate" import { useRatesStore } from "@/stores/select-rate"
import Image from "@/components/Image" import Image from "@/components/Image"
@@ -15,22 +16,31 @@ import { useRoomContext } from "@/contexts/SelectRate/Room"
import styles from "./selectedRoomPanel.module.css" import styles from "./selectedRoomPanel.module.css"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { CurrencyEnum } from "@/types/enums/currency" import { CurrencyEnum } from "@/types/enums/currency"
import { RateEnum } from "@/types/enums/rate" import { RateEnum } from "@/types/enums/rate"
export default function SelectedRoomPanel() { export default function SelectedRoomPanel() {
const intl = useIntl() const intl = useIntl()
const { isUserLoggedIn, roomCategories, rooms } = useRatesStore((state) => ({ const { dates, isUserLoggedIn, roomCategories, rooms } = useRatesStore(
(state) => ({
dates: {
from: state.booking.fromDate,
to: state.booking.toDate,
},
isUserLoggedIn: state.isUserLoggedIn, isUserLoggedIn: state.isUserLoggedIn,
roomCategories: state.roomCategories, roomCategories: state.roomCategories,
rooms: state.rooms, rooms: state.rooms,
})) })
)
const { const {
actions: { modifyRate }, actions: { modifyRate },
isMainRoom, isMainRoom,
roomNr, roomNr,
selectedPackages,
selectedRate, selectedRate,
} = useRoomContext() } = useRoomContext()
const nights = dt(dates.to).diff(dt(dates.from), "days")
const images = roomCategories.find((roomCategory) => const images = roomCategories.find((roomCategory) =>
roomCategory.roomTypes.some( roomCategory.roomTypes.some(
@@ -60,8 +70,16 @@ export default function SelectedRoomPanel() {
return null 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 selectedProduct
let isPerNight = true
if ( if (
isUserLoggedIn && isUserLoggedIn &&
isMainRoom && isMainRoom &&
@@ -69,19 +87,17 @@ export default function SelectedRoomPanel() {
selectedRate.product.member selectedRate.product.member
) { ) {
const { localPrice } = 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) { } else if ("public" in selectedRate.product && selectedRate.product.public) {
const { localPrice } = 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) { } else if ("corporateCheque" in selectedRate.product) {
isPerNight = false
const { localPrice } = selectedRate.product.corporateCheque const { localPrice } = selectedRate.product.corporateCheque
selectedProduct = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}` selectedProduct = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
if (localPrice.additionalPricePerStay && localPrice.currency) { if (localPrice.additionalPricePerStay && localPrice.currency) {
selectedProduct = `${selectedProduct} + ${localPrice.additionalPricePerStay} ${localPrice.currency}` selectedProduct = `${selectedProduct} + ${localPrice.additionalPricePerStay} ${localPrice.currency}`
} }
} else if ("voucher" in selectedRate.product) { } else if ("voucher" in selectedRate.product) {
isPerNight = false
selectedProduct = `${selectedRate.product.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}` selectedProduct = `${selectedRate.product.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
} }
@@ -109,9 +125,7 @@ export default function SelectedRoomPanel() {
<Body color="uiTextMediumContrast"> <Body color="uiTextMediumContrast">
{getRateTitle(selectedRate.product.rate)} {getRateTitle(selectedRate.product.rate)}
</Body> </Body>
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">{selectedProduct}</Body>
{`${selectedProduct}${isPerNight ? "/" + intl.formatMessage({ id: "night" }) : ""}`}
</Body>
</div> </div>
<div className={styles.imageContainer}> <div className={styles.imageContainer}>
{images?.[0]?.imageSizes?.tiny ? ( {images?.[0]?.imageSizes?.tiny ? (

View File

@@ -17,12 +17,16 @@ export default function NoAvailabilityAlert() {
const lang = useLang() const lang = useLang()
const intl = useIntl() const intl = useIntl()
const bookingCode = useRatesStore((state) => state.booking.bookingCode) const bookingCode = useRatesStore((state) => state.booking.bookingCode)
const { rooms } = useRoomContext() const { isFetchingPackages, rooms } = useRoomContext()
const noAvailableRooms = rooms.every( const noAvailableRooms = rooms.every(
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable (roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
) )
if (isFetchingPackages) {
return null
}
if (noAvailableRooms) { if (noAvailableRooms) {
const text = intl.formatMessage({ const text = intl.formatMessage({
id: "There are no rooms available that match your request.", 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 { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { useRatesStore } from "@/stores/select-rate" import { useRatesStore } from "@/stores/select-rate"
import Select from "@/components/TempDesignSystem/Select" import Select from "@/components/TempDesignSystem/Select"
import { useRoomContext } from "@/contexts/SelectRate/Room" import { useRoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang"
import styles from "./bookingCodeFilter.module.css" import styles from "./bookingCodeFilter.module.css"
@@ -16,12 +18,16 @@ import { RateTypeEnum } from "@/types/enums/rateType"
export default function BookingCodeFilter() { export default function BookingCodeFilter() {
const intl = useIntl() const intl = useIntl()
const lang = useLang()
const utils = trpc.useUtils()
const { const {
actions: { selectFilter }, actions: { appendRegularRates, selectFilter },
selectedFilter, bookingRoom,
rooms, rooms,
selectedFilter,
selectedPackages,
} = useRoomContext() } = useRoomContext()
const bookingCode = useRatesStore((state) => state.booking.bookingCode) const booking = useRatesStore((state) => state.booking)
const bookingCodeFilterItems = [ const bookingCodeFilterItems = [
{ {
@@ -38,11 +44,28 @@ export default function BookingCodeFilter() {
}, },
] ]
function handleChangeFilter(selectedFilter: Key) { async function handleChangeFilter(selectedFilter: Key) {
selectFilter(selectedFilter as BookingCodeFilterEnum) 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) => const hideFilterDespiteBookingCode =
rooms.length &&
rooms.every((room) =>
room.products.every((product) => { room.products.every((product) => {
const isRedemption = Array.isArray(product) const isRedemption = Array.isArray(product)
if (isRedemption) { if (isRedemption) {
@@ -56,7 +79,10 @@ export default function BookingCodeFilter() {
}) })
) )
if ((bookingCode && hideFilterDespiteBookingCode) || !bookingCode) { if (
(booking.bookingCode && hideFilterDespiteBookingCode) ||
!booking.bookingCode
) {
return null 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); 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 { .activeFilterButton {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -42,8 +15,14 @@
cursor: pointer; cursor: pointer;
} }
.buttonContainer { .dialog {
display: flex; display: grid;
justify-content: space-between; gap: var(--Space-x1);
align-items: center; 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 { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { PackageEnum } from "@/types/requests/packages"
export function getIconNameByPackageCode( export function getIconNameByPackageCode(
packageCode: RoomPackageCodeEnum packageCode: PackageEnum
): MaterialSymbolProps["icon"] { ): SymbolCodepoints {
switch (packageCode) { switch (packageCode) {
case RoomPackageCodeEnum.PET_ROOM: case RoomPackageCodeEnum.PET_ROOM:
return "pets" return "pets"

View File

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

View File

@@ -37,24 +37,21 @@ export default function Rates({
selectedFilter, selectedFilter,
selectedPackages, selectedPackages,
} = useRoomContext() } = useRoomContext()
const { nights, petRoomPackage } = useRatesStore((state) => ({ const nights = useRatesStore((state) =>
nights: dt(state.booking.toDate).diff(state.booking.fromDate, "days"), dt(state.booking.toDate).diff(state.booking.fromDate, "days")
petRoomPackage: state.petRoomPackage, )
}))
function handleSelectRate(product: Product) { function handleSelectRate(product: Product) {
selectRate({ features, product, roomType, roomTypeCode }) selectRate({ features, product, roomType, roomTypeCode })
} }
const petRoomPackageSelected = selectedPackages.includes( const petRoomPackageSelected = selectedPackages.find(
RoomPackageCodeEnum.PET_ROOM (pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
) )
const sharedProps = { const sharedProps = {
handleSelectRate, handleSelectRate,
nights, nights,
petRoomPackage: petRoomPackage: petRoomPackageSelected,
petRoomPackageSelected && petRoomPackage ? petRoomPackage : undefined,
roomTypeCode, roomTypeCode,
} }
const showAllRates = selectedFilter === BookingCodeFilterEnum.All 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( export function calculatePricePerNightPriceProduct(
pricePerNight: number, pricePerNight: number,
requestedPricePerNight: number | undefined, requestedPricePerNight: number | undefined,
nights: number, nights: number,
petRoomPackage?: RoomPackage petRoomPackage?: Package
) { ) {
const totalPrice = petRoomPackage?.localPrice const totalPrice = petRoomPackage?.localPrice
? Math.floor(pricePerNight + petRoomPackage.localPrice.price / nights) ? 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" import type { RoomListItemImageProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
export default function RoomImage({ export default function RoomImage({
features, roomPackages,
roomsLeft, roomsLeft,
roomType, roomType,
roomTypeCode, roomTypeCode,
@@ -44,11 +44,13 @@ export default function RoomImage({
</Footnote> </Footnote>
</span> </span>
) : null} ) : null}
{features {roomPackages
.filter((feature) => selectedPackages.includes(feature.code)) .filter((pkg) =>
.map((feature) => ( selectedPackages.find((spkg) => spkg.code === pkg.code)
<span className={styles.chip} key={feature.code}> )
{IconForFeatureCode({ featureCode: feature.code, size: 16 })} .map((pkg) => (
<span className={styles.chip} key={pkg.code}>
{IconForFeatureCode({ featureCode: pkg.code, size: 16 })}
</span> </span>
))} ))}
</div> </div>

View File

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

View File

@@ -8,12 +8,10 @@ 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, isFetchingRoomFeatures } = useRoomContext() const { isFetchingPackages, rooms } = useRoomContext()
if (isFetchingPackages) {
if (isFetchingRoomFeatures) {
return <RoomsListSkeleton /> return <RoomsListSkeleton />
} }
return ( return (
<> <>
<ScrollToList /> <ScrollToList />

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
"+46 8 517 517 00": "+46 8 517 517 00", "+46 8 517 517 00": "+46 8 517 517 00",
"/night per adult": "/nat per voksen", "/night per adult": "/nat per voksen",
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/nat</b> Vigtig information om priser og funktioner i kæledyrsvenlige værelser.",
"<b>Included</b> (based on availability)": "<b>Inkluderet</b> (baseret på tilgængelighed)", "<b>Included</b> (based on availability)": "<b>Inkluderet</b> (baseret på tilgængelighed)",
"<b>Total price</b> (incl VAT)": "<b>Samlet pris</b> (inkl. moms)", "<b>Total price</b> (incl VAT)": "<b>Samlet pris</b> (inkl. moms)",
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>", "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
@@ -610,6 +609,7 @@
"Pet room charge including VAT": "Gebyr for kæledyrsværelse inkl. moms", "Pet room charge including VAT": "Gebyr for kæledyrsværelse inkl. moms",
"Pet-friendly": "Kæledyrsvenlig", "Pet-friendly": "Kæledyrsvenlig",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Kæledyrsrum har en ekstra gebyr på 20 EUR per ophold", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Kæledyrsrum har en ekstra gebyr på 20 EUR per ophold",
"Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>": "Kæledyrsvenlige værelser inkluderer et gebyr på ca. <b>{price}</b>/ophold",
"Phone": "Telefon", "Phone": "Telefon",
"Phone is required": "Telefonnummer er påkrævet", "Phone is required": "Telefonnummer er påkrævet",
"Phone number": "Telefonnummer", "Phone number": "Telefonnummer",

View File

@@ -2,7 +2,6 @@
"+46 8 517 517 00": "+46 8 517 517 00", "+46 8 517 517 00": "+46 8 517 517 00",
"/night per adult": "/Nacht pro Erwachsenem", "/night per adult": "/Nacht pro Erwachsenem",
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/Nacht</b> Wichtige Informationen zu Preisen und Eigenschaften von haustierfreundlichen Zimmern.",
"<b>Included</b> (based on availability)": "<b>Inbegriffen</b> (je nach Verfügbarkeit)", "<b>Included</b> (based on availability)": "<b>Inbegriffen</b> (je nach Verfügbarkeit)",
"<b>Total price</b> (incl VAT)": "<b>Gesamtpreis</b> (inkl. MwSt.)", "<b>Total price</b> (incl VAT)": "<b>Gesamtpreis</b> (inkl. MwSt.)",
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>", "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
@@ -609,6 +608,7 @@
"Pet room charge including VAT": "Haustierzimmergebühr inkl. MwSt.", "Pet room charge including VAT": "Haustierzimmergebühr inkl. MwSt.",
"Pet-friendly": "Haustierfreundlich", "Pet-friendly": "Haustierfreundlich",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Haustierzimmer haben einen zusätzlichen Preis von 20 EUR pro Aufenthalt", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Haustierzimmer haben einen zusätzlichen Preis von 20 EUR pro Aufenthalt",
"Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>": "Für haustierfreundliche Zimmer fällt eine Gebühr von ca. <b>{price}</b>/Aufenthalt an.",
"Phone": "Telefon", "Phone": "Telefon",
"Phone is required": "Telefon ist erforderlich", "Phone is required": "Telefon ist erforderlich",
"Phone number": "Telefonnummer", "Phone number": "Telefonnummer",

View File

@@ -2,7 +2,6 @@
"+46 8 517 517 00": "+46 8 517 517 00", "+46 8 517 517 00": "+46 8 517 517 00",
"/night per adult": "/night per adult", "/night per adult": "/night per adult",
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.",
"<b>Included</b> (based on availability)": "<b>Included</b> (based on availability)", "<b>Included</b> (based on availability)": "<b>Included</b> (based on availability)",
"<b>Total price</b> (incl VAT)": "<b>Total price</b> (incl VAT)", "<b>Total price</b> (incl VAT)": "<b>Total price</b> (incl VAT)",
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>", "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
@@ -610,6 +609,7 @@
"Pet room charge including VAT": "Pet room charge including VAT", "Pet room charge including VAT": "Pet room charge including VAT",
"Pet-friendly": "Pet-friendly", "Pet-friendly": "Pet-friendly",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Pet-friendly rooms have an additional fee of 20 EUR per stay", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Pet-friendly rooms have an additional fee of 20 EUR per stay",
"Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>": "Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>",
"Phone": "Phone", "Phone": "Phone",
"Phone is required": "Phone is required", "Phone is required": "Phone is required",
"Phone number": "Phone number", "Phone number": "Phone number",

View File

@@ -2,7 +2,6 @@
"+46 8 517 517 00": "+46 8 517 517 00", "+46 8 517 517 00": "+46 8 517 517 00",
"/night per adult": "/yötä aikuista kohti", "/night per adult": "/yötä aikuista kohti",
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/yö</b> Tärkeitä tietoja hinnoista ja lemmikkieläinystävällisten huoneiden ominaisuuksista.",
"<b>Included</b> (based on availability)": "<b>Sisältyy</b> (saatavuuden mukaan)", "<b>Included</b> (based on availability)": "<b>Sisältyy</b> (saatavuuden mukaan)",
"<b>Total price</b> (incl VAT)": "<b>Kokonaishinta</b> (sis. ALV)", "<b>Total price</b> (incl VAT)": "<b>Kokonaishinta</b> (sis. ALV)",
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>", "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
@@ -608,6 +607,7 @@
"Pet room charge including VAT": "Lemmikkihuoneen maksu sis. ALV", "Pet room charge including VAT": "Lemmikkihuoneen maksu sis. ALV",
"Pet-friendly": "Lemmikkiystävällinen", "Pet-friendly": "Lemmikkiystävällinen",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Lemmikkihuoneen lisäkustannus on 20 EUR per majoitus", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Lemmikkihuoneen lisäkustannus on 20 EUR per majoitus",
"Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>": "Lemmikkiystävälliset huoneet sisältävät n. <b>{price}</b>/yöpyminen",
"Phone": "Puhelin", "Phone": "Puhelin",
"Phone is required": "Puhelin vaaditaan", "Phone is required": "Puhelin vaaditaan",
"Phone number": "Puhelinnumero", "Phone number": "Puhelinnumero",

View File

@@ -2,7 +2,6 @@
"+46 8 517 517 00": "+46 8 517 517 00", "+46 8 517 517 00": "+46 8 517 517 00",
"/night per adult": "/natt per voksen", "/night per adult": "/natt per voksen",
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/natt</b> Viktig informasjon om priser og funksjoner for dyrevennlige rom.",
"<b>Included</b> (based on availability)": "<b>Inkludert</b> (basert på tilgjengelighet)", "<b>Included</b> (based on availability)": "<b>Inkludert</b> (basert på tilgjengelighet)",
"<b>Total price</b> (incl VAT)": "<b>Totalpris</b> (inkl. mva)", "<b>Total price</b> (incl VAT)": "<b>Totalpris</b> (inkl. mva)",
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>", "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
@@ -607,6 +606,7 @@
"Pet room charge including VAT": "Kjæledyrromsgebyr inkl. MVA", "Pet room charge including VAT": "Kjæledyrromsgebyr inkl. MVA",
"Pet-friendly": "Dyrevennlig", "Pet-friendly": "Dyrevennlig",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Kjæledyrsrom har en tilleggsavgift på 20 EUR per opphold", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Kjæledyrsrom har en tilleggsavgift på 20 EUR per opphold",
"Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>": "Kjæledyrvennlige rom inkluderer en kostnad på ca. <b>{price}</b>/opphold",
"Phone": "Telefon", "Phone": "Telefon",
"Phone is required": "Telefon kreves", "Phone is required": "Telefon kreves",
"Phone number": "Telefonnummer", "Phone number": "Telefonnummer",

View File

@@ -2,7 +2,6 @@
"+46 8 517 517 00": "+46 8 517 517 00", "+46 8 517 517 00": "+46 8 517 517 00",
"/night per adult": "/natt per vuxen", "/night per adult": "/natt per vuxen",
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/natt</b> Viktig information om prissättning och funktioner för husdjursvänliga rum.",
"<b>Included</b> (based on availability)": "<b>Ingår</b> (baserat på tillgänglighet)", "<b>Included</b> (based on availability)": "<b>Ingår</b> (baserat på tillgänglighet)",
"<b>Total price</b> (incl VAT)": "<b>Totalpris</b> (inkl moms)", "<b>Total price</b> (incl VAT)": "<b>Totalpris</b> (inkl moms)",
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>", "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
@@ -607,6 +606,7 @@
"Pet room charge including VAT": "Avgift för husdjursrum inkl. moms", "Pet room charge including VAT": "Avgift för husdjursrum inkl. moms",
"Pet-friendly": "Husdjursvänlig", "Pet-friendly": "Husdjursvänlig",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Husdjursrum har en extra avgift på 20 EUR per vistelse", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Husdjursrum har en extra avgift på 20 EUR per vistelse",
"Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>": "Husdjursvänliga rum har en avgift på ca. <b>{price}/vistelse",
"Phone": "Telefon", "Phone": "Telefon",
"Phone is required": "Telefonnummer är obligatorisk", "Phone is required": "Telefonnummer är obligatorisk",
"Phone number": "Telefonnummer", "Phone number": "Telefonnummer",

View File

@@ -11,15 +11,13 @@ import type {
BreackfastPackagesInput, BreackfastPackagesInput,
PackagesInput, PackagesInput,
} from "@/types/requests/packages" } from "@/types/requests/packages"
import type { RoomsAvailabilityExtendedInputSchema } from "@/types/trpc/routers/hotel/availability"
import type { import type {
CityCoordinatesInput, CityCoordinatesInput,
HotelInput, HotelInput,
} from "@/types/trpc/routers/hotel/hotel" } from "@/types/trpc/routers/hotel/hotel"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
import type { import type { GetHotelsByCSFilterInput } from "@/server/routers/hotels/input"
GetHotelsByCSFilterInput,
GetSelectedRoomAvailabilityInput,
} from "@/server/routers/hotels/input"
import type { GetSavedPaymentCardsInput } from "@/server/routers/user/input" import type { GetSavedPaymentCardsInput } from "@/server/routers/user/input"
export const getLocations = cache(async function getMemoizedLocations() { export const getLocations = cache(async function getMemoizedLocations() {
@@ -92,14 +90,6 @@ export const getHotelPage = cache(async function getMemoizedHotelPage() {
return serverClient().contentstack.hotelPage.get() return serverClient().contentstack.hotelPage.get()
}) })
export const getSelectedRoomAvailability = cache(
function getMemoizedSelectedRoomAvailability(
input: GetSelectedRoomAvailabilityInput
) {
return serverClient().hotel.availability.room(input)
}
)
export const getFooter = cache(async function getMemoizedFooter() { export const getFooter = cache(async function getMemoizedFooter() {
return serverClient().contentstack.base.footer() return serverClient().contentstack.base.footer()
}) })
@@ -352,3 +342,11 @@ export const getJumpToData = cache(async function getMemoizedJumpToData() {
return null return null
}) })
export const getSelectedRoomsAvailability = cache(
async function getMemoizedSelectedRoomsAvailability(
input: RoomsAvailabilityExtendedInputSchema
) {
return serverClient().hotel.availability.enterDetails(input)
}
)

View File

@@ -15,7 +15,6 @@ export default function RatesProvider({
children, children,
hotelType, hotelType,
isUserLoggedIn, isUserLoggedIn,
packages,
roomCategories, roomCategories,
roomsAvailability, roomsAvailability,
vat, vat,
@@ -35,11 +34,10 @@ export default function RatesProvider({
allergyRoom: intl.formatMessage({ id: "Allergy-friendly room" }), allergyRoom: intl.formatMessage({ id: "Allergy-friendly room" }),
petRoom: intl.formatMessage({ id: "Pet room" }), petRoom: intl.formatMessage({ id: "Pet room" }),
}, },
packages: packages ?? [],
pathname, pathname,
roomCategories, roomCategories,
roomsAvailability, roomsAvailability,
searchParams, searchParams: new URLSearchParams(searchParams),
vat, vat,
}) })
} }

View File

@@ -1,15 +1,10 @@
"use client" "use client"
import { useEffect } from "react"
import { REDEMPTION } from "@/constants/booking"
import { trpc } from "@/lib/trpc/client"
import { useRatesStore } from "@/stores/select-rate" import { useRatesStore } from "@/stores/select-rate"
import { RoomContext } from "@/contexts/SelectRate/Room" import { RoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { RoomProviderProps } from "@/types/providers/select-rate/room" import type { RoomProviderProps } from "@/types/providers/select-rate/room"
export default function RoomProvider({ export default function RoomProvider({
@@ -17,128 +12,28 @@ export default function RoomProvider({
idx, idx,
room, room,
}: RoomProviderProps) { }: RoomProviderProps) {
const lang = useLang() const { activeRoom, roomAvailability, roomPackages } = useRatesStore(
const { (state) => ({
activeRoom,
booking,
roomAvailability,
searchParams,
selectedFilter,
selectedPackages,
} = useRatesStore((state) => ({
activeRoom: state.activeRoom, activeRoom: state.activeRoom,
booking: state.booking, roomPackages: state.roomsPackages[idx],
roomAvailability: state.roomsAvailability?.[idx], roomAvailability: state.roomsAvailability?.[idx],
searchParams: state.searchParams,
selectedFilter: state.rooms[idx].selectedFilter,
selectedPackages: state.rooms[idx].selectedPackages,
}))
const { appendRegularRates, addRoomFeatures, ...actions } = room.actions
const roomNr = idx + 1
const redemptionSearch = searchParams.has("searchType")
? searchParams.get("searchType") === REDEMPTION
: false
const hasRedemptionRates =
redemptionSearch || room.rooms.some((room) => room.redemptions.length)
const hasCorporateChequeOrVoucherRates = room.rooms.some((room) =>
room.code.some((product) => {
if ("corporateCheque" in product) {
return product.corporateCheque.rateType === RateTypeEnum.CorporateCheque
} else if ("voucher" in product) {
return product.voucher.rateType === RateTypeEnum.Voucher
}
return false
}) })
) )
const roomNr = idx + 1
const dontShowRegularRates = const petRoomPackage = roomPackages.find(
hasRedemptionRates || hasCorporateChequeOrVoucherRates (pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
// Since input would be the same on single room as already
// done in useRoomsAvailability hook, data is already present
// and thus runs the appendRegularRates updater resulting in
// duplicate data
const enabled = !!(
booking.bookingCode &&
selectedFilter !== BookingCodeFilterEnum.Discounted &&
!dontShowRegularRates
) )
// Extra query needed to fetch regular rates upon user
// selecting to view all rates.
// TODO: Setup route to handle singular availability call
const { data, isFetched, isFetching } =
trpc.hotel.availability.roomsCombinedAvailability.useQuery(
{
adultsCount: [room.bookingRoom.adults],
childArray: room.bookingRoom.childrenInRoom
? [room.bookingRoom.childrenInRoom]
: undefined,
hotelId: booking.hotelId,
lang,
roomStayEndDate: booking.toDate,
roomStayStartDate: booking.fromDate,
},
{
enabled,
}
)
useEffect(() => {
if (isFetched && !isFetching && data?.length && enabled) {
const regularRates = data[0]
if ("roomConfigurations" in regularRates) {
appendRegularRates(regularRates.roomConfigurations)
}
}
}, [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={{
...room, ...room,
actions,
isActiveRoom: activeRoom === idx, isActiveRoom: activeRoom === idx,
isFetchingAdditionalRate: isFetched ? false : isFetching,
isFetchingRoomFeatures: isRoomFeaturesFetched
? false
: isRoomFeaturesFetching,
isMainRoom: roomNr === 1, isMainRoom: roomNr === 1,
petRoomPackage,
roomAvailability, roomAvailability,
roomPackages,
roomNr, roomNr,
totalRooms: room.rooms.length, totalRooms: room.rooms.length,
}} }}

View File

@@ -8,7 +8,7 @@ import {
serviceProcedure, serviceProcedure,
} from "@/server/trpc" } from "@/server/trpc"
import { getHotel } from "../hotels/query" import { getHotel } from "../hotels/utils"
import { encrypt } from "../utils/encryption" import { encrypt } from "../utils/encryption"
import { import {
bookingConfirmationInput, bookingConfirmationInput,

View File

@@ -16,7 +16,7 @@ import { contentStackUidWithServiceProcedure, router } from "@/server/trpc"
import { generateTag } from "@/utils/generateTag" import { generateTag } from "@/utils/generateTag"
import { getHotel } from "../../hotels/query" import { getHotel } from "../../hotels/utils"
import { getUrlsOfAllLanguages } from "../languageSwitcher/utils" import { getUrlsOfAllLanguages } from "../languageSwitcher/utils"
import { getMetadataInput } from "./input" import { getMetadataInput } from "./input"
import { getNonContentstackUrls, metadataSchema } from "./output" import { getNonContentstackUrls, metadataSchema } from "./output"

View File

@@ -4,6 +4,7 @@ import { Lang } from "@/constants/languages"
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 { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { Country } from "@/types/enums/country" import { Country } from "@/types/enums/country"
export const hotelsAvailabilityInputSchema = z.object({ export const hotelsAvailabilityInputSchema = z.object({
@@ -25,55 +26,88 @@ export const getHotelsByHotelIdsAvailabilityInputSchema = z.object({
bookingCode: z.string().optional().default(""), bookingCode: z.string().optional().default(""),
}) })
export const roomsCombinedAvailabilityInputSchema = z.object({ const childrenInRoomSchema = z
adultsCount: z.array(z.number()),
bookingCode: z.string().optional(),
childArray: z
.array(
z
.array( .array(
z.object({ z.object({
age: z.number(), age: z.number(),
bed: z.nativeEnum(ChildBedMapEnum), bed: z.nativeEnum(ChildBedMapEnum),
}) })
) )
.nullable() .optional()
)
.nullish(), const baseRoomSchema = z.object({
hotelId: z.string(), adults: z.number().int().min(1),
lang: z.nativeEnum(Lang), bookingCode: z.string().optional(),
rateCode: z.string().optional(), childrenInRoom: childrenInRoomSchema,
roomStayEndDate: z.string(), packages: z
roomStayStartDate: z.string(), .array(z.nativeEnum({ ...BreakfastPackageEnum, ...RoomPackageCodeEnum }))
redemption: z.boolean().optional().default(false),
roomFeatureCodesArray: z
.array(z.array(z.nativeEnum(RoomPackageCodeEnum)).nullable())
.optional(), .optional(),
}) })
export const selectedRoomAvailabilityInputSchema = z.object({ const selectedRoomSchema = z.object({
hotelId: z.string(), counterRateCode: z.string().optional(),
roomStayStartDate: z.string(),
roomStayEndDate: z.string(),
adults: z.number(),
children: z.string().optional(),
bookingCode: z.string().optional(),
rateCode: z.string(), rateCode: z.string(),
roomTypeCode: z.string(), roomTypeCode: z.string(),
counterRateCode: z.string().optional(),
packageCodes: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(),
lang: z.nativeEnum(Lang).optional(),
redemption: z.boolean().optional(),
}) })
export type GetSelectedRoomAvailabilityInput = z.input< const baseBookingSchema = z.object({
typeof selectedRoomAvailabilityInputSchema bookingCode: z.string().optional(),
> fromDate: z.string(),
export const ratesInputSchema = z.object({
hotelId: z.string(), hotelId: z.string(),
searchType: z.string().optional(),
toDate: z.string(),
}) })
export const selectRateRoomsAvailabilityInputSchema = z.object({
booking: baseBookingSchema.extend({
rooms: z.array(
baseRoomSchema.extend({
rateCode: z.string().optional(),
roomTypeCode: z.string().optional(),
})
),
}),
lang: z.nativeEnum(Lang),
})
export const selectRateRoomAvailabilityInputSchema = z.object({
booking: baseBookingSchema.extend({
room: baseRoomSchema.extend({
rateCode: z.string().optional(),
roomTypeCode: z.string().optional(),
}),
}),
lang: z.nativeEnum(Lang),
})
export const enterDetailsRoomsAvailabilityInputSchema = z.object({
booking: baseBookingSchema.extend({
rooms: z.array(baseRoomSchema.merge(selectedRoomSchema)),
}),
lang: z.nativeEnum(Lang),
})
export const myStayRoomAvailabilityInputSchema = z.object({
booking: baseBookingSchema.extend({
room: baseRoomSchema.merge(selectedRoomSchema),
}),
lang: z.nativeEnum(Lang),
})
export const roomFeaturesInputSchema = z.object({
adults: z.number(),
childrenInRoom: childrenInRoomSchema,
endDate: z.string(),
hotelId: z.string(),
lang: z.nativeEnum(Lang),
roomFeatureCodes: z
.array(z.nativeEnum({ ...BreakfastPackageEnum, ...RoomPackageCodeEnum }))
.optional(),
startDate: z.string(),
})
export type RoomFeaturesInput = z.input<typeof roomFeaturesInputSchema>
export const hotelInputSchema = z.object({ export const hotelInputSchema = z.object({
hotelId: z.string(), hotelId: z.string(),
isCardOnlyPayment: z.boolean(), isCardOnlyPayment: z.boolean(),
@@ -91,7 +125,7 @@ export const getHotelsByCSFilterInput = z.object({
hotelsToInclude: z.array(z.string()), hotelsToInclude: z.array(z.string()),
}) })
export interface GetHotelsByCSFilterInput export interface GetHotelsByCSFilterInput
extends z.infer<typeof getHotelsByCSFilterInput> {} extends z.infer<typeof getHotelsByCSFilterInput> { }
export const nearbyHotelIdsInput = z.object({ export const nearbyHotelIdsInput = z.object({
hotelId: z.string(), hotelId: z.string(),
@@ -127,13 +161,13 @@ export const ancillaryPackageInputSchema = z.object({
}) })
export const roomPackagesInputSchema = z.object({ export const roomPackagesInputSchema = z.object({
hotelId: z.string(),
startDate: z.string(),
endDate: z.string(),
adults: z.number(), adults: z.number(),
children: z.number().optional().default(0), children: z.number().optional().default(0),
packageCodes: z.array(z.string()).optional().default([]), endDate: z.string(),
hotelId: z.string(),
lang: z.nativeEnum(Lang), lang: z.nativeEnum(Lang),
packageCodes: z.array(z.string()).optional().default([]),
startDate: z.string(),
}) })
export const cityCoordinatesInputSchema = z.object({ export const cityCoordinatesInputSchema = z.object({
city: z.string(), city: z.string(),
@@ -167,22 +201,3 @@ export const getLocationsInput = z.object({
export const getLocationsUrlsInput = z.object({ export const getLocationsUrlsInput = z.object({
lang: z.nativeEnum(Lang), lang: z.nativeEnum(Lang),
}) })
export const roomFeaturesInputSchema = z.object({
hotelId: z.string(),
startDate: z.string(),
endDate: z.string(),
adults: z.number(),
childrenInRoom: z
.array(
z.object({
age: z.number(),
bed: z.nativeEnum(ChildBedMapEnum),
})
)
.optional(),
roomFeatureCodes: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(),
roomIndex: z.number().optional(),
})
export type RoomFeaturesInput = z.input<typeof roomFeaturesInputSchema>

View File

@@ -70,14 +70,10 @@ export const metrics = {
fail: meter.createCounter("trpc.hotel.packages.get-fail"), fail: meter.createCounter("trpc.hotel.packages.get-fail"),
success: meter.createCounter("trpc.hotel.packages.get-success"), success: meter.createCounter("trpc.hotel.packages.get-success"),
}, },
roomsCombinedAvailability: { roomsAvailability: {
counter: meter.createCounter("trpc.hotel.roomsCombinedAvailability.rooms"), counter: meter.createCounter("trpc.hotel.roomsAvailability.rooms"),
fail: meter.createCounter( fail: meter.createCounter("trpc.hotel.roomsAvailability.rooms-fail"),
"trpc.hotel.roomsCombinedAvailability.rooms-fail" success: meter.createCounter("trpc.hotel.roomsAvailability.rooms-success"),
),
success: meter.createCounter(
"trpc.hotel.roomsCombinedAvailability.rooms-success"
),
}, },
selectedRoomAvailability: { selectedRoomAvailability: {
counter: meter.createCounter("trpc.hotel.availability.room"), counter: meter.createCounter("trpc.hotel.availability.room"),

View File

@@ -23,10 +23,10 @@ import {
breakfastPackageSchema, breakfastPackageSchema,
packageSchema, packageSchema,
} from "./schemas/packages" } from "./schemas/packages"
import { rateSchema } from "./schemas/rate"
import { relationshipsSchema } from "./schemas/relationships" import { relationshipsSchema } from "./schemas/relationships"
import { roomConfigurationSchema } from "./schemas/roomAvailability/configuration" import { roomConfigurationSchema } from "./schemas/roomAvailability/configuration"
import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition" import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition"
import { sortRoomConfigs } from "./utils"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
@@ -42,7 +42,6 @@ import type {
import type { import type {
Product, Product,
RateDefinition, RateDefinition,
RoomConfiguration,
} from "@/types/trpc/routers/hotel/roomAvailability" } from "@/types/trpc/routers/hotel/roomAvailability"
// NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html // NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html
@@ -136,18 +135,6 @@ const cancellationRules = {
NotCancellable: 0, NotCancellable: 0,
} as const } as const
// Used to ensure `Available` rooms
// are shown before all `NotAvailable`
const statusLookup = {
[AvailabilityEnum.Available]: 1,
[AvailabilityEnum.NotAvailable]: 2,
}
function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) {
// @ts-expect-error - array indexing
return statusLookup[a.status] - statusLookup[b.status]
}
export const roomsAvailabilitySchema = z export const roomsAvailabilitySchema = z
.object({ .object({
data: z.object({ data: z.object({
@@ -158,49 +145,11 @@ export const roomsAvailabilitySchema = z
hotelId: z.number(), hotelId: z.number(),
mustBeGuaranteed: z.boolean().optional(), mustBeGuaranteed: z.boolean().optional(),
occupancy: occupancySchema.optional(), occupancy: occupancySchema.optional(),
packages: z.array(packageSchema).optional().default([]),
rateDefinitions: z.array(rateDefinitionSchema), rateDefinitions: z.array(rateDefinitionSchema),
roomConfigurations: z roomConfigurations: z.array(roomConfigurationSchema),
.array(roomConfigurationSchema)
.transform((data) => {
// Initial sort to guarantee if one bed is NotAvailable and whereas
// the other is Available to make sure data is added to the correct
// roomConfig
const configs = data.sort(sortRoomConfigs)
const roomConfigs = new Map<string, RoomConfiguration>()
for (const roomConfig of configs) {
if (roomConfigs.has(roomConfig.roomType)) {
const currentRoomConf = roomConfigs.get(roomConfig.roomType)
if (currentRoomConf) {
currentRoomConf.features = roomConfig.features.reduce(
(feats, feature) => {
const currentFeatureIndex = feats.findIndex(
(f) => f.code === feature.code
)
if (currentFeatureIndex !== -1) {
feats[currentFeatureIndex].inventory =
feats[currentFeatureIndex].inventory +
feature.inventory
} else {
feats.push(feature)
}
return feats
},
currentRoomConf.features
)
currentRoomConf.roomsLeft =
currentRoomConf.roomsLeft + roomConfig.roomsLeft
roomConfigs.set(currentRoomConf.roomType, currentRoomConf)
}
} else {
roomConfigs.set(roomConfig.roomType, roomConfig)
}
}
return Array.from(roomConfigs.values())
}), }),
}), }),
relationships: relationshipsSchema.optional(),
type: z.string().optional(),
}),
}) })
.transform(({ data: { attributes } }) => { .transform(({ data: { attributes } }) => {
const rateDefinitions = attributes.rateDefinitions const rateDefinitions = attributes.rateDefinitions
@@ -425,8 +374,6 @@ export const roomsAvailabilitySchema = z
} }
}) })
export const ratesSchema = z.array(rateSchema)
export const citiesByCountrySchema = z.object({ export const citiesByCountrySchema = z.object({
data: z.array( data: z.array(
citySchema.transform((data) => { citySchema.transform((data) => {
@@ -597,17 +544,6 @@ export const packagesSchema = z
hotelId: z.number(), hotelId: z.number(),
packages: z.array(packageSchema).default([]), packages: z.array(packageSchema).default([]),
}), }),
relationships: z
.object({
links: z.array(
z.object({
type: z.string(),
url: z.string(),
})
),
})
.optional(),
type: z.string(),
}) })
.optional(), .optional(),
}) })

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
import { z } from "zod"
const flexibilityPrice = z.object({
member: z.number(),
standard: z.number(),
})
export const rateSchema = z.object({
breakfastIncluded: z.boolean(),
description: z.string(),
id: z.number(),
imageSrc: z.string(),
name: z.string(),
prices: z.object({
currency: z.string(),
freeCancellation: flexibilityPrice,
freeRebooking: flexibilityPrice,
nonRefundable: flexibilityPrice,
}),
size: z.string(),
})

View File

@@ -1,104 +0,0 @@
[
{
"id": 1,
"name": "Cabin",
"description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.",
"size": "17 - 24 m² (1 - 2 persons)",
"imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg",
"breakfastIncluded": false,
"prices": {
"currency": "SEK",
"nonRefundable": {
"standard": 2315,
"member": 2247
},
"freeRebooking": { "standard": 2437, "member": 2365 },
"freeCancellation": { "standard": 2620, "member": 2542 }
}
},
{
"id": 2,
"name": "Standard",
"description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.",
"size": "19 - 30 m² (1 - 2 persons)",
"imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg",
"breakfastIncluded": false,
"prices": {
"currency": "SEK",
"nonRefundable": {
"standard": 2315,
"member": 2247
},
"freeRebooking": { "standard": 2437, "member": 2365 },
"freeCancellation": { "standard": 2620, "member": 2542 }
}
},
{
"id": 3,
"name": "Superior",
"description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.",
"size": "22 - 40 m² (1 - 3 persons)",
"imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg",
"breakfastIncluded": false,
"prices": {
"currency": "SEK",
"nonRefundable": {
"standard": 2315,
"member": 2247
},
"freeRebooking": { "standard": 2437, "member": 2365 },
"freeCancellation": { "standard": 2620, "member": 2542 }
}
},
{
"id": 4,
"name": "Superior Family",
"description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.",
"size": "29 - 49 m² (3 - 4 persons)",
"imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg",
"breakfastIncluded": false,
"prices": {
"currency": "SEK",
"nonRefundable": {
"standard": 2315,
"member": 2247
},
"freeRebooking": { "standard": 2437, "member": 2365 },
"freeCancellation": { "standard": 2620, "member": 2542 }
}
},
{
"id": 5,
"name": "Superior PLUS",
"description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.",
"size": "21 - 28 m² (2 - 3 persons)",
"imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg",
"breakfastIncluded": false,
"prices": {
"currency": "SEK",
"nonRefundable": {
"standard": 2315,
"member": 2247
},
"freeRebooking": { "standard": 2437, "member": 2365 },
"freeCancellation": { "standard": 2620, "member": 2542 }
}
},
{
"id": 6,
"name": "Junior Suite",
"description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.",
"size": "35 - 43 m² (2 - 4 persons)",
"imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg",
"breakfastIncluded": false,
"prices": {
"currency": "SEK",
"nonRefundable": {
"standard": 2315,
"member": 2247
},
"freeRebooking": { "standard": 2437, "member": 2365 },
"freeCancellation": { "standard": 2620, "member": 2542 }
}
}
]

View File

@@ -1,14 +1,19 @@
import deepmerge from "deepmerge" import deepmerge from "deepmerge"
import stringify from "json-stable-stringify-without-jsonify"
import { REDEMPTION } from "@/constants/booking"
import { Lang } from "@/constants/languages" import { Lang } from "@/constants/languages"
import { env } from "@/env/server" import { env } from "@/env/server"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { badRequestError } from "@/server/errors/trpc" import { badRequestError } from "@/server/errors/trpc"
import { toApiLang } from "@/server/utils" import { toApiLang } from "@/server/utils"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import { getCacheClient } from "@/services/dataCache" import { getCacheClient } from "@/services/dataCache"
import { cache } from "@/utils/cache"
import { getHotelPageUrls } from "../contentstack/hotelPage/utils" import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
import { type RoomFeaturesInput, roomPackagesInputSchema } from "./input"
import { metrics } from "./metrics" import { metrics } from "./metrics"
import { import {
type Cities, type Cities,
@@ -16,15 +21,31 @@ import {
citiesSchema, citiesSchema,
countriesSchema, countriesSchema,
getHotelIdsSchema, getHotelIdsSchema,
hotelsAvailabilitySchema,
hotelSchema,
locationsSchema, locationsSchema,
packagesSchema,
roomFeaturesSchema,
roomsAvailabilitySchema, roomsAvailabilitySchema,
} from "./output" } from "./output"
import { getHotel } from "./query"
import type { z } from "zod"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest" import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
import type { DestinationPagesHotelData } from "@/types/hotel" import type {
DestinationPagesHotelData,
Room as RoomCategory,
} from "@/types/hotel"
import type { PackagesInput } from "@/types/requests/packages"
import type {
HotelsAvailabilityInputSchema,
HotelsByHotelIdsAvailabilityInputSchema,
RoomsAvailabilityInputRoom,
RoomsAvailabilityInputSchema,
} from "@/types/trpc/routers/hotel/availability"
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
import type { import type {
CitiesGroupedByCountry, CitiesGroupedByCountry,
CityLocation, CityLocation,
@@ -34,9 +55,9 @@ import type {
Products, Products,
RateDefinition, RateDefinition,
RedemptionsProduct, RedemptionsProduct,
RoomConfiguration,
} from "@/types/trpc/routers/hotel/roomAvailability" } from "@/types/trpc/routers/hotel/roomAvailability"
import type { Endpoint } from "@/lib/api/endpoints" import type { Endpoint } from "@/lib/api/endpoints"
import type { selectedRoomAvailabilityInputSchema } from "./input"
export function getPoiGroupByCategoryName(category: string | undefined) { export function getPoiGroupByCategoryName(category: string | undefined) {
if (!category) return PointOfInterestGroupEnum.LOCATION if (!category) return PointOfInterestGroupEnum.LOCATION
@@ -608,39 +629,38 @@ function findProduct(product: Products, rateDefinition: RateDefinition) {
} }
} }
export async function getSelectedRoomAvailability( export const getHotel = cache(
input: z.input<typeof selectedRoomAvailabilityInputSchema>, async (input: HotelInput, serviceToken: string) => {
lang: string, const callable = async function (
serviceToken: string, hotelId: HotelInput["hotelId"],
userPoints?: number language: HotelInput["language"],
) { isCardOnlyPayment?: HotelInput["isCardOnlyPayment"]
const { ) {
adults, /**
bookingCode, * Since API expects the params appended and not just
children, * a comma separated string we need to initialize the
* SearchParams with a sequence of pairs
* (include=City&include=NearbyHotels&include=Restaurants etc.)
**/
const params = new URLSearchParams([
["include", "AdditionalData"],
["include", "City"],
["include", "NearbyHotels"],
["include", "Restaurants"],
["include", "RoomCategories"],
["language", toApiLang(language)],
])
metrics.hotel.counter.add(1, {
hotelId, hotelId,
roomStayEndDate, language,
roomStayStartDate, })
redemption,
} = input
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
...(redemption && { isRedemption: "true" }),
language: lang,
}
metrics.selectedRoomAvailability.counter.add(1, input)
console.info( console.info(
"api.hotels.selectedRoomAvailability start", "api.hotels.hotelData start",
JSON.stringify({ query: { hotelId: input.hotelId, params } }) JSON.stringify({ query: { hotelId, params: params.toString() } })
) )
const apiResponseAvailability = await api.get(
api.endpoints.v1.Availability.hotel(hotelId.toString()), const apiResponse = await api.get(
api.endpoints.v1.Hotel.Hotels.hotel(hotelId),
{ {
headers: { headers: {
Authorization: `Bearer ${serviceToken}`, Authorization: `Bearer ${serviceToken}`,
@@ -649,10 +669,144 @@ export async function getSelectedRoomAvailability(
params params
) )
if (!apiResponseAvailability.ok) { if (!apiResponse.ok) {
const text = await apiResponseAvailability.text() const text = await apiResponse.text()
metrics.selectedRoomAvailability.fail.add(1, { metrics.hotel.fail.add(1, {
hotelId, hotelId,
language,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.hotels.hotelData error",
JSON.stringify({
query: { hotelId, params: params.toString() },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validateHotelData = hotelSchema.safeParse(apiJson)
if (!validateHotelData.success) {
metrics.hotel.fail.add(1, {
hotelId,
language,
error_type: "validation_error",
error: JSON.stringify(validateHotelData.error),
})
console.error(
"api.hotels.hotelData validation error",
JSON.stringify({
query: { hotelId, params: params.toString() },
error: validateHotelData.error,
})
)
throw badRequestError()
}
metrics.hotel.success.add(1, {
hotelId,
language,
})
console.info(
"api.hotels.hotelData success",
JSON.stringify({
query: { hotelId, params: params.toString() },
})
)
const hotelData = validateHotelData.data
if (isCardOnlyPayment) {
hotelData.hotel.merchantInformationData.alternatePaymentOptions = []
}
const gallery = hotelData.additionalData?.gallery
if (gallery) {
const smallerImages = gallery.smallerImages
const hotelGalleryImages =
hotelData.hotel.hotelType === HotelTypeEnum.Signature
? smallerImages.slice(0, 10)
: smallerImages.slice(0, 6)
hotelData.hotel.galleryImages = hotelGalleryImages
}
return hotelData
}
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`,
async () => {
return callable(input.hotelId, input.language, input.isCardOnlyPayment)
},
env.CACHE_TIME_HOTELS
)
}
)
export async function getHotelsAvailabilityByCity(
input: HotelsAvailabilityInputSchema,
apiLang: string,
token: string, // Either service token or user access token in case of redemption search
userPoints: number = 0
) {
const {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
redemption,
} = input
const params: Record<string, string | number> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
...(redemption ? { isRedemption: "true" } : {}),
language: apiLang,
}
metrics.hotelsAvailability.counter.add(1, {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
redemption,
})
console.info(
"api.hotels.hotelsAvailability start",
JSON.stringify({ query: { cityId, params } })
)
const apiResponse = await api.get(
api.endpoints.v1.Availability.city(cityId),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.hotelsAvailability.fail.add(1, {
cityId,
roomStayStartDate, roomStayStartDate,
roomStayEndDate, roomStayEndDate,
adults, adults,
@@ -660,31 +814,173 @@ export async function getSelectedRoomAvailability(
bookingCode, bookingCode,
error_type: "http_error", error_type: "http_error",
error: JSON.stringify({ error: JSON.stringify({
status: apiResponseAvailability.status, status: apiResponse.status,
statusText: apiResponseAvailability.statusText, statusText: apiResponse.statusText,
text, text,
}), }),
}) })
console.error( console.error(
"api.hotels.selectedRoomAvailability error", "api.hotels.hotelsAvailability error",
JSON.stringify({ JSON.stringify({
query: { hotelId, params }, query: { cityId, params },
error: { error: {
status: apiResponseAvailability.status, status: apiResponse.status,
statusText: apiResponseAvailability.statusText, statusText: apiResponse.statusText,
text, text,
}, },
}) })
) )
throw new Error("Failed to fetch selected room availability") throw new Error("Failed to fetch hotels availability by city")
} }
const apiJsonAvailability = await apiResponseAvailability.json()
const validateAvailabilityData = const apiJson = await apiResponse.json()
roomsAvailabilitySchema.safeParse(apiJsonAvailability) const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) { if (!validateAvailabilityData.success) {
metrics.selectedRoomAvailability.fail.add(1, { metrics.hotelsAvailability.fail.add(1, {
hotelId, cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
redemption,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
console.error(
"api.hotels.hotelsAvailability validation error",
JSON.stringify({
query: { cityId, params },
error: validateAvailabilityData.error,
})
)
throw badRequestError()
}
metrics.hotelsAvailability.success.add(1, {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
redemption,
})
console.info(
"api.hotels.hotelsAvailability success",
JSON.stringify({
query: { cityId, params: params },
})
)
if (redemption) {
validateAvailabilityData.data.data.forEach((data) => {
data.attributes.productType?.redemptions?.forEach((r) => {
r.hasEnoughPoints = userPoints >= r.localPrice.pointsPerStay
})
})
}
return {
availability: validateAvailabilityData.data.data.flatMap(
(hotels) => hotels.attributes
),
}
}
export async function getHotelsAvailabilityByHotelIds(
input: HotelsByHotelIdsAvailabilityInputSchema,
apiLang: string,
serviceToken: string
) {
const {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
} = input
const params = new URLSearchParams([
["roomStayStartDate", roomStayStartDate],
["roomStayEndDate", roomStayEndDate],
["adults", adults.toString()],
["children", children ?? ""],
["bookingCode", bookingCode],
["language", apiLang],
])
const cacheClient = await getCacheClient()
return cacheClient.cacheOrGet(
`${apiLang}:hotels:availability:${hotelIds.join(",")}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`,
async () => {
/**
* Since API expects the params appended and not just
* a comma separated string we need to initialize the
* SearchParams with a sequence of pairs
* (hotelIds=810&hotelIds=879&hotelIds=222 etc.)
**/
hotelIds.forEach((hotelId) =>
params.append("hotelIds", hotelId.toString())
)
metrics.hotelsByHotelIdAvailability.counter.add(1, {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.hotelsByHotelIdAvailability start",
JSON.stringify({ query: { params } })
)
const apiResponse = await api.get(
api.endpoints.v1.Availability.hotels(),
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.hotelsByHotelIdAvailability.fail.add(1, {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.hotels.hotelsByHotelIdAvailability error",
JSON.stringify({
query: { params },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
throw new Error("Failed to fetch hotels availability by hotelIds")
}
const apiJson = await apiResponse.json()
const validateAvailabilityData =
hotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
metrics.hotelsByHotelIdAvailability.fail.add(1, {
hotelIds,
roomStayStartDate, roomStayStartDate,
roomStayEndDate, roomStayEndDate,
adults, adults,
@@ -694,27 +990,435 @@ export async function getSelectedRoomAvailability(
error: JSON.stringify(validateAvailabilityData.error), error: JSON.stringify(validateAvailabilityData.error),
}) })
console.error( console.error(
"api.hotels.selectedRoomAvailability validation error", "api.hotels.hotelsByHotelIdAvailability validation error",
JSON.stringify({ JSON.stringify({
query: { hotelId, params }, query: { params },
error: validateAvailabilityData.error, error: validateAvailabilityData.error,
}) })
) )
throw badRequestError() throw badRequestError()
} }
metrics.hotelsByHotelIdAvailability.success.add(1, {
const { rateDefinitions, roomConfigurations } = validateAvailabilityData.data hotelIds,
roomStayStartDate,
const rateDefinition = rateDefinitions.find( roomStayEndDate,
(rd) => rd.rateCode === input.rateCode adults,
children,
bookingCode,
})
console.info(
"api.hotels.hotelsByHotelIdAvailability success",
JSON.stringify({
query: { params },
})
) )
return {
availability: validateAvailabilityData.data.data.flatMap(
(hotels) => hotels.attributes
),
}
},
env.CACHE_TIME_CITY_SEARCH
)
}
async function getRoomFeaturesInventory(
input: RoomFeaturesInput,
token: string
) {
const {
adults,
childrenInRoom,
endDate,
hotelId,
roomFeatureCodes,
startDate,
} = input
const cacheClient = await getCacheClient()
return cacheClient.cacheOrGet(
stringify(input),
async function () {
const params = {
adults,
hotelId,
roomFeatureCode: roomFeatureCodes,
roomStayEndDate: endDate,
roomStayStartDate: startDate,
...(childrenInRoom?.length && {
children: generateChildrenString(childrenInRoom),
}),
}
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
},
"5m"
)
}
export async function getPackages(
rawInput: PackagesInput,
serviceToken: string
) {
const parsedInput = roomPackagesInputSchema.safeParse(rawInput)
if (!parsedInput.success) {
console.info(`Failed to parse input for Get Packages`)
console.error(parsedInput.error)
return null
}
const input = parsedInput.data
const cacheClient = await getCacheClient()
return cacheClient.cacheOrGet(
stringify(input),
async function () {
const {
adults,
children,
endDate,
hotelId,
lang,
packageCodes,
startDate,
} = input
const apiLang = toApiLang(lang)
const searchParams = new URLSearchParams({
adults: adults.toString(),
children: children.toString(),
endDate,
language: apiLang,
startDate,
})
packageCodes.forEach((code) => {
searchParams.append("packageCodes", code)
})
const params = searchParams.toString()
metrics.packages.counter.add(1, {
hotelId,
})
console.info(
"api.hotels.packages start",
JSON.stringify({ query: { hotelId, params } })
)
const apiResponse = await api.get(
api.endpoints.v1.Package.Packages.hotel(hotelId),
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
searchParams
)
if (!apiResponse.ok) {
metrics.packages.fail.add(1, {
hotelId,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
}),
})
console.error(
"api.hotels.packages error",
JSON.stringify({ query: { hotelId, params } })
)
return null
}
const apiJson = await apiResponse.json()
const validatedPackagesData = packagesSchema.safeParse(apiJson)
if (!validatedPackagesData.success) {
metrics.packages.fail.add(1, {
hotelId,
error_type: "validation_error",
error: JSON.stringify(validatedPackagesData.error),
})
console.error(
"api.hotels.packages validation error",
JSON.stringify({
query: { hotelId, params },
error: validatedPackagesData.error,
})
)
return null
}
metrics.packages.success.add(1, {
hotelId,
})
console.info(
"api.hotels.packages success",
JSON.stringify({ query: { hotelId, params: params } })
)
return validatedPackagesData.data
},
"3h"
)
}
export async function getRoomsAvailability(
input: RoomsAvailabilityInputSchema,
token: string,
serviceToken: string,
userPoints: number | undefined
) {
const {
booking: { bookingCode, fromDate, hotelId, rooms, searchType, toDate },
lang,
} = input
const redemption = searchType === REDEMPTION
const apiLang = toApiLang(lang)
const kids = rooms
.map((r) => r.childrenInRoom)
.filter(Boolean)
.map((kid) => JSON.stringify(kid))
const metricsData = {
adultsCount: rooms.map((r) => r.adults),
bookingCode,
childArray: kids.length ? kids : undefined,
hotelId,
roomStayEndDate: toDate,
roomStayStartDate: fromDate,
}
metrics.roomsAvailability.counter.add(1, metricsData)
console.info(
"api.hotels.roomsAvailability start",
JSON.stringify({ query: { hotelId, params: metricsData } })
)
const baseCacheKey = {
bookingCode,
fromDate,
hotelId,
lang,
searchType,
toDate,
}
const cacheClient = await getCacheClient()
const availabilityResponses = await Promise.allSettled(
rooms.map(async (room: RoomsAvailabilityInputRoom) => {
const cacheKey = {
...baseCacheKey,
room,
}
return await cacheClient.cacheOrGet(
stringify(cacheKey),
async function () {
{
const params = {
adults: room.adults,
language: apiLang,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
...(room.childrenInRoom?.length && {
children: generateChildrenString(room.childrenInRoom),
}),
...(room.bookingCode && { bookingCode: room.bookingCode }),
...(redemption && { isRedemption: "true" }),
}
const apiResponse = await api.get(
api.endpoints.v1.Availability.hotel(hotelId),
{
headers: {
Authorization: `Bearer ${token}`,
},
next: {
revalidate: 60,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.roomsAvailability.fail.add(1, metricsData)
console.error("Failed API call", { params, text })
return { error: "http_error", details: text }
}
const apiJson = await apiResponse.json()
const validateAvailabilityData =
roomsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
console.error("Validation error", {
params,
error: validateAvailabilityData.error,
})
metrics.roomsAvailability.fail.add(1, metricsData)
return {
error: "validation_error",
details: validateAvailabilityData.error,
}
}
if (redemption) {
for (const roomConfig of validateAvailabilityData.data
.roomConfigurations) {
for (const product of roomConfig.redemptions) {
if (userPoints) {
product.redemption.hasEnoughPoints =
userPoints >= product.redemption.localPrice.pointsPerStay
}
}
}
}
const roomFeatures = await getPackages(
{
adults: room.adults,
children: room.childrenInRoom?.length,
endDate: input.booking.toDate,
hotelId: input.booking.hotelId,
lang,
packageCodes: [
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
RoomPackageCodeEnum.PET_ROOM,
],
startDate: input.booking.fromDate,
},
serviceToken
)
if (roomFeatures) {
validateAvailabilityData.data.packages = roomFeatures
}
// Fetch packages
if (room.packages?.length) {
const roomFeaturesInventory = await getRoomFeaturesInventory(
{
adults: room.adults,
childrenInRoom: room.childrenInRoom,
endDate: input.booking.toDate,
hotelId: input.booking.hotelId,
lang,
roomFeatureCodes: room.packages,
startDate: input.booking.fromDate,
},
serviceToken
)
if (roomFeaturesInventory) {
const features = roomFeaturesInventory.reduce<
Record<string, number>
>((fts, feat) => {
fts[feat.roomTypeCode] = feat.features?.[0]?.inventory ?? 0
return fts
}, {})
const updatedRoomConfigurations =
validateAvailabilityData.data.roomConfigurations
// This filter is needed since we can get availability
// back from roomFeatures yet the availability call
// says there are no rooms left...
.filter((rc) => rc.roomsLeft)
.filter((rc) => features?.[rc.roomTypeCode])
.map((rc) => ({
...rc,
roomsLeft: features[rc.roomTypeCode],
status: AvailabilityEnum.Available,
}))
validateAvailabilityData.data.roomConfigurations =
updatedRoomConfigurations
}
}
return validateAvailabilityData.data
}
},
"1m"
)
})
)
metrics.roomsAvailability.success.add(1, metricsData)
const data = availabilityResponses.map((availability) => {
if (availability.status === "fulfilled") {
return availability.value
}
return {
details: availability.reason,
error: "request_failure",
}
})
return data
}
export function getSelectedRoomAvailability(
rateCode: string,
rateDefinitions: RateDefinition[],
roomConfigurations: RoomConfiguration[],
roomTypeCode: string,
userPoints: number | undefined
) {
const rateDefinition = rateDefinitions.find((rd) => rd.rateCode === rateCode)
if (!rateDefinition) { if (!rateDefinition) {
return null return null
} }
const selectedRoom = roomConfigurations.find( const selectedRoom = roomConfigurations.find(
(room) => (room) =>
room.roomTypeCode === input.roomTypeCode && room.roomTypeCode === roomTypeCode &&
room.products.find((product) => findProduct(product, rateDefinition)) room.products.find((product) => findProduct(product, rateDefinition))
) )
@@ -753,3 +1457,90 @@ export async function getSelectedRoomAvailability(
selectedRoom, selectedRoom,
} }
} }
// Used to ensure `Available` rooms
// are shown before all `NotAvailable`
const statusLookup = {
[AvailabilityEnum.Available]: 1,
[AvailabilityEnum.NotAvailable]: 2,
}
export function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) {
// @ts-expect-error - array indexing
return statusLookup[a.status] - statusLookup[b.status]
}
export function getBedTypes(
rooms: RoomConfiguration[],
roomType: string,
roomCategories?: RoomCategory[]
) {
if (!roomCategories) {
return []
}
return rooms
.filter((room) => room.roomType === roomType)
.map((availRoom) => {
const matchingRoom = roomCategories
?.find((room) =>
room.roomTypes
.map((roomType) => roomType.code)
.includes(availRoom.roomTypeCode)
)
?.roomTypes.find((roomType) => roomType.code === availRoom.roomTypeCode)
if (matchingRoom) {
return {
description: matchingRoom.description,
size: matchingRoom.mainBed.widthRange,
value: matchingRoom.code,
type: matchingRoom.mainBed.type,
extraBed: matchingRoom.fixedExtraBed
? {
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
: undefined,
}
}
})
.filter((bed): bed is BedTypeSelection => Boolean(bed))
}
export function mergeRoomTypes(roomConfigurations: RoomConfiguration[]) {
// Initial sort to guarantee if one bed is NotAvailable and whereas
// the other is Available to make sure data is added to the correct
// roomConfig
roomConfigurations.sort(sortRoomConfigs)
const roomConfigs = new Map<string, RoomConfiguration>()
for (const roomConfig of roomConfigurations) {
if (roomConfigs.has(roomConfig.roomType)) {
const currentRoomConf = roomConfigs.get(roomConfig.roomType)
if (currentRoomConf) {
currentRoomConf.features = roomConfig.features.reduce(
(feats, feature) => {
const currentFeatureIndex = feats.findIndex(
(f) => f.code === feature.code
)
if (currentFeatureIndex !== -1) {
feats[currentFeatureIndex].inventory =
feats[currentFeatureIndex].inventory + feature.inventory
} else {
feats.push(feature)
}
return feats
},
currentRoomConf.features
)
currentRoomConf.roomsLeft =
currentRoomConf.roomsLeft + roomConfig.roomsLeft
roomConfigs.set(currentRoomConf.roomType, currentRoomConf)
}
} else {
roomConfigs.set(roomConfig.roomType, roomConfig)
}
}
return Array.from(roomConfigs.values())
}

View File

@@ -107,15 +107,13 @@ export function isRoomPackageCode(
) )
} }
export function filterRoomsBySelectedPackages( export function clearRoomSelectionFromUrl(
selectedPackages: RoomPackageCodeEnum[], roomIdx: number,
rooms: RoomConfiguration[] searchParams: URLSearchParams
) { ) {
if (!selectedPackages.length) { searchParams.delete(`room[${roomIdx}].bookingCode`)
return rooms searchParams.delete(`room[${roomIdx}].counterratecode`)
} searchParams.delete(`room[${roomIdx}].ratecode`)
searchParams.delete(`room[${roomIdx}].roomtype`)
return rooms.filter((r) => return searchParams
selectedPackages.every((pkg) => r.features.find((f) => f.code === pkg))
)
} }

View File

@@ -1,12 +1,13 @@
import { produce } from "immer" import { produce } from "immer"
import { ReadonlyURLSearchParams } from "next/navigation"
import { useContext } from "react" import { useContext } from "react"
import { create, useStore } from "zustand" import { create, useStore } from "zustand"
import { REDEMPTION } from "@/constants/booking"
import { RatesContext } from "@/contexts/Rates" import { RatesContext } from "@/contexts/Rates"
import { import {
filterRoomsBySelectedPackages, clearRoomSelectionFromUrl,
findProductInRoom, findProductInRoom,
findSelectedRate, findSelectedRate,
} from "./helpers" } from "./helpers"
@@ -14,18 +15,15 @@ import {
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter" import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
import { RateTypeEnum } from "@/types/enums/rateType" import { RateTypeEnum } from "@/types/enums/rateType"
import type { Package, Packages } from "@/types/requests/packages"
import type { InitialState, RatesState } from "@/types/stores/rates" import type { InitialState, RatesState } from "@/types/stores/rates"
import type { import type { PriceProduct } from "@/types/trpc/routers/hotel/roomAvailability"
PriceProduct,
RoomConfiguration,
} from "@/types/trpc/routers/hotel/roomAvailability"
export function createRatesStore({ export function createRatesStore({
booking, booking,
hotelType, hotelType,
isUserLoggedIn, isUserLoggedIn,
labels, labels,
packages,
pathname, pathname,
roomCategories, roomCategories,
roomsAvailability, roomsAvailability,
@@ -36,34 +34,28 @@ export function createRatesStore({
{ {
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM, code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
description: labels.accessibilityRoom, description: labels.accessibilityRoom,
itemCode: packages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
)?.itemCode,
}, },
{ {
code: RoomPackageCodeEnum.ALLERGY_ROOM, code: RoomPackageCodeEnum.ALLERGY_ROOM,
description: labels.allergyRoom, description: labels.allergyRoom,
itemCode: packages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM
)?.itemCode,
}, },
{ {
code: RoomPackageCodeEnum.PET_ROOM, code: RoomPackageCodeEnum.PET_ROOM,
description: labels.petRoom, description: labels.petRoom,
itemCode: packages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)?.itemCode,
}, },
] ]
let roomConfigurations: RatesState["roomConfigurations"] = [] const roomsPackages: NonNullable<Packages>[] = []
const roomConfigurations: RatesState["roomConfigurations"] = []
if (roomsAvailability) { if (roomsAvailability) {
for (const availability of roomsAvailability) { for (const availability of roomsAvailability) {
if ("error" in availability) { if ("error" in availability) {
// Availability request failed, default to empty array // Availability request failed, default to empty array
roomConfigurations.push([]) roomConfigurations.push([])
roomsPackages.push([])
} else { } else {
roomConfigurations.push(availability.roomConfigurations) roomConfigurations.push(availability.roomConfigurations)
roomsPackages.push(availability.packages)
} }
} }
} }
@@ -72,21 +64,22 @@ 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,
rooms roomConfiguration
) )
if (!selectedRoom) { if (!selectedRoom) {
const updatedSearchParams = clearRoomSelectionFromUrl(idx, searchParams)
searchParams = updatedSearchParams
window.history.replaceState(
{},
"",
`${pathname}?${updatedSearchParams}`
)
continue continue
} }
@@ -99,7 +92,9 @@ export function createRatesStore({
rateSummary[idx] = { rateSummary[idx] = {
features: selectedRoom.features, features: selectedRoom.features,
product, product,
packages: room.packages ?? [], packages: roomsPackages[idx].filter((pkg) =>
room.packages?.includes(pkg.code)
),
rate: product.rate, rate: product.rate,
roomType: selectedRoom.roomType, roomType: selectedRoom.roomType,
roomTypeCode: selectedRoom.roomTypeCode, roomTypeCode: selectedRoom.roomTypeCode,
@@ -126,22 +121,20 @@ export function createRatesStore({
booking, booking,
packageOptions, packageOptions,
hotelType, hotelType,
isRedemptionBooking: searchParams.has("searchType")
? searchParams.get("searchType") === REDEMPTION
: false,
isUserLoggedIn, isUserLoggedIn,
packages,
pathname, pathname,
petRoomPackage: packages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
),
rateSummary, rateSummary,
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 ?? [] const roomPackages = roomsPackages[idx]
const selectedPackages =
let rooms: RoomConfiguration[] = filterRoomsBySelectedPackages( room.packages
selectedPackages, ?.map((code) => roomPackages.find((pkg) => pkg.code === code))
roomConfiguration .filter((pkg): pkg is Package => Boolean(pkg)) ?? []
)
const selectedRate = const selectedRate =
findSelectedRate( findSelectedRate(
@@ -159,12 +152,23 @@ export function createRatesStore({
room.counterRateCode room.counterRateCode
) )
} }
let selectedFilter
const bookingCode = room.rateCode
? room.bookingCode
: booking.bookingCode
if (bookingCode) {
selectedFilter = BookingCodeFilterEnum.Discounted
} else {
selectedFilter = BookingCodeFilterEnum.Regular
}
return { return {
actions: { actions: {
appendRegularRates(roomConfigurations) { appendRegularRates(roomConfigurations) {
return set( return set(
produce((state: RatesState) => { produce((state: RatesState) => {
state.rooms[idx].isFetchingAdditionalRate = false
if (roomConfigurations) {
const rooms = state.rooms[idx].rooms const rooms = state.rooms[idx].rooms
const updatedRooms = rooms.map((currentRoom) => { const updatedRooms = rooms.map((currentRoom) => {
const incomingRoom = roomConfigurations.find( const incomingRoom = roomConfigurations.find(
@@ -203,47 +207,8 @@ export function createRatesStore({
}) })
state.rooms[idx].rooms = updatedRooms state.rooms[idx].rooms = updatedRooms
}) } else {
) state.rooms[idx].rooms = []
},
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
} }
}) })
) )
@@ -266,70 +231,75 @@ export function createRatesStore({
}) })
) )
}, },
selectFilter(filter) { removeSelectedPackage(code) {
return set( return set(
produce((state: RatesState) => { produce((state: RatesState) => {
state.rooms[idx].selectedFilter = filter state.rooms[idx].isFetchingPackages = true
}) const filteredSelectedPackages = state.rooms[
) idx
}, ].selectedPackages.filter((c) => c.code !== code)
togglePackages(selectedPackages) { state.rooms[idx].selectedPackages = filteredSelectedPackages
return set(
produce((state: RatesState) => {
state.rooms[idx].selectedPackages = selectedPackages
const rateSummaryItem = state.rateSummary[idx]
const roomConfiguration = state.roomConfigurations[idx] if (
if (roomConfiguration) { state.rooms[idx].selectedRate?.product.bookingCode ||
const searchParams = new URLSearchParams(state.searchParams) state.booking.bookingCode
if (selectedPackages.length) { ) {
state.rooms[idx].selectedFilter =
BookingCodeFilterEnum.Discounted
}
const searchParams = state.searchParams
if (filteredSelectedPackages.length) {
searchParams.set( searchParams.set(
`room[${idx}].packages`, `room[${idx}].packages`,
selectedPackages.join(",") filteredSelectedPackages.map((pkg) => pkg.code).join(",")
) )
if (rateSummaryItem) {
rateSummaryItem.packages = selectedPackages
}
} else { } else {
state.rooms[idx].rooms = roomConfiguration
if (rateSummaryItem) {
rateSummaryItem.packages = []
}
searchParams.delete(`room[${idx}].packages`) searchParams.delete(`room[${idx}].packages`)
} }
// If we already have the features data 'addRoomFeatures' wont run state.searchParams = searchParams
// so we need to do additional filtering here if thats the case
const filteredRooms = filterRoomsBySelectedPackages(
selectedPackages,
state.roomConfigurations[idx]
)
if (filteredRooms.length) { window.history.replaceState(
const selectedRate = findSelectedRate(
room.rateCode,
room.counterRateCode,
room.roomTypeCode,
state.rooms[idx].rooms
)
if (!selectedRate) {
state.rooms[idx].selectedRate = null
state.rateSummary[idx] = null
}
}
state.searchParams = new ReadonlyURLSearchParams(
searchParams
)
window.history.pushState(
{}, {},
"", "",
`${state.pathname}?${searchParams}` `${state.pathname}?${searchParams}`
) )
})
)
},
removeSelectedPackages() {
return set(
produce((state: RatesState) => {
state.rooms[idx].isFetchingPackages = true
state.rooms[idx].selectedPackages = []
if (
state.rooms[idx].selectedRate?.product.bookingCode ||
state.booking.bookingCode
) {
state.rooms[idx].selectedFilter =
BookingCodeFilterEnum.Discounted
} }
const searchParams = state.searchParams
searchParams.delete(`room[${idx}].packages`)
state.searchParams = searchParams
window.history.replaceState(
{},
"",
`${state.pathname}?${searchParams}`
)
})
)
},
selectFilter(filter) {
return set(
produce((state: RatesState) => {
state.rooms[idx].selectedFilter = filter
state.rooms[idx].isFetchingAdditionalRate = true
}) })
) )
}, },
@@ -393,6 +363,19 @@ export function createRatesStore({
isMainRoom && isMainRoom &&
hasMemberRate && hasMemberRate &&
isRegularRate isRegularRate
state.rooms[idx].bookingRoom.rateCode = isMemberRate
? memberRateCode
: productRateCode
if (!isMemberRate && hasMemberRate) {
state.rooms[idx].bookingRoom.counterRateCode =
memberRateCode
}
state.rooms[idx].bookingRoom.roomTypeCode =
selectedRate.roomTypeCode
state.rooms[idx].bookingRoom.bookingCode =
selectedRate.product.bookingCode
const searchParams = new URLSearchParams(state.searchParams) const searchParams = new URLSearchParams(state.searchParams)
const counterratecode = isMemberRate const counterratecode = isMemberRate
? productRateCode ? productRateCode
@@ -411,6 +394,17 @@ export function createRatesStore({
searchParams.set(`room[${idx}].ratecode`, rateCode) searchParams.set(`room[${idx}].ratecode`, rateCode)
} }
if (selectedRate.product.bookingCode) {
searchParams.set(
`room[${idx}].bookingCode`,
selectedRate.product.bookingCode
)
} else {
if (searchParams.has(`room[${idx}].bookingCode`)) {
searchParams.delete(`room[${idx}].bookingCode`)
}
}
searchParams.set( searchParams.set(
`room[${idx}].roomtype`, `room[${idx}].roomtype`,
selectedRate.roomTypeCode selectedRate.roomTypeCode
@@ -422,8 +416,9 @@ export function createRatesStore({
state.activeRoom = idx + 1 state.activeRoom = idx + 1
} }
state.searchParams = new ReadonlyURLSearchParams(searchParams) state.searchParams = searchParams
window.history.pushState(
window.history.replaceState(
{}, {},
"", "",
`${state.pathname}?${searchParams}` `${state.pathname}?${searchParams}`
@@ -431,13 +426,105 @@ export function createRatesStore({
}) })
) )
}, },
selectPackages(selectedPackages) {
return set(
produce((state: RatesState) => {
state.rooms[idx].isFetchingPackages = true
const pkgs = state.roomsPackages[idx].filter((pkg) =>
selectedPackages.includes(pkg.code)
)
state.rooms[idx].selectedPackages = pkgs
if (
state.rooms[idx].selectedRate?.product.bookingCode ||
state.booking.bookingCode
) {
state.rooms[idx].selectedFilter =
BookingCodeFilterEnum.Discounted
}
const searchParams = state.searchParams
if (selectedPackages.length) {
searchParams.set(
`room[${idx}].packages`,
selectedPackages.join(",")
)
} else {
searchParams.delete(`room[${idx}].packages`)
}
state.searchParams = searchParams
window.history.replaceState(
{},
"",
`${state.pathname}?${searchParams}`
)
})
)
},
updateRooms(rooms) {
return set(
produce((state: RatesState) => {
state.rooms[idx].isFetchingPackages = false
if (rooms) {
state.rooms[idx].rooms = rooms
const rateSummaryRoom = state.rateSummary[idx]
if (rateSummaryRoom) {
const room = state.rooms[idx].bookingRoom
const selectedRoom = findSelectedRate(
room.rateCode,
room.counterRateCode,
room.roomTypeCode,
rooms
)
if (selectedRoom) {
rateSummaryRoom.packages =
state.rooms[idx].selectedPackages
} else {
const searchParams = clearRoomSelectionFromUrl(
idx,
state.searchParams
)
state.searchParams = searchParams
state.rateSummary[idx] = null
state.rooms[idx].selectedRate = null
window.history.replaceState(
{},
"",
`${pathname}?${searchParams}`
)
}
}
} else {
state.rooms[idx].rooms = []
if (state.rateSummary[idx]) {
const searchParams = clearRoomSelectionFromUrl(
idx,
state.searchParams
)
state.searchParams = searchParams
state.rateSummary[idx] = null
state.rooms[idx].selectedRate = null
window.history.replaceState(
{},
"",
`${pathname}?${searchParams}`
)
}
}
})
)
},
}, },
bookingRoom: room, bookingRoom: room,
rooms, isFetchingAdditionalRate: false,
selectedFilter: booking.bookingCode isFetchingPackages: false,
? BookingCodeFilterEnum.Discounted rooms: roomConfiguration,
: BookingCodeFilterEnum.All, selectedFilter,
selectedPackages, selectedPackages,
selectedRate: selectedRate:
selectedRate && product selectedRate && product
@@ -452,6 +539,7 @@ export function createRatesStore({
} }
}), }),
roomCategories, roomCategories,
roomsPackages,
roomsAvailability, roomsAvailability,
searchParams, searchParams,
vat, vat,

View File

@@ -1,39 +0,0 @@
import type { z } from "zod"
import type {
Product,
RoomConfiguration,
} from "@/types/trpc/routers/hotel/roomAvailability"
import type {
priceSchema,
productTypePriceSchema,
} from "@/server/routers/hotels/schemas/productTypePrice"
import type { RoomPackage } from "./roomFilter"
export type ProductPrice = z.output<typeof productTypePriceSchema>
export type RoomPriceSchema = z.output<typeof priceSchema>
export type FlexibilityOptionProps = {
features: RoomConfiguration["features"]
paymentTerm: string
petRoomPackage: RoomPackage | undefined
priceInformation?: Array<string>
product: Product | undefined
roomType: RoomConfiguration["roomType"]
roomTypeCode: RoomConfiguration["roomTypeCode"]
title: string
rateName?: string // Obtained in case of booking code and redemption rates
}
export interface FlexibilityOptionVoucherProps
extends Omit<FlexibilityOptionProps, "| product"> {
product: Product
}
export type FlexibilityOptionChequeProps = FlexibilityOptionVoucherProps
export interface PriceListProps {
publicPrice: ProductPrice
memberPrice: ProductPrice
petRoomPackage?: RoomPackage
rateName?: string // Obtained in case of booking code and redemption rates
}

View File

@@ -1,4 +1,4 @@
import type { Packages } from "@/types/requests/packages" import type { Package } from "@/types/requests/packages"
import type { import type {
Product, Product,
RoomConfiguration, RoomConfiguration,
@@ -12,5 +12,5 @@ export interface SharedRateCardProps
extends Pick<RoomConfiguration, "roomTypeCode"> { extends Pick<RoomConfiguration, "roomTypeCode"> {
handleSelectRate: (product: Product) => void handleSelectRate: (product: Product) => void
nights: number nights: number
petRoomPackage: NonNullable<Packages>[number] | undefined petRoomPackage: Package | undefined
} }

View File

@@ -11,7 +11,6 @@ export enum RoomPackageCodeEnum {
export interface DefaultFilterOptions { export interface DefaultFilterOptions {
code: RoomPackageCodeEnum code: RoomPackageCodeEnum
description: string description: string
itemCode: string | undefined
} }
export type FilterValues = { export type FilterValues = {

View File

@@ -1,8 +1,5 @@
import type { z } from "zod" import type { Package } from "@/types/requests/packages"
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability" import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
import type { packagePriceSchema } from "@/server/routers/hotels/schemas/packages"
import type { RoomPriceSchema } from "./flexibilityOption"
export type RoomListItemProps = { export type RoomListItemProps = {
roomConfiguration: RoomConfiguration roomConfiguration: RoomConfiguration
@@ -10,19 +7,9 @@ export type RoomListItemProps = {
export type RoomListItemImageProps = Pick< export type RoomListItemImageProps = Pick<
RoomConfiguration, RoomConfiguration,
"features" | "roomType" | "roomTypeCode" | "roomsLeft" "roomType" | "roomTypeCode" | "roomsLeft"
> > & {
roomPackages: Package[]
type RoomPackagePriceSchema = z.output<typeof packagePriceSchema>
export type CalculatePricesPerNightProps = {
publicLocalPrice: RoomPriceSchema
memberLocalPrice: RoomPriceSchema
publicRequestedPrice: RoomPriceSchema | null
memberRequestedPrice: RoomPriceSchema | null
petRoomLocalPrice?: RoomPackagePriceSchema
petRoomRequestedPrice?: RoomPackagePriceSchema
nights: number
} }
export interface RoomSizeProps { export interface RoomSizeProps {

View File

@@ -1,14 +1,9 @@
import type { HotelData } from "@/types/hotel" import type { HotelData } from "@/types/hotel"
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
import type { SelectRateSearchParams } from "./selectRate" import type { SelectRateSearchParams } from "./selectRate"
export interface RoomsContainerProps { export interface RoomsContainerProps
adultArray: number[] extends Pick<HotelData, "roomCategories">,
Pick<HotelData["hotel"], "hotelType" | "vat"> {
booking: SelectRateSearchParams booking: SelectRateSearchParams
bookingCode?: string
childArray: ChildrenInRoom
fromDate: Date
hotelData: HotelData
isUserLoggedIn: boolean isUserLoggedIn: boolean
toDate: Date
} }

View File

@@ -1,10 +1,10 @@
import type { RateEnum } from "@/types/enums/rate" import type { RateEnum } from "@/types/enums/rate"
import type { PackageEnum, Packages } from "@/types/requests/packages"
import type { import type {
Product, Product,
RoomConfiguration, RoomConfiguration,
} from "@/types/trpc/routers/hotel/roomAvailability" } from "@/types/trpc/routers/hotel/roomAvailability"
import type { ChildBedMapEnum } from "../../bookingWidget/enums" import type { ChildBedMapEnum } from "../../bookingWidget/enums"
import type { RoomPackageCodeEnum } from "./roomFilter"
export interface Child { export interface Child {
bed: ChildBedMapEnum bed: ChildBedMapEnum
@@ -13,9 +13,10 @@ export interface Child {
export interface Room { export interface Room {
adults: number adults: number
bookingCode?: string
childrenInRoom?: Child[] childrenInRoom?: Child[]
counterRateCode: string counterRateCode: string
packages?: RoomPackageCodeEnum[] packages?: PackageEnum[]
rateCode: string rateCode: string
roomTypeCode: string roomTypeCode: string
} }
@@ -32,7 +33,7 @@ export interface SelectRateSearchParams {
export type Rate = { export type Rate = {
features: RoomConfiguration["features"] features: RoomConfiguration["features"]
packages: RoomPackageCodeEnum[] packages: NonNullable<Packages>
priceName?: string priceName?: string
priceTerm?: string priceTerm?: string
product: Product product: Product

View File

@@ -1,17 +1,16 @@
import type { Package } from "@/types/requests/packages"
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< actions: SelectedRoom["actions"]
SelectedRoom["actions"],
"appendRegularRates" | "addRoomFeatures"
>
isActiveRoom: boolean isActiveRoom: boolean
isFetchingAdditionalRate: boolean isFetchingAdditionalRate: boolean
isFetchingRoomFeatures: boolean
isMainRoom: boolean isMainRoom: boolean
petRoomPackage: Package | undefined
roomAvailability: roomAvailability:
| NonNullable<RatesState["roomsAvailability"]>[number] | NonNullable<RatesState["roomsAvailability"]>[number]
| undefined | undefined
roomPackages: Package[]
roomNr: number roomNr: number
totalRooms: number totalRooms: number
} }

View File

@@ -1,5 +1,4 @@
import type { Room } from "@/types/hotel" import type { Room } from "@/types/hotel"
import type { Packages } from "@/types/requests/packages"
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability" import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate" import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate"
import type { AvailabilityError } from "../stores/rates" import type { AvailabilityError } from "../stores/rates"
@@ -8,7 +7,6 @@ export interface RatesProviderProps extends React.PropsWithChildren {
booking: SelectRateSearchParams booking: SelectRateSearchParams
hotelType: string | undefined hotelType: string | undefined
isUserLoggedIn: boolean isUserLoggedIn: boolean
packages: Packages | null
roomCategories: Room[] roomCategories: Room[]
roomsAvailability: (RoomsAvailability | AvailabilityError)[] | undefined roomsAvailability: (RoomsAvailability | AvailabilityError)[] | undefined
vat: number vat: number

View File

@@ -6,6 +6,8 @@ import type {
roomPackagesInputSchema, roomPackagesInputSchema,
} from "@/server/routers/hotels/input" } from "@/server/routers/hotels/input"
import type { packagesSchema } from "@/server/routers/hotels/output" import type { packagesSchema } from "@/server/routers/hotels/output"
import type { RoomPackageCodeEnum } from "../components/hotelReservation/selectRate/roomFilter"
import type { BreakfastPackageEnum } from "../enums/breakfast"
export interface BreackfastPackagesInput export interface BreackfastPackagesInput
extends z.input<typeof breakfastPackageInputSchema> {} extends z.input<typeof breakfastPackageInputSchema> {}
@@ -17,3 +19,6 @@ export interface PackagesInput
extends z.input<typeof roomPackagesInputSchema> {} extends z.input<typeof roomPackagesInputSchema> {}
export type Packages = z.output<typeof packagesSchema> export type Packages = z.output<typeof packagesSchema>
export type Package = NonNullable<Packages>[number]
export type PackageEnum = BreakfastPackageEnum | RoomPackageCodeEnum

View File

@@ -1,16 +1,11 @@
import type { ReadonlyURLSearchParams } from "next/navigation" import type { DefaultFilterOptions } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type {
DefaultFilterOptions,
RoomPackageCodeEnum,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { import type {
Rate, Rate,
Room as RoomBooking, Room as RoomBooking,
SelectRateSearchParams, SelectRateSearchParams,
} from "@/types/components/hotelReservation/selectRate/selectRate" } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Room } from "@/types/hotel" import type { Room } from "@/types/hotel"
import type { Packages } from "@/types/requests/packages" import type { Package, PackageEnum } from "@/types/requests/packages"
import type { import type {
Product, Product,
RoomConfiguration, RoomConfiguration,
@@ -24,18 +19,17 @@ export interface AvailabilityError {
} }
interface Actions { interface Actions {
appendRegularRates: (roomConfigurations: RoomConfiguration[]) => void appendRegularRates: (
addRoomFeatures: ( roomConfigurations: RoomConfiguration[] | undefined
roomFeatures: {
roomTypeCode: RoomConfiguration["roomTypeCode"]
features: RoomConfiguration["features"]
}[]
) => void ) => void
closeSection: () => void closeSection: VoidFunction
modifyRate: () => void modifyRate: VoidFunction
removeSelectedPackage: (code: PackageEnum) => void
removeSelectedPackages: VoidFunction
selectFilter: (filter: BookingCodeFilterEnum) => void selectFilter: (filter: BookingCodeFilterEnum) => void
togglePackages: (codes: RoomPackageCodeEnum[]) => void selectPackages: (codes: PackageEnum[]) => void
selectRate: (rate: SelectedRate) => void selectRate: (rate: SelectedRate) => void
updateRooms: (rooms: RoomConfiguration[] | undefined) => void
} }
export interface SelectedRate { export interface SelectedRate {
@@ -48,27 +42,29 @@ export interface SelectedRate {
export interface SelectedRoom { export interface SelectedRoom {
actions: Actions actions: Actions
bookingRoom: RoomBooking bookingRoom: RoomBooking
isFetchingAdditionalRate: boolean
isFetchingPackages: boolean
rooms: RoomConfiguration[] rooms: RoomConfiguration[]
selectedFilter: BookingCodeFilterEnum | undefined selectedFilter: BookingCodeFilterEnum | undefined
selectedPackages: RoomPackageCodeEnum[] selectedPackages: Package[]
selectedRate: SelectedRate | null selectedRate: SelectedRate | null
} }
export interface RatesState { export interface RatesState {
activeRoom: number activeRoom: number
booking: SelectRateSearchParams booking: SelectRateSearchParams
packageOptions: DefaultFilterOptions[]
hotelType: string | undefined hotelType: string | undefined
isRedemptionBooking: boolean
isUserLoggedIn: boolean isUserLoggedIn: boolean
packages: NonNullable<Packages> packageOptions: DefaultFilterOptions[]
pathname: string pathname: string
petRoomPackage: NonNullable<Packages>[number] | undefined
rateSummary: Array<Rate | null> rateSummary: Array<Rate | null>
rooms: SelectedRoom[] rooms: SelectedRoom[]
roomCategories: Room[] roomCategories: Room[]
roomConfigurations: RoomConfiguration[][] roomConfigurations: RoomConfiguration[][]
roomsPackages: Package[][]
roomsAvailability: (RoomsAvailability | AvailabilityError)[] | undefined roomsAvailability: (RoomsAvailability | AvailabilityError)[] | undefined
searchParams: ReadonlyURLSearchParams searchParams: URLSearchParams
vat: number vat: number
} }
@@ -78,7 +74,6 @@ export interface InitialState
| "booking" | "booking"
| "hotelType" | "hotelType"
| "isUserLoggedIn" | "isUserLoggedIn"
| "packages"
| "pathname" | "pathname"
| "roomCategories" | "roomCategories"
| "roomsAvailability" | "roomsAvailability"

View File

@@ -1,12 +1,11 @@
import {
type getHotelsByHotelIdsAvailabilityInputSchema,
type hotelsAvailabilityInputSchema,
type roomsCombinedAvailabilityInputSchema,
type selectedRoomAvailabilityInputSchema,
} from "@/server/routers/hotels/input"
import type { z } from "zod" import type { z } from "zod"
import type {
enterDetailsRoomsAvailabilityInputSchema,
getHotelsByHotelIdsAvailabilityInputSchema,
hotelsAvailabilityInputSchema,
selectRateRoomsAvailabilityInputSchema,
} from "@/server/routers/hotels/input"
import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output" import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output"
import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType" import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType"
import type { import type {
@@ -23,12 +22,15 @@ export type HotelsAvailabilityInputSchema = z.output<
export type HotelsByHotelIdsAvailabilityInputSchema = z.output< export type HotelsByHotelIdsAvailabilityInputSchema = z.output<
typeof getHotelsByHotelIdsAvailabilityInputSchema typeof getHotelsByHotelIdsAvailabilityInputSchema
> >
export type RoomsCombinedAvailabilityInputSchema = z.output< export type RoomsAvailabilityInputSchema = z.input<
typeof roomsCombinedAvailabilityInputSchema typeof selectRateRoomsAvailabilityInputSchema
> >
export type SelectedRoomAvailabilitySchema = z.output< export type RoomsAvailabilityInputRoom =
typeof selectedRoomAvailabilityInputSchema RoomsAvailabilityInputSchema["booking"]["rooms"][number]
export type RoomsAvailabilityExtendedInputSchema = z.input<
typeof enterDetailsRoomsAvailabilityInputSchema
> >
export type ProductType = z.output<typeof productTypeSchema> export type ProductType = z.output<typeof productTypeSchema>
export type ProductTypePrices = z.output<typeof productTypePriceSchema> export type ProductTypePrices = z.output<typeof productTypePriceSchema>
export type ProductTypePoints = z.output<typeof productTypePointsSchema> export type ProductTypePoints = z.output<typeof productTypePointsSchema>

View File

@@ -1,5 +0,0 @@
import type { z } from "zod"
import type { rateSchema } from "@/server/routers/hotels/schemas/rate"
export type Rate = z.output<typeof rateSchema>

View File

@@ -1,6 +1,5 @@
import type { z } from "zod" import type { z } from "zod"
import type { RouterOutput } from "@/lib/trpc/client"
import type { roomsAvailabilitySchema } from "@/server/routers/hotels/output" import type { roomsAvailabilitySchema } from "@/server/routers/hotels/output"
import type { roomConfigurationSchema } from "@/server/routers/hotels/schemas/roomAvailability/configuration" import type { roomConfigurationSchema } from "@/server/routers/hotels/schemas/roomAvailability/configuration"
import type { import type {
@@ -13,10 +12,6 @@ import type {
} from "@/server/routers/hotels/schemas/roomAvailability/product" } from "@/server/routers/hotels/schemas/roomAvailability/product"
import type { rateDefinitionSchema } from "@/server/routers/hotels/schemas/roomAvailability/rateDefinition" import type { rateDefinitionSchema } from "@/server/routers/hotels/schemas/roomAvailability/rateDefinition"
export type RoomAvailability = NonNullable<
RouterOutput["hotel"]["availability"]["room"]
>
export type CorporateChequeProduct = z.output<typeof corporateChequeProduct> export type CorporateChequeProduct = z.output<typeof corporateChequeProduct>
export type PriceProduct = z.output<typeof priceProduct> export type PriceProduct = z.output<typeof priceProduct>
export type RedemptionProduct = z.output<typeof redemptionProduct> export type RedemptionProduct = z.output<typeof redemptionProduct>