Merged in chore/refactor-booking-flow (pull request #1333)
chore: cleaning up select-rate Approved-by: Arvid Norlin Approved-by: Linus Flood
This commit is contained in:
@@ -7,8 +7,11 @@ import { getHotel } from "@/lib/trpc/memoizedRequests"
|
|||||||
import HotelInfoCard, {
|
import HotelInfoCard, {
|
||||||
HotelInfoCardSkeleton,
|
HotelInfoCardSkeleton,
|
||||||
} from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
} from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
||||||
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainer"
|
import {
|
||||||
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainerSkeleton"
|
preload,
|
||||||
|
RoomsContainer,
|
||||||
|
} from "@/components/HotelReservation/SelectRate/RoomsContainer"
|
||||||
|
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/RoomsContainer/RoomsContainerSkeleton"
|
||||||
import TrackingSDK from "@/components/TrackingSDK"
|
import TrackingSDK from "@/components/TrackingSDK"
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
import { convertSearchParamsToObj } from "@/utils/url"
|
import { convertSearchParamsToObj } from "@/utils/url"
|
||||||
@@ -37,17 +40,26 @@ export default async function SelectRatePage({
|
|||||||
const { hotel, adultsInRoom, childrenInRoom, selectHotelParams } =
|
const { hotel, adultsInRoom, childrenInRoom, selectHotelParams } =
|
||||||
searchDetails
|
searchDetails
|
||||||
|
|
||||||
|
const { fromDate, toDate } = getValidDates(
|
||||||
|
selectHotelParams.fromDate,
|
||||||
|
selectHotelParams.toDate
|
||||||
|
)
|
||||||
|
|
||||||
|
preload(
|
||||||
|
hotel.id,
|
||||||
|
params.lang,
|
||||||
|
fromDate.format("YYYY-MM-DD"),
|
||||||
|
toDate.format("YYYY-MM-DD"),
|
||||||
|
adultsInRoom,
|
||||||
|
childrenInRoom
|
||||||
|
)
|
||||||
|
|
||||||
const hotelData = await getHotel({
|
const hotelData = await getHotel({
|
||||||
hotelId: hotel.id,
|
hotelId: hotel.id,
|
||||||
isCardOnlyPayment: false,
|
isCardOnlyPayment: false,
|
||||||
language: params.lang,
|
language: params.lang,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { fromDate, toDate } = getValidDates(
|
|
||||||
selectHotelParams.fromDate,
|
|
||||||
selectHotelParams.toDate
|
|
||||||
)
|
|
||||||
|
|
||||||
const arrivalDate = fromDate.toDate()
|
const arrivalDate = fromDate.toDate()
|
||||||
const departureDate = toDate.toDate()
|
const departureDate = toDate.toDate()
|
||||||
|
|
||||||
@@ -96,13 +108,13 @@ export default async function SelectRatePage({
|
|||||||
fallback={<RoomsContainerSkeleton />}
|
fallback={<RoomsContainerSkeleton />}
|
||||||
>
|
>
|
||||||
<RoomsContainer
|
<RoomsContainer
|
||||||
|
adultArray={adultsInRoom}
|
||||||
|
booking={booking}
|
||||||
|
childArray={childrenInRoom}
|
||||||
|
fromDate={arrivalDate}
|
||||||
hotelId={hotelId}
|
hotelId={hotelId}
|
||||||
lang={params.lang}
|
lang={params.lang}
|
||||||
fromDate={fromDate.toDate()}
|
toDate={departureDate}
|
||||||
toDate={toDate.toDate()}
|
|
||||||
adultArray={adultsInRoom}
|
|
||||||
childArray={childrenInRoom}
|
|
||||||
booking={booking}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { usePathname } from "next/navigation"
|
|
||||||
import { useEffect, useMemo, useRef } from "react"
|
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
|
||||||
import { selectRoom } from "@/stores/enter-details/helpers"
|
|
||||||
|
|
||||||
import { useSessionId } from "@/hooks/useSessionId"
|
|
||||||
import { createSDKPageObject, trackPageView } from "@/utils/tracking"
|
|
||||||
|
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
|
||||||
import {
|
|
||||||
type Ancillary,
|
|
||||||
TrackingChannelEnum,
|
|
||||||
type TrackingSDKHotelInfo,
|
|
||||||
type TrackingSDKPageData,
|
|
||||||
type TrackingSDKUserData,
|
|
||||||
} from "@/types/components/tracking"
|
|
||||||
import type { Packages } from "@/types/requests/packages"
|
|
||||||
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
|
|
||||||
import type { Lang } from "@/constants/languages"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initialHotelsTrackingData: TrackingSDKHotelInfo
|
|
||||||
userTrackingData: TrackingSDKUserData
|
|
||||||
lang: Lang
|
|
||||||
selectedRoom: RoomConfiguration
|
|
||||||
cancellationRule: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EnterDetailsTracking(props: Props) {
|
|
||||||
const {
|
|
||||||
initialHotelsTrackingData,
|
|
||||||
userTrackingData,
|
|
||||||
lang,
|
|
||||||
selectedRoom,
|
|
||||||
cancellationRule,
|
|
||||||
} = props
|
|
||||||
|
|
||||||
const { bedType, breakfast, roomPrice, roomRate, roomFeatures } =
|
|
||||||
useEnterDetailsStore(selectRoom)
|
|
||||||
|
|
||||||
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
|
|
||||||
|
|
||||||
const pathName = usePathname()
|
|
||||||
const sessionId = useSessionId()
|
|
||||||
|
|
||||||
const previousPathname = useRef<string | null>(null)
|
|
||||||
|
|
||||||
const getSpecialRoomType = (packages: Packages | null) => {
|
|
||||||
if (!packages) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const specialRoom = packages.find((p) =>
|
|
||||||
[
|
|
||||||
RoomPackageCodeEnum.PET_ROOM,
|
|
||||||
RoomPackageCodeEnum.ALLERGY_ROOM,
|
|
||||||
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
|
||||||
].includes(p.code)
|
|
||||||
)
|
|
||||||
|
|
||||||
switch (specialRoom?.code) {
|
|
||||||
case RoomPackageCodeEnum.PET_ROOM:
|
|
||||||
return "pet-friendly"
|
|
||||||
case RoomPackageCodeEnum.ALLERGY_ROOM:
|
|
||||||
return "allergy room"
|
|
||||||
case RoomPackageCodeEnum.ACCESSIBILITY_ROOM:
|
|
||||||
return "accessibility room"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAnalyticsRateCode = (rateCodeName: string | undefined) => {
|
|
||||||
switch (rateCodeName) {
|
|
||||||
case "FLEXEU":
|
|
||||||
return "flex"
|
|
||||||
case "CHANGEEU":
|
|
||||||
return "change"
|
|
||||||
case "SAVEEU":
|
|
||||||
return "save"
|
|
||||||
default:
|
|
||||||
return rateCodeName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageObject = useMemo(() => {
|
|
||||||
const stepByPathname = pathName.split("/").pop()!
|
|
||||||
const pageTrackingData: TrackingSDKPageData = {
|
|
||||||
pageId: stepByPathname,
|
|
||||||
domainLanguage: lang,
|
|
||||||
channel: TrackingChannelEnum["hotelreservation"],
|
|
||||||
pageName: `hotelreservation|${stepByPathname}`,
|
|
||||||
siteSections: `hotelreservation|${stepByPathname}`,
|
|
||||||
pageType: stepByPathname,
|
|
||||||
siteVersion: "new-web",
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackingData = {
|
|
||||||
...pageTrackingData,
|
|
||||||
pathName,
|
|
||||||
sessionId,
|
|
||||||
pageLoadTime: 0, // Yes, this is instant
|
|
||||||
}
|
|
||||||
const pageObject = createSDKPageObject(trackingData)
|
|
||||||
|
|
||||||
return pageObject
|
|
||||||
}, [lang, sessionId, pathName])
|
|
||||||
|
|
||||||
const hotelDetailsData = useMemo(() => {
|
|
||||||
const isMember = true
|
|
||||||
const rate = isMember ? roomRate.memberRate : roomRate.publicRate
|
|
||||||
|
|
||||||
const breakfastAncillary = breakfast && {
|
|
||||||
hotelid: initialHotelsTrackingData.hotelID,
|
|
||||||
productName: "BreakfastAdult",
|
|
||||||
productCategory: "", // TODO: Add category
|
|
||||||
productId: breakfast.code,
|
|
||||||
productPrice: +breakfast.localPrice.price,
|
|
||||||
productUnits: initialHotelsTrackingData.noOfAdults,
|
|
||||||
productPoints: 0,
|
|
||||||
productType: "food",
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: TrackingSDKHotelInfo = {
|
|
||||||
...initialHotelsTrackingData,
|
|
||||||
rateCode: rate?.rateCode,
|
|
||||||
rateCodeType: roomRate.publicRate.rateType,
|
|
||||||
rateCodeName: "", //TODO: this should be ratedefinition.title and should be fixed when tracking is implemented for multiroom.
|
|
||||||
rateCodeCancellationRule: cancellationRule,
|
|
||||||
revenueCurrencyCode: totalPrice.local?.currency,
|
|
||||||
breakfastOption: breakfast ? "breakfast buffet" : "no breakfast",
|
|
||||||
totalPrice: totalPrice.local?.price,
|
|
||||||
specialRoomType: getSpecialRoomType(roomFeatures),
|
|
||||||
roomTypeName: selectedRoom.roomType,
|
|
||||||
bedType: bedType?.description,
|
|
||||||
roomTypeCode: bedType?.roomTypeCode,
|
|
||||||
roomPrice: roomPrice.perStay.local.price,
|
|
||||||
discount: roomRate.memberRate
|
|
||||||
? roomRate.publicRate.localPrice.pricePerStay -
|
|
||||||
roomRate.memberRate.localPrice.pricePerStay
|
|
||||||
: 0,
|
|
||||||
analyticsrateCode: getAnalyticsRateCode(roomRate.publicRate.rateCode),
|
|
||||||
ancillaries: breakfastAncillary ? [breakfastAncillary] : [],
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}, [
|
|
||||||
bedType,
|
|
||||||
breakfast,
|
|
||||||
totalPrice,
|
|
||||||
roomPrice,
|
|
||||||
roomRate,
|
|
||||||
roomFeatures,
|
|
||||||
initialHotelsTrackingData,
|
|
||||||
cancellationRule,
|
|
||||||
selectedRoom.roomType,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (previousPathname.current !== pathName) {
|
|
||||||
trackPageView({
|
|
||||||
event: "pageView",
|
|
||||||
pageInfo: pageObject,
|
|
||||||
userInfo: userTrackingData,
|
|
||||||
hotelInfo: hotelDetailsData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
previousPathname.current = pathName // Update for next render
|
|
||||||
}, [userTrackingData, pageObject, hotelDetailsData, pathName])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -42,8 +42,8 @@ import { type PaymentFormData, paymentSchema } from "./schema"
|
|||||||
|
|
||||||
import styles from "./payment.module.css"
|
import styles from "./payment.module.css"
|
||||||
|
|
||||||
|
import type { PaymentClientProps } from "@/types/components/hotelReservation/enterDetails/payment"
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import type { PaymentClientProps } from "@/types/components/hotelReservation/selectRate/section"
|
|
||||||
|
|
||||||
const maxRetries = 15
|
const maxRetries = 15
|
||||||
const retryInterval = 2000
|
const retryInterval = 2000
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
|
|||||||
|
|
||||||
import PaymentClient from "./PaymentClient"
|
import PaymentClient from "./PaymentClient"
|
||||||
|
|
||||||
import type { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
import type { PaymentProps } from "@/types/components/hotelReservation/enterDetails/payment"
|
||||||
|
|
||||||
export default async function Payment({
|
export default async function Payment({
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
|||||||
export default function DesktopSummary(props: SummaryProps) {
|
export default function DesktopSummary(props: SummaryProps) {
|
||||||
const {
|
const {
|
||||||
booking,
|
booking,
|
||||||
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
|
actions: { toggleSummaryOpen },
|
||||||
totalPrice,
|
totalPrice,
|
||||||
vat,
|
vat,
|
||||||
} = useEnterDetailsStore((state) => state)
|
} = useEnterDetailsStore((state) => state)
|
||||||
@@ -28,7 +28,6 @@ export default function DesktopSummary(props: SummaryProps) {
|
|||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
vat={vat}
|
vat={vat}
|
||||||
toggleSummaryOpen={toggleSummaryOpen}
|
toggleSummaryOpen={toggleSummaryOpen}
|
||||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
|
||||||
/>
|
/>
|
||||||
</SidePanel>
|
</SidePanel>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
|||||||
export default function MobileSummary(props: SummaryProps) {
|
export default function MobileSummary(props: SummaryProps) {
|
||||||
const {
|
const {
|
||||||
booking,
|
booking,
|
||||||
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
|
actions: { toggleSummaryOpen },
|
||||||
totalPrice,
|
totalPrice,
|
||||||
vat,
|
vat,
|
||||||
} = useEnterDetailsStore((state) => state)
|
} = useEnterDetailsStore((state) => state)
|
||||||
@@ -40,7 +40,6 @@ export default function MobileSummary(props: SummaryProps) {
|
|||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
vat={vat}
|
vat={vat}
|
||||||
toggleSummaryOpen={toggleSummaryOpen}
|
toggleSummaryOpen={toggleSummaryOpen}
|
||||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SummaryBottomSheet>
|
</SummaryBottomSheet>
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export default function SummaryUI({
|
|||||||
breakfastIncluded,
|
breakfastIncluded,
|
||||||
vat,
|
vat,
|
||||||
toggleSummaryOpen,
|
toggleSummaryOpen,
|
||||||
togglePriceDetailsModalOpen,
|
|
||||||
}: EnterDetailsSummaryProps) {
|
}: EnterDetailsSummaryProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
@@ -53,12 +52,6 @@ export default function SummaryUI({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTogglePriceDetailsModal() {
|
|
||||||
if (togglePriceDetailsModalOpen) {
|
|
||||||
togglePriceDetailsModalOpen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMemberPrice(roomRate: RoomRate) {
|
function getMemberPrice(roomRate: RoomRate) {
|
||||||
return roomRate?.memberRate
|
return roomRate?.memberRate
|
||||||
? {
|
? {
|
||||||
@@ -369,7 +362,6 @@ export default function SummaryUI({
|
|||||||
}))}
|
}))}
|
||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
vat={vat}
|
vat={vat}
|
||||||
toggleModal={handleTogglePriceDetailsModal}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ describe("EnterDetails Summary", () => {
|
|||||||
}}
|
}}
|
||||||
vat={12}
|
vat={12}
|
||||||
toggleSummaryOpen={jest.fn()}
|
toggleSummaryOpen={jest.fn()}
|
||||||
togglePriceDetailsModalOpen={jest.fn()}
|
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
wrapper: createWrapper(intl),
|
wrapper: createWrapper(intl),
|
||||||
@@ -141,7 +140,6 @@ describe("EnterDetails Summary", () => {
|
|||||||
}}
|
}}
|
||||||
vat={12}
|
vat={12}
|
||||||
toggleSummaryOpen={jest.fn()}
|
toggleSummaryOpen={jest.fn()}
|
||||||
togglePriceDetailsModalOpen={jest.fn()}
|
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
wrapper: createWrapper(intl),
|
wrapper: createWrapper(intl),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Button from "@/components/TempDesignSystem/Button"
|
|||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import { isValidClientSession } from "@/utils/clientSession"
|
||||||
|
|
||||||
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
|
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
|
||||||
import HotelCardDialogImage from "../HotelCardDialogImage"
|
import HotelCardDialogImage from "../HotelCardDialogImage"
|
||||||
@@ -33,7 +34,7 @@ export default function ListingHotelCardDialog({
|
|||||||
}: ListingHotelCardProps) {
|
}: ListingHotelCardProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const isUserLoggedIn = !!session
|
const isUserLoggedIn = isValidClientSession(session)
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
publicPrice,
|
publicPrice,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
|||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import { isValidClientSession } from "@/utils/clientSession"
|
||||||
|
|
||||||
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
|
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
|
||||||
import HotelCardDialogImage from "../HotelCardDialogImage"
|
import HotelCardDialogImage from "../HotelCardDialogImage"
|
||||||
@@ -35,7 +36,7 @@ export default function StandaloneHotelCardDialog({
|
|||||||
}: StandaloneHotelCardProps) {
|
}: StandaloneHotelCardProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const isUserLoggedIn = !!session
|
const isUserLoggedIn = isValidClientSession(session)
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
publicPrice,
|
publicPrice,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useHotelsMapStore } from "@/stores/hotels-map"
|
|||||||
import Alert from "@/components/TempDesignSystem/Alert"
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||||
import { useScrollToTop } from "@/hooks/useScrollToTop"
|
import { useScrollToTop } from "@/hooks/useScrollToTop"
|
||||||
|
import { isValidClientSession } from "@/utils/clientSession"
|
||||||
|
|
||||||
import HotelCard from "../HotelCard"
|
import HotelCard from "../HotelCard"
|
||||||
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
|
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
|
||||||
@@ -29,7 +30,7 @@ export default function HotelCardListing({
|
|||||||
type = HotelCardListingTypeEnum.PageListing,
|
type = HotelCardListingTypeEnum.PageListing,
|
||||||
}: HotelCardListingProps) {
|
}: HotelCardListingProps) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const isUserLoggedIn = !!session
|
const isUserLoggedIn = isValidClientSession(session)
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ interface PriceDetailsModalProps {
|
|||||||
}[]
|
}[]
|
||||||
totalPrice: Price
|
totalPrice: Price
|
||||||
vat: number
|
vat: number
|
||||||
toggleModal: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PriceDetailsModal({
|
export default function PriceDetailsModal({
|
||||||
@@ -35,7 +34,6 @@ export default function PriceDetailsModal({
|
|||||||
rooms,
|
rooms,
|
||||||
totalPrice,
|
totalPrice,
|
||||||
vat,
|
vat,
|
||||||
toggleModal,
|
|
||||||
}: PriceDetailsModalProps) {
|
}: PriceDetailsModalProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
@@ -43,7 +41,7 @@ export default function PriceDetailsModal({
|
|||||||
<Modal
|
<Modal
|
||||||
title={intl.formatMessage({ id: "Price details" })}
|
title={intl.formatMessage({ id: "Price details" })}
|
||||||
trigger={
|
trigger={
|
||||||
<Button intent="text" onPress={toggleModal}>
|
<Button intent="text">
|
||||||
<Caption color="burgundy">
|
<Caption color="burgundy">
|
||||||
{intl.formatMessage({ id: "Price details" })}
|
{intl.formatMessage({ id: "Price details" })}
|
||||||
</Caption>
|
</Caption>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
border-bottom: 1px solid rgba(17, 17, 17, 0.2);
|
|
||||||
padding-bottom: var(--Spacing-x3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
margin-top: var(--Spacing-x2);
|
|
||||||
margin-bottom: var(--Spacing-x2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
margin-top: var(--Spacing-x4);
|
|
||||||
list-style: none;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
column-gap: var(--Spacing-x2);
|
|
||||||
row-gap: var(--Spacing-x4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list > li {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list input[type="radio"] {
|
|
||||||
opacity: 0;
|
|
||||||
position: fixed;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import SelectionCard from "../SelectionCard"
|
|
||||||
|
|
||||||
import styles from "./bedSelection.module.css"
|
|
||||||
|
|
||||||
import type { BedSelectionProps } from "@/types/components/hotelReservation/selectRate/section"
|
|
||||||
|
|
||||||
export default function BedSelection({
|
|
||||||
alternatives,
|
|
||||||
nextPath,
|
|
||||||
}: BedSelectionProps) {
|
|
||||||
const intl = useIntl()
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault()
|
|
||||||
const queryParams = new URLSearchParams(searchParams)
|
|
||||||
queryParams.set("bed", e.currentTarget.bed?.value)
|
|
||||||
router.push(`${nextPath}?${queryParams}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<form
|
|
||||||
method="GET"
|
|
||||||
action={`${nextPath}?${searchParams}`}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
<ul className={styles.list}>
|
|
||||||
{alternatives.map((alternative) => (
|
|
||||||
<li key={alternative.value}>
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="bed" value={alternative.value} />
|
|
||||||
<SelectionCard
|
|
||||||
title={alternative.name}
|
|
||||||
subtext={`(${alternative.payment})`}
|
|
||||||
price={alternative.pricePerNight}
|
|
||||||
membersPrice={alternative.membersPricePerNight}
|
|
||||||
currency={alternative.currency}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button type="submit" hidden>
|
|
||||||
{intl.formatMessage({ id: "Submit" })}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
border-bottom: 1px solid rgba(17, 17, 17, 0.2);
|
|
||||||
padding-bottom: var(--Spacing-x3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
margin-top: var(--Spacing-x2);
|
|
||||||
margin-bottom: var(--Spacing-x2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
margin-top: var(--Spacing-x4);
|
|
||||||
list-style: none;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
column-gap: var(--Spacing-x2);
|
|
||||||
row-gap: var(--Spacing-x4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list > li {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list input[type="radio"] {
|
|
||||||
opacity: 0;
|
|
||||||
position: fixed;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import SelectionCard from "../SelectionCard"
|
|
||||||
|
|
||||||
import styles from "./breakfastSelection.module.css"
|
|
||||||
|
|
||||||
import type { BreakfastSelectionProps } from "@/types/components/hotelReservation/selectRate/section"
|
|
||||||
|
|
||||||
export default function BreakfastSelection({
|
|
||||||
alternatives,
|
|
||||||
nextPath,
|
|
||||||
}: BreakfastSelectionProps) {
|
|
||||||
const intl = useIntl()
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault()
|
|
||||||
const queryParams = new URLSearchParams(searchParams)
|
|
||||||
queryParams.set("breakfast", e.currentTarget.breakfast?.value)
|
|
||||||
router.push(`${nextPath}?${queryParams}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<form
|
|
||||||
method="GET"
|
|
||||||
action={`${nextPath}?${searchParams}`}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
<ul className={styles.list}>
|
|
||||||
{alternatives.map((alternative) => (
|
|
||||||
<li key={alternative.value}>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="breakfast"
|
|
||||||
value={alternative.value}
|
|
||||||
/>
|
|
||||||
<SelectionCard
|
|
||||||
title={alternative.name}
|
|
||||||
subtext={alternative.payment}
|
|
||||||
price={alternative.pricePerNight}
|
|
||||||
currency={alternative.currency}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button type="submit" hidden>
|
|
||||||
{intl.formatMessage({ id: "Submit" })}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
|
||||||
|
|
||||||
import styles from "./details.module.css"
|
|
||||||
|
|
||||||
import type { DetailsProps } from "@/types/components/hotelReservation/selectRate/section"
|
|
||||||
|
|
||||||
export default function Details({ nextPath }: DetailsProps) {
|
|
||||||
const intl = useIntl()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<form method="GET" action={`${nextPath}?${searchParams}`}>
|
|
||||||
<Button type="submit">{intl.formatMessage({ id: "Submit" })}</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import { dt } from "@/lib/dt"
|
|
||||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
|
||||||
|
|
||||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
|
||||||
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
|
||||||
import { formatPrice } from "@/utils/numberFormatting"
|
|
||||||
|
|
||||||
import MobileSummary from "./MobileSummary"
|
|
||||||
import { calculateTotalPrice } from "./utils"
|
|
||||||
|
|
||||||
import styles from "./rateSummary.module.css"
|
|
||||||
|
|
||||||
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
|
||||||
|
|
||||||
export default function RateSummary({
|
|
||||||
isUserLoggedIn,
|
|
||||||
packages,
|
|
||||||
roomsAvailability,
|
|
||||||
booking,
|
|
||||||
vat,
|
|
||||||
}: RateSummaryProps) {
|
|
||||||
const intl = useIntl()
|
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
|
||||||
|
|
||||||
const { getSelectedRateSummary } = useRateSelectionStore()
|
|
||||||
|
|
||||||
const { rooms } = booking
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setIsVisible(true), 0)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const selectedRateSummary = getSelectedRateSummary()
|
|
||||||
const totalRoomsRequired = rooms?.length || 1
|
|
||||||
if (selectedRateSummary.length === 0) return null
|
|
||||||
|
|
||||||
const petRoomPackage = packages?.find(
|
|
||||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalPriceToShow = calculateTotalPrice(
|
|
||||||
selectedRateSummary,
|
|
||||||
isUserLoggedIn,
|
|
||||||
petRoomPackage
|
|
||||||
)
|
|
||||||
const isAllRoomsSelected = selectedRateSummary.length === totalRoomsRequired
|
|
||||||
|
|
||||||
const checkInDate = new Date(roomsAvailability.checkInDate)
|
|
||||||
const checkOutDate = new Date(roomsAvailability.checkOutDate)
|
|
||||||
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
|
|
||||||
|
|
||||||
const hasMemberRates = selectedRateSummary.some((room) => room.member)
|
|
||||||
|
|
||||||
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
|
||||||
|
|
||||||
const summaryPriceText = `${intl.formatMessage(
|
|
||||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
|
||||||
{ totalNights: nights }
|
|
||||||
)}, ${intl.formatMessage(
|
|
||||||
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
|
||||||
{ totalAdults: rooms.reduce((acc, room) => acc + room.adults, 0) }
|
|
||||||
)}${
|
|
||||||
rooms.some((room) => room.childrenInRoom?.length)
|
|
||||||
? `, ${intl.formatMessage(
|
|
||||||
{ id: "{totalChildren, plural, one {# child} other {# children}}" },
|
|
||||||
{
|
|
||||||
totalChildren: rooms.reduce(
|
|
||||||
(acc, room) => acc + (room.childrenInRoom?.length ?? 0),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)}`
|
|
||||||
: ""
|
|
||||||
}, ${intl.formatMessage(
|
|
||||||
{ id: "{totalRooms, plural, one {# room} other {# rooms}}" },
|
|
||||||
{
|
|
||||||
totalRooms: rooms.length,
|
|
||||||
}
|
|
||||||
)}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.summary} data-visible={isVisible}>
|
|
||||||
<div className={styles.content}>
|
|
||||||
<div className={styles.summaryText}>
|
|
||||||
{selectedRateSummary.map((room, index) => (
|
|
||||||
<div key={index} className={styles.roomSummary}>
|
|
||||||
<Subtitle color="uiTextHighContrast">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "Room {roomIndex}" },
|
|
||||||
{ roomIndex: index + 1 }
|
|
||||||
)}
|
|
||||||
</Subtitle>
|
|
||||||
<Body color="uiTextMediumContrast">{room.roomType}</Body>
|
|
||||||
<Caption color="uiTextMediumContrast">{`${room.priceName}, ${room.priceTerm}`}</Caption>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{/* Render unselected rooms */}
|
|
||||||
{Array.from({
|
|
||||||
length: totalRoomsRequired - selectedRateSummary.length,
|
|
||||||
}).map((_, index) => (
|
|
||||||
<div key={`unselected-${index}`} className={styles.roomSummary}>
|
|
||||||
<Subtitle color="uiTextPlaceholder">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "Room {roomIndex}" },
|
|
||||||
{ roomIndex: selectedRateSummary.length + index + 1 }
|
|
||||||
)}
|
|
||||||
</Subtitle>
|
|
||||||
<Body color="uiTextPlaceholder">
|
|
||||||
{intl.formatMessage({ id: "Select room" })}
|
|
||||||
</Body>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.summaryPriceContainer}>
|
|
||||||
{showMemberDiscountBanner && (
|
|
||||||
<div className={styles.promoContainer}>
|
|
||||||
<SignupPromoDesktop
|
|
||||||
memberPrice={{
|
|
||||||
amount: selectedRateSummary.reduce((total, room) => {
|
|
||||||
const memberPrice =
|
|
||||||
room.member?.localPrice.pricePerStay ?? 0
|
|
||||||
const isPetRoom = room.features.some(
|
|
||||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
|
||||||
)
|
|
||||||
const petRoomPrice =
|
|
||||||
isPetRoom && petRoomPackage
|
|
||||||
? Number(petRoomPackage.localPrice.totalPrice || 0)
|
|
||||||
: 0
|
|
||||||
return total + memberPrice + petRoomPrice
|
|
||||||
}, 0),
|
|
||||||
currency:
|
|
||||||
selectedRateSummary[0].member?.localPrice.currency ??
|
|
||||||
selectedRateSummary[0].public.localPrice.currency,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles.summaryPriceTextDesktop}>
|
|
||||||
<Body>
|
|
||||||
{intl.formatMessage<React.ReactNode>(
|
|
||||||
{ id: "<b>Total price</b> (incl VAT)" },
|
|
||||||
{ b: (str) => <b>{str}</b> }
|
|
||||||
)}
|
|
||||||
</Body>
|
|
||||||
<Caption color="uiTextMediumContrast">{summaryPriceText}</Caption>
|
|
||||||
</div>
|
|
||||||
<div className={styles.summaryPrice}>
|
|
||||||
<div className={styles.summaryPriceTextDesktop}>
|
|
||||||
<Subtitle
|
|
||||||
color={isUserLoggedIn ? "red" : "uiTextHighContrast"}
|
|
||||||
textAlign="right"
|
|
||||||
>
|
|
||||||
{formatPrice(
|
|
||||||
intl,
|
|
||||||
totalPriceToShow.local.price,
|
|
||||||
totalPriceToShow.local.currency
|
|
||||||
)}
|
|
||||||
</Subtitle>
|
|
||||||
{totalPriceToShow?.requested ? (
|
|
||||||
<Body color="uiTextMediumContrast">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "Approx. {value}" },
|
|
||||||
{
|
|
||||||
value: formatPrice(
|
|
||||||
intl,
|
|
||||||
totalPriceToShow.requested.price,
|
|
||||||
totalPriceToShow.requested.currency
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Body>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
intent="primary"
|
|
||||||
theme="base"
|
|
||||||
type="submit"
|
|
||||||
className={styles.continueButton}
|
|
||||||
disabled={!isAllRoomsSelected}
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "Continue" })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.mobileSummary}>
|
|
||||||
{showMemberDiscountBanner ? <SignupPromoMobile /> : null}
|
|
||||||
<MobileSummary
|
|
||||||
totalPriceToShow={totalPriceToShow}
|
|
||||||
isAllRoomsSelected={isAllRoomsSelected}
|
|
||||||
booking={booking}
|
|
||||||
isUserLoggedIn={isUserLoggedIn}
|
|
||||||
vat={vat}
|
|
||||||
roomsAvailability={roomsAvailability}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { useMemo } from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import { alternativeHotels } from "@/constants/routes/hotelReservation"
|
|
||||||
import { useRoomFilteringStore } from "@/stores/select-rate/room-filtering"
|
|
||||||
|
|
||||||
import Alert from "@/components/TempDesignSystem/Alert"
|
|
||||||
import useLang from "@/hooks/useLang"
|
|
||||||
|
|
||||||
import RoomTypeFilter from "../RoomTypeFilter"
|
|
||||||
import RoomTypeList from "../RoomTypeList"
|
|
||||||
|
|
||||||
import styles from "../Rooms/rooms.module.css"
|
|
||||||
|
|
||||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
|
||||||
import type { FilterValues } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
|
||||||
import type { RoomSelectionPanelProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
|
|
||||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
|
||||||
|
|
||||||
export function RoomSelectionPanel({
|
|
||||||
availablePackages,
|
|
||||||
defaultPackages,
|
|
||||||
hotelType,
|
|
||||||
roomCategories,
|
|
||||||
roomListIndex,
|
|
||||||
selectedPackages,
|
|
||||||
}: RoomSelectionPanelProps) {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const intl = useIntl()
|
|
||||||
const lang = useLang()
|
|
||||||
|
|
||||||
const { getRooms } = useRoomFilteringStore()
|
|
||||||
|
|
||||||
const rooms = getRooms(roomListIndex)
|
|
||||||
|
|
||||||
const initialFilterValues = useMemo(() => {
|
|
||||||
const packagesFromSearchParams =
|
|
||||||
searchParams.get(`room[${roomListIndex}].packages`)?.split(",") ?? []
|
|
||||||
|
|
||||||
return defaultPackages.reduce<FilterValues>((acc, option) => {
|
|
||||||
acc[option.code] = packagesFromSearchParams.includes(option.code)
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
}, [defaultPackages, searchParams, roomListIndex])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{rooms?.roomConfigurations.every(
|
|
||||||
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
|
|
||||||
) && (
|
|
||||||
<div className={styles.hotelAlert}>
|
|
||||||
<Alert
|
|
||||||
type={AlertTypeEnum.Info}
|
|
||||||
heading={intl.formatMessage({ id: "No availability" })}
|
|
||||||
text={intl.formatMessage({
|
|
||||||
id: "There are no rooms available that match your request.",
|
|
||||||
})}
|
|
||||||
link={{
|
|
||||||
title: intl.formatMessage({ id: "See alternative hotels" }),
|
|
||||||
url: `${alternativeHotels(lang)}`,
|
|
||||||
keepSearchParams: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<RoomTypeFilter
|
|
||||||
numberOfRooms={rooms?.roomConfigurations.length ?? 0}
|
|
||||||
filterOptions={defaultPackages}
|
|
||||||
initialFilterValues={initialFilterValues}
|
|
||||||
roomListIndex={roomListIndex}
|
|
||||||
/>
|
|
||||||
{rooms && (
|
|
||||||
<RoomTypeList
|
|
||||||
availablePackages={availablePackages}
|
|
||||||
hotelType={hotelType}
|
|
||||||
roomCategories={roomCategories}
|
|
||||||
roomListIndex={roomListIndex}
|
|
||||||
roomsAvailability={rooms}
|
|
||||||
selectedPackages={selectedPackages}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { FormProvider, useForm } from "react-hook-form"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
import { useMediaQuery } from "usehooks-ts"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { useRoomFilteringStore } from "@/stores/select-rate/room-filtering"
|
|
||||||
|
|
||||||
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
|
|
||||||
import { InfoCircleIcon } from "@/components/Icons"
|
|
||||||
import CheckboxChip from "@/components/TempDesignSystem/Form/FilterChip/Checkbox"
|
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
|
||||||
import { Tooltip } from "@/components/TempDesignSystem/Tooltip"
|
|
||||||
|
|
||||||
import styles from "./roomFilter.module.css"
|
|
||||||
|
|
||||||
import {
|
|
||||||
type FilterValues,
|
|
||||||
type RoomFilterProps,
|
|
||||||
RoomPackageCodeEnum,
|
|
||||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
|
||||||
|
|
||||||
export default function RoomFilter({
|
|
||||||
numberOfRooms,
|
|
||||||
filterOptions,
|
|
||||||
initialFilterValues,
|
|
||||||
roomListIndex,
|
|
||||||
}: RoomFilterProps) {
|
|
||||||
const isTabletAndUp = useMediaQuery("(min-width: 768px)")
|
|
||||||
const [isAboveMobile, setIsAboveMobile] = useState(false)
|
|
||||||
|
|
||||||
const intl = useIntl()
|
|
||||||
const methods = useForm<FilterValues>({
|
|
||||||
defaultValues: initialFilterValues,
|
|
||||||
mode: "all",
|
|
||||||
reValidateMode: "onChange",
|
|
||||||
resolver: zodResolver(z.object({})),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { handleFilter } = useRoomFilteringStore()
|
|
||||||
|
|
||||||
const { watch, getValues } = methods
|
|
||||||
const petFriendly = watch(RoomPackageCodeEnum.PET_ROOM)
|
|
||||||
const allergyFriendly = watch(RoomPackageCodeEnum.ALLERGY_ROOM)
|
|
||||||
|
|
||||||
const selectedFilters = getValues()
|
|
||||||
|
|
||||||
const tooltipText = intl.formatMessage({
|
|
||||||
id: "Pet-friendly rooms have an additional fee of 20 EUR per stay",
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialFilterValues) return
|
|
||||||
|
|
||||||
handleFilter(initialFilterValues, roomListIndex)
|
|
||||||
}, [initialFilterValues, handleFilter, roomListIndex])
|
|
||||||
|
|
||||||
// Watch for filter changes
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = watch((value, { name }) => {
|
|
||||||
if (name) handleFilter(getValues(), roomListIndex)
|
|
||||||
})
|
|
||||||
return () => subscription.unsubscribe()
|
|
||||||
}, [watch, getValues, handleFilter, roomListIndex])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsAboveMobile(isTabletAndUp)
|
|
||||||
}, [isTabletAndUp])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.infoDesktop}>
|
|
||||||
<Body color="uiTextHighContrast">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
numberOfRooms,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Body>
|
|
||||||
</div>
|
|
||||||
<div className={styles.infoMobile}>
|
|
||||||
<div className={styles.filterInfo}>
|
|
||||||
<Tooltip
|
|
||||||
text={tooltipText}
|
|
||||||
position="bottom"
|
|
||||||
arrow="left"
|
|
||||||
isTouchable
|
|
||||||
>
|
|
||||||
<InfoCircleIcon color="uiTextHighContrast" height={20} width={20} />
|
|
||||||
<div className={styles.filter}>
|
|
||||||
<Caption
|
|
||||||
type="label"
|
|
||||||
color="baseTextMediumContrast"
|
|
||||||
textTransform="uppercase"
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "Filter" })}
|
|
||||||
</Caption>
|
|
||||||
<Caption type="regular" color="baseTextMediumContrast">
|
|
||||||
{Object.entries(selectedFilters)
|
|
||||||
.filter(([_, value]) => value)
|
|
||||||
.map(([key]) => intl.formatMessage({ id: key }))
|
|
||||||
.join(", ")}
|
|
||||||
</Caption>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Caption color="uiTextHighContrast">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
numberOfRooms,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Caption>
|
|
||||||
</div>
|
|
||||||
<FormProvider {...methods}>
|
|
||||||
<form>
|
|
||||||
<div className={styles.roomsFilter}>
|
|
||||||
{filterOptions.map((option) => {
|
|
||||||
const { code, description, itemCode } = option
|
|
||||||
const isPetRoom = code === RoomPackageCodeEnum.PET_ROOM
|
|
||||||
const isAllergyRoom = code === RoomPackageCodeEnum.ALLERGY_ROOM
|
|
||||||
const isDisabled =
|
|
||||||
(isAllergyRoom && petFriendly) ||
|
|
||||||
(isPetRoom && allergyFriendly) ||
|
|
||||||
!itemCode
|
|
||||||
|
|
||||||
const checkboxChip = (
|
|
||||||
<CheckboxChip
|
|
||||||
name={code}
|
|
||||||
key={code}
|
|
||||||
label={description}
|
|
||||||
disabled={isDisabled}
|
|
||||||
selected={getValues(code)}
|
|
||||||
Icon={getIconForFeatureCode(code)}
|
|
||||||
hasTooltip={isPetRoom && isAboveMobile}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return isPetRoom && isAboveMobile ? (
|
|
||||||
<Tooltip
|
|
||||||
key={option.code}
|
|
||||||
text={tooltipText}
|
|
||||||
position="bottom"
|
|
||||||
arrow="right"
|
|
||||||
isTouchable
|
|
||||||
>
|
|
||||||
{checkboxChip}
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
checkboxChip
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</FormProvider>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roomsFilter {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: var(--Spacing-x1);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--Spacing-x-half);
|
|
||||||
margin-left: var(--Spacing-x-half);
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterInfo {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: var(--Spacing-x-half);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-right: var(--Spacing-x1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoDesktop {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoMobile {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.infoDesktop {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoMobile {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { useSession } from "next-auth/react"
|
|
||||||
import { createElement, useCallback, useEffect, useMemo } from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
|
||||||
|
|
||||||
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
|
|
||||||
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
|
|
||||||
import { ErrorCircleIcon } from "@/components/Icons"
|
|
||||||
import ImageGallery from "@/components/ImageGallery"
|
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
|
||||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
|
||||||
|
|
||||||
import FlexibilityOption from "../FlexibilityOption"
|
|
||||||
import { cardVariants } from "./cardVariants"
|
|
||||||
|
|
||||||
import styles from "./roomCard.module.css"
|
|
||||||
|
|
||||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
|
||||||
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
|
||||||
import { HotelTypeEnum } from "@/types/enums/hotelType"
|
|
||||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
|
||||||
|
|
||||||
function getBreakfastMessage(
|
|
||||||
publicBreakfastIncluded: boolean,
|
|
||||||
memberBreakfastIncluded: boolean,
|
|
||||||
hotelType: string | undefined,
|
|
||||||
userIsLoggedIn: boolean,
|
|
||||||
msgs: Record<"included" | "noSelection" | "scandicgo" | "notIncluded", string>
|
|
||||||
) {
|
|
||||||
if (hotelType === HotelTypeEnum.ScandicGo) {
|
|
||||||
return msgs.scandicgo
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userIsLoggedIn && memberBreakfastIncluded) {
|
|
||||||
return msgs.included
|
|
||||||
}
|
|
||||||
|
|
||||||
if (publicBreakfastIncluded && memberBreakfastIncluded) {
|
|
||||||
return msgs.included
|
|
||||||
}
|
|
||||||
|
|
||||||
/** selected and rate does not include breakfast */
|
|
||||||
if (false) {
|
|
||||||
return msgs.notIncluded
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
|
|
||||||
return msgs.notIncluded
|
|
||||||
}
|
|
||||||
|
|
||||||
return msgs.noSelection
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RoomCard({
|
|
||||||
hotelId,
|
|
||||||
hotelType,
|
|
||||||
rateDefinitions,
|
|
||||||
roomConfiguration,
|
|
||||||
roomCategories,
|
|
||||||
selectedPackages,
|
|
||||||
packages,
|
|
||||||
roomListIndex,
|
|
||||||
}: RoomCardProps) {
|
|
||||||
const { data: session } = useSession()
|
|
||||||
const isUserLoggedIn = !!session
|
|
||||||
const intl = useIntl()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const { selectRate, selectedRates, closeModifyRate } = useRateSelectionStore(
|
|
||||||
(state) => ({
|
|
||||||
selectRate: state.selectRate,
|
|
||||||
selectedRates: state.selectedRates,
|
|
||||||
closeModifyRate: state.closeModifyRate,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectedRate = useRateSelectionStore(
|
|
||||||
(state) => state.selectedRates[roomListIndex]
|
|
||||||
)
|
|
||||||
|
|
||||||
const classNames = cardVariants({
|
|
||||||
availability:
|
|
||||||
roomConfiguration.status === AvailabilityEnum.NotAvailable
|
|
||||||
? "noAvailability"
|
|
||||||
: "default",
|
|
||||||
})
|
|
||||||
|
|
||||||
const breakfastMessages = {
|
|
||||||
included: intl.formatMessage({ id: "Breakfast is included." }),
|
|
||||||
notIncluded: intl.formatMessage({
|
|
||||||
id: "Breakfast selection in next step.",
|
|
||||||
}),
|
|
||||||
noSelection: intl.formatMessage({ id: "Select a rate" }),
|
|
||||||
scandicgo: intl.formatMessage({
|
|
||||||
id: "Breakfast deal can be purchased at the hotel.",
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
const breakfastMessage = getBreakfastMessage(
|
|
||||||
roomConfiguration.breakfastIncludedInAllRatesPublic,
|
|
||||||
roomConfiguration.breakfastIncludedInAllRatesMember,
|
|
||||||
hotelType,
|
|
||||||
isUserLoggedIn,
|
|
||||||
breakfastMessages
|
|
||||||
)
|
|
||||||
|
|
||||||
const rates = useMemo(
|
|
||||||
() => ({
|
|
||||||
change: rateDefinitions.filter(
|
|
||||||
(rate) => rate.cancellationRule === "Changeable"
|
|
||||||
),
|
|
||||||
flex: rateDefinitions.filter(
|
|
||||||
(rate) => rate.cancellationRule === "CancellableBefore6PM"
|
|
||||||
),
|
|
||||||
save: rateDefinitions.filter(
|
|
||||||
(rate) => rate.cancellationRule === "NotCancellable"
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
[rateDefinitions]
|
|
||||||
)
|
|
||||||
|
|
||||||
const petRoomPackage =
|
|
||||||
(selectedPackages?.includes(RoomPackageCodeEnum.PET_ROOM) &&
|
|
||||||
packages?.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
|
|
||||||
undefined
|
|
||||||
|
|
||||||
const selectedRoom = roomCategories.find((roomCategory) =>
|
|
||||||
roomCategory.roomTypes.find(
|
|
||||||
(roomType) => roomType.code === roomConfiguration.roomTypeCode
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const { name, roomSize, totalOccupancy, images } = selectedRoom || {}
|
|
||||||
const galleryImages = mapApiImagesToGalleryImages(images || [])
|
|
||||||
|
|
||||||
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
|
||||||
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
|
||||||
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
|
|
||||||
const payLater = intl.formatMessage({ id: "Pay later" })
|
|
||||||
const payNow = intl.formatMessage({ id: "Pay now" })
|
|
||||||
|
|
||||||
function handleRateSelection(
|
|
||||||
rateCode: string,
|
|
||||||
rateName: string,
|
|
||||||
paymentTerm: string
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
selectedRates[roomListIndex]?.publicRateCode === rateCode &&
|
|
||||||
selectedRates[roomListIndex]?.roomTypeCode ===
|
|
||||||
roomConfiguration.roomTypeCode
|
|
||||||
) {
|
|
||||||
selectRate(roomListIndex, undefined)
|
|
||||||
} else {
|
|
||||||
selectRate(roomListIndex, {
|
|
||||||
publicRateCode: rateCode,
|
|
||||||
roomTypeCode: roomConfiguration.roomTypeCode,
|
|
||||||
name: rateName,
|
|
||||||
paymentTerm: paymentTerm,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRate = useCallback(
|
|
||||||
(rateCode: string) => {
|
|
||||||
switch (rateCode) {
|
|
||||||
case "change":
|
|
||||||
return {
|
|
||||||
isFlex: false,
|
|
||||||
notAvailable: false,
|
|
||||||
title: freeBooking,
|
|
||||||
}
|
|
||||||
case "flex":
|
|
||||||
return {
|
|
||||||
isFlex: true,
|
|
||||||
notAvailable: false,
|
|
||||||
title: freeCancelation,
|
|
||||||
}
|
|
||||||
case "save":
|
|
||||||
return {
|
|
||||||
isFlex: false,
|
|
||||||
notAvailable: false,
|
|
||||||
title: nonRefundable,
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new Error(
|
|
||||||
`Unknown key for rate, should be "change", "flex" or "save", but got ${rateCode}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[freeBooking, freeCancelation, nonRefundable]
|
|
||||||
)
|
|
||||||
|
|
||||||
const getRateInfo = useCallback(
|
|
||||||
(product: Product) => {
|
|
||||||
if (
|
|
||||||
!product.productType.public.rateCode &&
|
|
||||||
!product.productType.member?.rateCode
|
|
||||||
) {
|
|
||||||
const possibleRate = getRate(product.productType.public.rate)
|
|
||||||
if (possibleRate) {
|
|
||||||
return {
|
|
||||||
...possibleRate,
|
|
||||||
notAvailable: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
isFlex: false,
|
|
||||||
notAvailable: true,
|
|
||||||
title: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const publicRate = Object.keys(rates).find((k) =>
|
|
||||||
rates[k as keyof typeof rates].find(
|
|
||||||
(a) => a.rateCode === product.productType.public.rateCode
|
|
||||||
)
|
|
||||||
)
|
|
||||||
let memberRate
|
|
||||||
if (product.productType.member) {
|
|
||||||
memberRate = Object.keys(rates).find((k) =>
|
|
||||||
rates[k as keyof typeof rates].find(
|
|
||||||
(a) => a.rateCode === product.productType.member!.rateCode
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!publicRate || !memberRate) {
|
|
||||||
throw new Error("We should never make it here without rateCodes")
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = isUserLoggedIn ? memberRate : publicRate
|
|
||||||
return getRate(key)
|
|
||||||
},
|
|
||||||
[getRate, isUserLoggedIn, rates]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handle URL-based preselection
|
|
||||||
useEffect(() => {
|
|
||||||
const ratecodeSearchParam = searchParams.get(
|
|
||||||
`room[${roomListIndex}].ratecode`
|
|
||||||
)
|
|
||||||
const roomtypeSearchParam = searchParams.get(
|
|
||||||
`room[${roomListIndex}].roomtype`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!ratecodeSearchParam || !roomtypeSearchParam) return
|
|
||||||
|
|
||||||
// Check if there's already a selection for this room index
|
|
||||||
const existingSelection = selectedRates[roomListIndex]
|
|
||||||
if (existingSelection) return
|
|
||||||
|
|
||||||
const matchingRate = roomConfiguration.products.find(
|
|
||||||
(product) =>
|
|
||||||
product.productType.public.rateCode === ratecodeSearchParam &&
|
|
||||||
roomConfiguration.roomTypeCode === roomtypeSearchParam
|
|
||||||
)
|
|
||||||
|
|
||||||
if (matchingRate) {
|
|
||||||
const rateInfo = getRateInfo(matchingRate)
|
|
||||||
selectRate(roomListIndex, {
|
|
||||||
publicRateCode: matchingRate.productType.public.rateCode,
|
|
||||||
roomTypeCode: roomConfiguration.roomTypeCode,
|
|
||||||
name: rateInfo.title,
|
|
||||||
paymentTerm: rateInfo.isFlex ? payLater : payNow,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
searchParams,
|
|
||||||
roomListIndex,
|
|
||||||
rates,
|
|
||||||
roomConfiguration.products,
|
|
||||||
roomConfiguration.roomTypeCode,
|
|
||||||
payLater,
|
|
||||||
payNow,
|
|
||||||
selectRate,
|
|
||||||
selectedRates,
|
|
||||||
getRateInfo,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className={classNames}>
|
|
||||||
<div>
|
|
||||||
<div className={styles.imageContainer}>
|
|
||||||
<div className={styles.chipContainer}>
|
|
||||||
{roomConfiguration.roomsLeft > 0 &&
|
|
||||||
roomConfiguration.roomsLeft < 5 && (
|
|
||||||
<span className={styles.chip}>
|
|
||||||
<Footnote color="burgundy" textTransform="uppercase">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "{amount, number} left" },
|
|
||||||
{ amount: roomConfiguration.roomsLeft }
|
|
||||||
)}
|
|
||||||
</Footnote>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{roomConfiguration.features
|
|
||||||
.filter((feature) => selectedPackages?.includes(feature.code))
|
|
||||||
.map((feature) => (
|
|
||||||
<span className={styles.chip} key={feature.code}>
|
|
||||||
{createElement(getIconForFeatureCode(feature.code), {
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
color: "burgundy",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<ImageGallery
|
|
||||||
images={galleryImages}
|
|
||||||
title={roomConfiguration.roomType}
|
|
||||||
fill
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.specification}>
|
|
||||||
{totalOccupancy && (
|
|
||||||
<Caption color="uiTextMediumContrast" className={styles.guests}>
|
|
||||||
{intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "Max. {max, plural, one {{range} guest} other {{range} guests}}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
max: totalOccupancy.max,
|
|
||||||
range: totalOccupancy.range,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Caption>
|
|
||||||
)}
|
|
||||||
{roomSize && (
|
|
||||||
<Caption color="uiTextMediumContrast">
|
|
||||||
{roomSize.min === roomSize.max
|
|
||||||
? intl.formatMessage(
|
|
||||||
{ id: "{roomSize} m²" },
|
|
||||||
{
|
|
||||||
roomSize: roomSize.min,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
: intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "{roomSizeMin}–{roomSizeMax} m²",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
roomSizeMin: roomSize.min,
|
|
||||||
roomSizeMax: roomSize.max,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Caption>
|
|
||||||
)}
|
|
||||||
<div className={styles.toggleSidePeek}>
|
|
||||||
{roomConfiguration.roomTypeCode && (
|
|
||||||
<ToggleSidePeek
|
|
||||||
hotelId={hotelId}
|
|
||||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.roomDetails}>
|
|
||||||
<Subtitle className={styles.name} type="two">
|
|
||||||
{name}
|
|
||||||
</Subtitle>
|
|
||||||
{/* Out of scope for now
|
|
||||||
<Body>{descriptions?.short}</Body>
|
|
||||||
*/}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.container}>
|
|
||||||
{roomConfiguration.status === AvailabilityEnum.Available ? (
|
|
||||||
<Caption color="uiTextHighContrast" type="bold">
|
|
||||||
{breakfastMessage}
|
|
||||||
</Caption>
|
|
||||||
) : null}
|
|
||||||
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
|
|
||||||
<div className={styles.noRoomsContainer}>
|
|
||||||
<div className={styles.noRooms}>
|
|
||||||
<ErrorCircleIcon color="red" width={16} />
|
|
||||||
<Caption color="uiTextHighContrast" type="bold">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "This room is not available",
|
|
||||||
})}
|
|
||||||
</Caption>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
roomConfiguration.products.map((product) => {
|
|
||||||
const rate = getRateInfo(product)
|
|
||||||
return (
|
|
||||||
<FlexibilityOption
|
|
||||||
key={product.productType.public.rateCode}
|
|
||||||
handleSelect={(rateCode, rateName, paymentTerm) => {
|
|
||||||
handleRateSelection(rateCode, rateName, paymentTerm)
|
|
||||||
closeModifyRate()
|
|
||||||
}}
|
|
||||||
isSelected={
|
|
||||||
selectedRate?.publicRateCode ===
|
|
||||||
product.productType.public.rateCode &&
|
|
||||||
selectedRate?.roomTypeCode === roomConfiguration.roomTypeCode
|
|
||||||
}
|
|
||||||
isUserLoggedIn={isUserLoggedIn}
|
|
||||||
paymentTerm={rate.isFlex ? payLater : payNow}
|
|
||||||
petRoomPackage={petRoomPackage}
|
|
||||||
product={rate?.notAvailable ? undefined : product}
|
|
||||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
|
||||||
title={rate.title}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import RoomCard from "./RoomCard"
|
|
||||||
|
|
||||||
import styles from "./roomSelection.module.css"
|
|
||||||
|
|
||||||
import type { RoomTypeListProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
|
|
||||||
|
|
||||||
export default function RoomTypeList({
|
|
||||||
availablePackages,
|
|
||||||
hotelType,
|
|
||||||
roomCategories,
|
|
||||||
roomListIndex,
|
|
||||||
roomsAvailability,
|
|
||||||
selectedPackages,
|
|
||||||
}: RoomTypeListProps) {
|
|
||||||
const { roomConfigurations, rateDefinitions } = roomsAvailability
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<ul className={styles.roomList}>
|
|
||||||
{roomConfigurations.map((roomConfiguration) => (
|
|
||||||
<RoomCard
|
|
||||||
key={roomConfiguration.roomTypeCode}
|
|
||||||
hotelId={roomsAvailability.hotelId.toString()}
|
|
||||||
hotelType={hotelType}
|
|
||||||
packages={availablePackages}
|
|
||||||
rateDefinitions={rateDefinitions}
|
|
||||||
roomCategories={roomCategories}
|
|
||||||
roomConfiguration={roomConfiguration}
|
|
||||||
roomListIndex={roomListIndex}
|
|
||||||
selectedPackages={selectedPackages}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { dt } from "@/lib/dt"
|
|
||||||
import {
|
|
||||||
getHotel,
|
|
||||||
getPackages,
|
|
||||||
getRoomsAvailability,
|
|
||||||
} from "@/lib/trpc/memoizedRequests"
|
|
||||||
|
|
||||||
import { auth } from "@/auth"
|
|
||||||
import { getIntl } from "@/i18n"
|
|
||||||
import { safeTry } from "@/utils/safeTry"
|
|
||||||
import { isValidSession } from "@/utils/session"
|
|
||||||
|
|
||||||
import { generateChildrenString } from "../../utils"
|
|
||||||
import { combineRoomAvailabilities } from "../utils"
|
|
||||||
import Rooms from "."
|
|
||||||
|
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
|
||||||
import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer"
|
|
||||||
|
|
||||||
export async function RoomsContainer({
|
|
||||||
adultArray,
|
|
||||||
childArray,
|
|
||||||
fromDate,
|
|
||||||
hotelId,
|
|
||||||
lang,
|
|
||||||
toDate,
|
|
||||||
}: RoomsContainerProps) {
|
|
||||||
const session = await auth()
|
|
||||||
const isUserLoggedIn = isValidSession(session)
|
|
||||||
|
|
||||||
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
|
|
||||||
const toDateString = dt(toDate).format("YYYY-MM-DD")
|
|
||||||
|
|
||||||
const hotelDataPromise = safeTry(
|
|
||||||
getHotel({
|
|
||||||
hotelId: hotelId.toString(),
|
|
||||||
isCardOnlyPayment: false,
|
|
||||||
language: lang,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const packagesPromise = safeTry(
|
|
||||||
getPackages({
|
|
||||||
hotelId: hotelId.toString(),
|
|
||||||
startDate: fromDateString,
|
|
||||||
endDate: toDateString,
|
|
||||||
adults: adultArray[0],
|
|
||||||
children: childArray ? childArray.length : undefined,
|
|
||||||
packageCodes: [
|
|
||||||
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
|
||||||
RoomPackageCodeEnum.PET_ROOM,
|
|
||||||
RoomPackageCodeEnum.ALLERGY_ROOM,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const uniqueAdultCounts = [...new Set(adultArray)]
|
|
||||||
const roomsAvailabilityPromises = uniqueAdultCounts.map((adultCount) => {
|
|
||||||
return safeTry(
|
|
||||||
getRoomsAvailability({
|
|
||||||
hotelId: hotelId,
|
|
||||||
roomStayStartDate: fromDateString,
|
|
||||||
roomStayEndDate: toDateString,
|
|
||||||
adults: adultCount,
|
|
||||||
children:
|
|
||||||
childArray && childArray.length > 0
|
|
||||||
? generateChildrenString(childArray)
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const [hotelData, hotelDataError] = await hotelDataPromise
|
|
||||||
const [packages, packagesError] = await packagesPromise
|
|
||||||
const roomsAvailabilityResults = await Promise.all(roomsAvailabilityPromises)
|
|
||||||
|
|
||||||
const roomsAvailability = combineRoomAvailabilities({
|
|
||||||
availabilityResults: roomsAvailabilityResults,
|
|
||||||
})
|
|
||||||
|
|
||||||
const intl = await getIntl(lang)
|
|
||||||
|
|
||||||
if (packagesError) {
|
|
||||||
// TODO: Log packages error
|
|
||||||
console.error("[RoomsContainer] unable to fetch packages")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hotelData) {
|
|
||||||
// TODO: Log hotel data error
|
|
||||||
console.error("[RoomsContainer] unable to fetch hotel data")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Rooms
|
|
||||||
availablePackages={packages ?? []}
|
|
||||||
hotelType={hotelData?.hotel.hotelType}
|
|
||||||
isUserLoggedIn={isUserLoggedIn}
|
|
||||||
roomsAvailability={roomsAvailability}
|
|
||||||
roomCategories={hotelData?.roomCategories ?? []}
|
|
||||||
vat={hotelData.hotel.vat}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { useCallback, useEffect, useMemo, useTransition } from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
|
||||||
import { useRoomFilteringStore } from "@/stores/select-rate/room-filtering"
|
|
||||||
|
|
||||||
import { ChevronDownSmallIcon } from "@/components/Icons"
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
|
||||||
import { trackLowestRoomPrice } from "@/utils/tracking"
|
|
||||||
import { convertObjToSearchParams, convertSearchParamsToObj } from "@/utils/url"
|
|
||||||
|
|
||||||
import RateSummary from "../RateSummary"
|
|
||||||
import { RoomSelectionPanel } from "../RoomSelectionPanel"
|
|
||||||
import SelectedRoomPanel from "../SelectedRoomPanel"
|
|
||||||
import { roomSelectionPanelVariants } from "./variants"
|
|
||||||
|
|
||||||
import styles from "./rooms.module.css"
|
|
||||||
|
|
||||||
import {
|
|
||||||
type DefaultFilterOptions,
|
|
||||||
RoomPackageCodeEnum,
|
|
||||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
|
||||||
import type { SelectRateProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
|
|
||||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
|
||||||
|
|
||||||
export default function Rooms({
|
|
||||||
availablePackages,
|
|
||||||
hotelType,
|
|
||||||
isUserLoggedIn,
|
|
||||||
roomsAvailability,
|
|
||||||
roomCategories = [],
|
|
||||||
vat,
|
|
||||||
}: SelectRateProps) {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const intl = useIntl()
|
|
||||||
const [isPending, startTransition] = useTransition()
|
|
||||||
|
|
||||||
const hotelId = searchParams.get("hotel")
|
|
||||||
const arrivalDate = searchParams.get("fromDate")
|
|
||||||
const departureDate = searchParams.get("toDate")
|
|
||||||
|
|
||||||
const {
|
|
||||||
selectedRates,
|
|
||||||
rateSummary,
|
|
||||||
calculateRateSummary,
|
|
||||||
initializeRates,
|
|
||||||
setGuestsInRooms,
|
|
||||||
modifyRateIndex,
|
|
||||||
closeModifyRate,
|
|
||||||
} = useRateSelectionStore()
|
|
||||||
|
|
||||||
const {
|
|
||||||
selectedPackagesByRoom,
|
|
||||||
visibleRooms,
|
|
||||||
setVisibleRooms,
|
|
||||||
setRoomsAvailability,
|
|
||||||
getFilteredRooms,
|
|
||||||
} = useRoomFilteringStore()
|
|
||||||
|
|
||||||
const bookingWidgetSearchData = useMemo(
|
|
||||||
() =>
|
|
||||||
convertSearchParamsToObj<SelectRateSearchParams>(
|
|
||||||
Object.fromEntries(searchParams)
|
|
||||||
),
|
|
||||||
[searchParams]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
bookingWidgetSearchData.rooms.forEach((room, index) => {
|
|
||||||
setGuestsInRooms(index, room.adults, room.childrenInRoom)
|
|
||||||
})
|
|
||||||
}, [bookingWidgetSearchData.rooms, setGuestsInRooms])
|
|
||||||
|
|
||||||
const isMultipleRooms = bookingWidgetSearchData.rooms.length > 1
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initializeRates(bookingWidgetSearchData.rooms.length)
|
|
||||||
}, [initializeRates, bookingWidgetSearchData.rooms.length])
|
|
||||||
|
|
||||||
const defaultPackages: DefaultFilterOptions[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
|
||||||
description: intl.formatMessage({ id: "Accessible room" }),
|
|
||||||
itemCode: availablePackages.find(
|
|
||||||
(pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
|
||||||
)?.itemCode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: RoomPackageCodeEnum.ALLERGY_ROOM,
|
|
||||||
description: intl.formatMessage({ id: "Allergy-friendly room" }),
|
|
||||||
itemCode: availablePackages.find(
|
|
||||||
(pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
|
||||||
)?.itemCode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: RoomPackageCodeEnum.PET_ROOM,
|
|
||||||
description: intl.formatMessage({ id: "Pet room" }),
|
|
||||||
itemCode: availablePackages.find(
|
|
||||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
|
||||||
)?.itemCode,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[availablePackages, intl]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (roomsAvailability) {
|
|
||||||
setRoomsAvailability(roomsAvailability)
|
|
||||||
}
|
|
||||||
setVisibleRooms()
|
|
||||||
}, [roomsAvailability, setRoomsAvailability, setVisibleRooms])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
selectedRates.length > 0 &&
|
|
||||||
selectedRates.some((rate) => rate !== undefined)
|
|
||||||
) {
|
|
||||||
calculateRateSummary({
|
|
||||||
getFilteredRooms,
|
|
||||||
availablePackages,
|
|
||||||
roomCategories,
|
|
||||||
selectedPackagesByRoom,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
selectedRates,
|
|
||||||
getFilteredRooms,
|
|
||||||
availablePackages,
|
|
||||||
roomCategories,
|
|
||||||
selectedPackagesByRoom,
|
|
||||||
calculateRateSummary,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const pricesWithCurrencies = visibleRooms.flatMap((room) =>
|
|
||||||
room.products.map((product) => ({
|
|
||||||
price: product.productType.public.localPrice.pricePerNight,
|
|
||||||
currency: product.productType.public.localPrice.currency,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
const lowestPrice = pricesWithCurrencies.reduce(
|
|
||||||
(minPrice, { price }) => Math.min(minPrice, price),
|
|
||||||
Infinity
|
|
||||||
)
|
|
||||||
|
|
||||||
const currency = pricesWithCurrencies[0]?.currency
|
|
||||||
|
|
||||||
trackLowestRoomPrice({
|
|
||||||
hotelId,
|
|
||||||
arrivalDate,
|
|
||||||
departureDate,
|
|
||||||
lowestPrice: lowestPrice,
|
|
||||||
currency: currency,
|
|
||||||
})
|
|
||||||
}, [arrivalDate, departureDate, hotelId, visibleRooms])
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault()
|
|
||||||
startTransition(() => {
|
|
||||||
const rooms = rateSummary.map((rate, index) => ({
|
|
||||||
roomTypeCode: rate?.roomTypeCode,
|
|
||||||
rateCode: rate?.public.rateCode,
|
|
||||||
counterRateCode: rate?.member?.rateCode,
|
|
||||||
packages: selectedPackagesByRoom[index] || [],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const newSearchParams = convertObjToSearchParams({ rooms }, searchParams)
|
|
||||||
router.push(`details?${newSearchParams}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const SCROLL_OFFSET = 100
|
|
||||||
const roomElements = document.querySelectorAll(`.${styles.roomContainer}`)
|
|
||||||
|
|
||||||
let targetIndex: number
|
|
||||||
if (modifyRateIndex !== null) {
|
|
||||||
targetIndex = modifyRateIndex
|
|
||||||
} else {
|
|
||||||
const index = selectedRates.findIndex((rate) => rate === undefined)
|
|
||||||
targetIndex = index === -1 ? selectedRates.length - 1 : index - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedRoom = roomElements[targetIndex]
|
|
||||||
if (selectedRoom) {
|
|
||||||
const elementPosition = selectedRoom.getBoundingClientRect().top
|
|
||||||
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET
|
|
||||||
|
|
||||||
window.scrollTo({
|
|
||||||
top: offsetPosition,
|
|
||||||
behavior: "smooth",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [selectedRates, modifyRateIndex])
|
|
||||||
|
|
||||||
const getRoomState = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const isFirstRoom = index === 0
|
|
||||||
const hasPrevRoomBeenSelected = selectedRates[index - 1] !== undefined
|
|
||||||
const isCurrentRoomSelected = selectedRates[index] !== undefined
|
|
||||||
const isModifyRoom = modifyRateIndex === index
|
|
||||||
|
|
||||||
if (isModifyRoom && isCurrentRoomSelected) {
|
|
||||||
return { active: true, selected: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCurrentRoomSelected) {
|
|
||||||
return { active: false, selected: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(isFirstRoom || hasPrevRoomBeenSelected) &&
|
|
||||||
modifyRateIndex === null
|
|
||||||
) {
|
|
||||||
return { active: true, selected: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { active: false, selected: false }
|
|
||||||
},
|
|
||||||
[modifyRateIndex, selectedRates]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.content}>
|
|
||||||
{isMultipleRooms ? (
|
|
||||||
bookingWidgetSearchData.rooms.map((room, index) => {
|
|
||||||
const roomState = getRoomState(index)
|
|
||||||
const classNames = roomSelectionPanelVariants(roomState)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={index} className={styles.roomContainer}>
|
|
||||||
{!roomState.selected && (
|
|
||||||
<header className={styles.header}>
|
|
||||||
<Subtitle>
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "Room {roomIndex}" },
|
|
||||||
{ roomIndex: index + 1 }
|
|
||||||
)}
|
|
||||||
,{" "}
|
|
||||||
{intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: room.childrenInRoom?.length
|
|
||||||
? "{adults} adults, {children} children"
|
|
||||||
: "{adults} adults",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
adults: room.adults,
|
|
||||||
children: room.childrenInRoom?.length,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Subtitle>
|
|
||||||
{modifyRateIndex === index ? (
|
|
||||||
<Button
|
|
||||||
variant="icon"
|
|
||||||
size="medium"
|
|
||||||
intent="text"
|
|
||||||
theme="base"
|
|
||||||
onClick={closeModifyRate}
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "Close" })}
|
|
||||||
<ChevronDownSmallIcon />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={classNames}>
|
|
||||||
<div className={styles.roomPanel}>
|
|
||||||
<SelectedRoomPanel
|
|
||||||
roomIndex={index}
|
|
||||||
room={room}
|
|
||||||
roomCategories={roomCategories}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.roomSelectionPanel}>
|
|
||||||
<RoomSelectionPanel
|
|
||||||
availablePackages={availablePackages}
|
|
||||||
defaultPackages={defaultPackages}
|
|
||||||
hotelType={hotelType}
|
|
||||||
roomCategories={roomCategories}
|
|
||||||
roomListIndex={index}
|
|
||||||
selectedPackages={selectedPackagesByRoom[index]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<RoomSelectionPanel
|
|
||||||
availablePackages={availablePackages}
|
|
||||||
defaultPackages={defaultPackages}
|
|
||||||
hotelType={hotelType}
|
|
||||||
roomCategories={roomCategories}
|
|
||||||
roomListIndex={0}
|
|
||||||
selectedPackages={selectedPackagesByRoom[0]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{rateSummary && roomsAvailability && (
|
|
||||||
<form
|
|
||||||
method="GET"
|
|
||||||
action={`details?${searchParams}`}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
<RateSummary
|
|
||||||
isUserLoggedIn={isUserLoggedIn}
|
|
||||||
packages={availablePackages}
|
|
||||||
roomsAvailability={roomsAvailability}
|
|
||||||
booking={bookingWidgetSearchData}
|
|
||||||
vat={vat}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
|
||||||
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the lowest priced room for each room type that appears more than once.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function filterDuplicateRoomTypesByLowestPrice(
|
|
||||||
roomConfigurations: RoomConfiguration[]
|
|
||||||
): RoomConfiguration[] {
|
|
||||||
const roomTypeCount = roomConfigurations.reduce<Record<string, number>>(
|
|
||||||
(roomTypeTally, currentRoom) => {
|
|
||||||
const currentRoomType = currentRoom.roomType
|
|
||||||
const currentCount = roomTypeTally[currentRoomType] || 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
...roomTypeTally,
|
|
||||||
[currentRoomType]: currentCount + 1,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
|
|
||||||
const duplicateRoomTypes = new Set(
|
|
||||||
Object.keys(roomTypeCount).filter((roomType) => roomTypeCount[roomType] > 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
const roomMap = new Map()
|
|
||||||
|
|
||||||
roomConfigurations.forEach((room) => {
|
|
||||||
const { roomType, products, status } = room
|
|
||||||
|
|
||||||
if (!duplicateRoomTypes.has(roomType)) {
|
|
||||||
roomMap.set(roomType, room)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousRoom = roomMap.get(roomType)
|
|
||||||
|
|
||||||
// Prioritize 'Available' status
|
|
||||||
if (
|
|
||||||
status === AvailabilityEnum.Available &&
|
|
||||||
previousRoom?.status === AvailabilityEnum.NotAvailable
|
|
||||||
) {
|
|
||||||
roomMap.set(roomType, room)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
status === AvailabilityEnum.NotAvailable &&
|
|
||||||
previousRoom?.status === AvailabilityEnum.Available
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousRoom) {
|
|
||||||
products.forEach((product) => {
|
|
||||||
const { productType } = product
|
|
||||||
const publicProduct = productType.public || {
|
|
||||||
requestedPrice: null,
|
|
||||||
localPrice: null,
|
|
||||||
}
|
|
||||||
const memberProduct = productType.member || {
|
|
||||||
requestedPrice: null,
|
|
||||||
localPrice: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
requestedPrice: publicRequestedPrice,
|
|
||||||
localPrice: publicLocalPrice,
|
|
||||||
} = publicProduct
|
|
||||||
const {
|
|
||||||
requestedPrice: memberRequestedPrice,
|
|
||||||
localPrice: memberLocalPrice,
|
|
||||||
} = memberProduct
|
|
||||||
|
|
||||||
const previousLowest = roomMap.get(roomType)
|
|
||||||
|
|
||||||
const currentRequestedPrice = Math.min(
|
|
||||||
Number(publicRequestedPrice?.pricePerNight) ?? Infinity,
|
|
||||||
Number(memberRequestedPrice?.pricePerNight) ?? Infinity
|
|
||||||
)
|
|
||||||
const currentLocalPrice = Math.min(
|
|
||||||
Number(publicLocalPrice?.pricePerNight) ?? Infinity,
|
|
||||||
Number(memberLocalPrice?.pricePerNight) ?? Infinity
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
!previousLowest ||
|
|
||||||
currentRequestedPrice <
|
|
||||||
Math.min(
|
|
||||||
Number(
|
|
||||||
previousLowest.products[0].productType.public.requestedPrice
|
|
||||||
?.pricePerNight
|
|
||||||
) ?? Infinity,
|
|
||||||
Number(
|
|
||||||
previousLowest.products[0].productType.member?.requestedPrice
|
|
||||||
?.pricePerNight
|
|
||||||
) ?? Infinity
|
|
||||||
) ||
|
|
||||||
(currentRequestedPrice ===
|
|
||||||
Math.min(
|
|
||||||
Number(
|
|
||||||
previousLowest.products[0].productType.public.requestedPrice
|
|
||||||
?.pricePerNight
|
|
||||||
) ?? Infinity,
|
|
||||||
Number(
|
|
||||||
previousLowest.products[0].productType.member?.requestedPrice
|
|
||||||
?.pricePerNight
|
|
||||||
) ?? Infinity
|
|
||||||
) &&
|
|
||||||
currentLocalPrice <
|
|
||||||
Math.min(
|
|
||||||
Number(
|
|
||||||
previousLowest.products[0].productType.public.localPrice
|
|
||||||
?.pricePerNight
|
|
||||||
) ?? Infinity,
|
|
||||||
Number(
|
|
||||||
previousLowest.products[0].productType.member?.localPrice
|
|
||||||
?.pricePerNight
|
|
||||||
) ?? Infinity
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
roomMap.set(roomType, room)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
roomMap.set(roomType, room)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.from(roomMap.values())
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { Fragment } from "react"
|
||||||
import React from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { dt } from "@/lib/dt"
|
import { dt } from "@/lib/dt"
|
||||||
@@ -34,7 +33,6 @@ export default function Summary({
|
|||||||
isMember,
|
isMember,
|
||||||
vat,
|
vat,
|
||||||
toggleSummaryOpen,
|
toggleSummaryOpen,
|
||||||
togglePriceDetailsModalOpen,
|
|
||||||
}: SelectRateSummaryProps) {
|
}: SelectRateSummaryProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
@@ -46,18 +44,6 @@ export default function Summary({
|
|||||||
{ totalNights: diff }
|
{ totalNights: diff }
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleToggleSummary() {
|
|
||||||
if (toggleSummaryOpen) {
|
|
||||||
toggleSummaryOpen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTogglePriceDetailsModal() {
|
|
||||||
if (togglePriceDetailsModalOpen) {
|
|
||||||
togglePriceDetailsModalOpen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMemberPrice(roomRate: RoomRate) {
|
function getMemberPrice(roomRate: RoomRate) {
|
||||||
return roomRate?.memberRate
|
return roomRate?.memberRate
|
||||||
? {
|
? {
|
||||||
@@ -85,7 +71,7 @@ export default function Summary({
|
|||||||
intent="text"
|
intent="text"
|
||||||
size="small"
|
size="small"
|
||||||
className={styles.chevronButton}
|
className={styles.chevronButton}
|
||||||
onClick={handleToggleSummary}
|
onClick={toggleSummaryOpen}
|
||||||
>
|
>
|
||||||
<ChevronDownSmallIcon height="20" width="20" />
|
<ChevronDownSmallIcon height="20" width="20" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -135,7 +121,7 @@ export default function Summary({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={idx}>
|
<Fragment key={idx}>
|
||||||
<div
|
<div
|
||||||
className={styles.addOns}
|
className={styles.addOns}
|
||||||
data-testid={`summary-room-${roomNumber}`}
|
data-testid={`summary-room-${roomNumber}`}
|
||||||
@@ -237,7 +223,7 @@ export default function Summary({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Divider color="primaryLightSubtle" />
|
<Divider color="primaryLightSubtle" />
|
||||||
</React.Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<div className={styles.total}>
|
<div className={styles.total}>
|
||||||
@@ -260,7 +246,6 @@ export default function Summary({
|
|||||||
}))}
|
}))}
|
||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
vat={vat}
|
vat={vat}
|
||||||
toggleModal={handleTogglePriceDetailsModal}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef } from "react"
|
"use client"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
import { useRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
@@ -15,60 +16,26 @@ import styles from "./mobileSummary.module.css"
|
|||||||
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||||
|
|
||||||
export default function MobileSummary({
|
export default function MobileSummary({
|
||||||
totalPriceToShow,
|
|
||||||
isAllRoomsSelected,
|
isAllRoomsSelected,
|
||||||
booking,
|
|
||||||
isUserLoggedIn,
|
isUserLoggedIn,
|
||||||
vat,
|
totalPriceToShow,
|
||||||
roomsAvailability,
|
|
||||||
}: MobileSummaryProps) {
|
}: MobileSummaryProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const scrollY = useRef(0)
|
const scrollY = useRef(0)
|
||||||
|
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
|
||||||
|
|
||||||
const {
|
const { booking, bookingRooms, rateDefinitions, rateSummary, vat } =
|
||||||
guestsInRooms,
|
useRatesStore((state) => ({
|
||||||
isSummaryOpen,
|
booking: state.booking,
|
||||||
getSelectedRateSummary,
|
bookingRooms: state.booking.rooms,
|
||||||
toggleSummaryOpen,
|
rateDefinitions: state.roomsAvailability?.rateDefinitions,
|
||||||
togglePriceDetailsModalOpen,
|
rateSummary: state.rateSummary,
|
||||||
} = useRateSelectionStore()
|
vat: state.vat,
|
||||||
|
}))
|
||||||
|
|
||||||
const selectedRateSummary = getSelectedRateSummary()
|
function toggleSummaryOpen() {
|
||||||
|
setIsSummaryOpen(!isSummaryOpen)
|
||||||
const rooms = selectedRateSummary.map((room, index) => ({
|
}
|
||||||
adults: guestsInRooms[index].adults,
|
|
||||||
childrenInRoom: guestsInRooms[index].children,
|
|
||||||
roomType: room.roomType,
|
|
||||||
roomPrice: {
|
|
||||||
perNight: {
|
|
||||||
local: {
|
|
||||||
price: room.public.localPrice.pricePerNight,
|
|
||||||
currency: room.public.localPrice.currency,
|
|
||||||
},
|
|
||||||
requested: undefined,
|
|
||||||
},
|
|
||||||
perStay: {
|
|
||||||
local: {
|
|
||||||
price: room.public.localPrice.pricePerStay,
|
|
||||||
currency: room.public.localPrice.currency,
|
|
||||||
},
|
|
||||||
requested: undefined,
|
|
||||||
},
|
|
||||||
currency: room.public.localPrice.currency,
|
|
||||||
},
|
|
||||||
roomRate: {
|
|
||||||
...room.public,
|
|
||||||
memberRate: room.member,
|
|
||||||
publicRate: room.public,
|
|
||||||
},
|
|
||||||
rateDetails: roomsAvailability.rateDefinitions.find(
|
|
||||||
(rate) => rate.rateCode === room.public.rateCode
|
|
||||||
)?.generalTerms,
|
|
||||||
cancellationText:
|
|
||||||
roomsAvailability.rateDefinitions.find(
|
|
||||||
(rate) => rate.rateCode === room.public.rateCode
|
|
||||||
)?.cancellationText ?? "",
|
|
||||||
}))
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSummaryOpen) {
|
if (isSummaryOpen) {
|
||||||
@@ -93,6 +60,44 @@ export default function MobileSummary({
|
|||||||
}
|
}
|
||||||
}, [isSummaryOpen])
|
}, [isSummaryOpen])
|
||||||
|
|
||||||
|
if (!rateDefinitions) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rooms = rateSummary.map((room, index) => ({
|
||||||
|
adults: bookingRooms[index].adults,
|
||||||
|
childrenInRoom: bookingRooms[index].childrenInRoom ?? undefined,
|
||||||
|
roomType: room.roomType,
|
||||||
|
roomPrice: {
|
||||||
|
perNight: {
|
||||||
|
local: {
|
||||||
|
price: room.public.localPrice.pricePerNight,
|
||||||
|
currency: room.public.localPrice.currency,
|
||||||
|
},
|
||||||
|
requested: undefined,
|
||||||
|
},
|
||||||
|
perStay: {
|
||||||
|
local: {
|
||||||
|
price: room.public.localPrice.pricePerStay,
|
||||||
|
currency: room.public.localPrice.currency,
|
||||||
|
},
|
||||||
|
requested: undefined,
|
||||||
|
},
|
||||||
|
currency: room.public.localPrice.currency,
|
||||||
|
},
|
||||||
|
roomRate: {
|
||||||
|
...room.public,
|
||||||
|
memberRate: room.member,
|
||||||
|
publicRate: room.public,
|
||||||
|
},
|
||||||
|
rateDetails: rateDefinitions.find(
|
||||||
|
(rate) => rate.rateCode === room.public.rateCode
|
||||||
|
)?.generalTerms,
|
||||||
|
cancellationText:
|
||||||
|
rateDefinitions.find((rate) => rate.rateCode === room.public.rateCode)
|
||||||
|
?.cancellationText ?? "",
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -104,7 +109,6 @@ export default function MobileSummary({
|
|||||||
totalPrice={totalPriceToShow}
|
totalPrice={totalPriceToShow}
|
||||||
vat={vat}
|
vat={vat}
|
||||||
toggleSummaryOpen={toggleSummaryOpen}
|
toggleSummaryOpen={toggleSummaryOpen}
|
||||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"use client"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTransition } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { dt } from "@/lib/dt"
|
||||||
|
import { useRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
|
import { getRates } from "@/components/HotelReservation/SelectRate/utils"
|
||||||
|
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||||
|
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
|
||||||
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import { formatPrice } from "@/utils/numberFormatting"
|
||||||
|
|
||||||
|
import MobileSummary from "./MobileSummary"
|
||||||
|
import { calculateTotalPrice } from "./utils"
|
||||||
|
|
||||||
|
import styles from "./rateSummary.module.css"
|
||||||
|
|
||||||
|
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||||
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
|
|
||||||
|
export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||||
|
const {
|
||||||
|
bookingRooms,
|
||||||
|
petRoomPackage,
|
||||||
|
rateSummary,
|
||||||
|
roomsAvailability,
|
||||||
|
searchParams,
|
||||||
|
} = useRatesStore((state) => ({
|
||||||
|
bookingRooms: state.booking.rooms,
|
||||||
|
petRoomPackage: state.petRoomPackage,
|
||||||
|
rateSummary: state.rateSummary,
|
||||||
|
roomsAvailability: state.roomsAvailability,
|
||||||
|
searchParams: state.searchParams,
|
||||||
|
}))
|
||||||
|
const intl = useIntl()
|
||||||
|
const router = useRouter()
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
const [_, startTransition] = useTransition()
|
||||||
|
|
||||||
|
if (!roomsAvailability) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkInDate = new Date(roomsAvailability.checkInDate)
|
||||||
|
const checkOutDate = new Date(roomsAvailability.checkOutDate)
|
||||||
|
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
|
||||||
|
|
||||||
|
const totalNights = intl.formatMessage(
|
||||||
|
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||||
|
{ totalNights: nights }
|
||||||
|
)
|
||||||
|
const totalAdults = intl.formatMessage(
|
||||||
|
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
||||||
|
{ totalAdults: bookingRooms.reduce((acc, room) => acc + room.adults, 0) }
|
||||||
|
)
|
||||||
|
const childrenInOneOrMoreRooms = bookingRooms.some(
|
||||||
|
(room) => room.childrenInRoom?.length
|
||||||
|
)
|
||||||
|
const childrenInroom = intl.formatMessage(
|
||||||
|
{ id: "{totalChildren, plural, one {# child} other {# children}}" },
|
||||||
|
{
|
||||||
|
totalChildren: bookingRooms.reduce(
|
||||||
|
(acc, room) => acc + (room.childrenInRoom?.length ?? 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const totalChildren = childrenInOneOrMoreRooms ? `, ${childrenInroom}` : ""
|
||||||
|
const totalRooms = intl.formatMessage(
|
||||||
|
{ id: "{totalRooms, plural, one {# room} other {# rooms}}" },
|
||||||
|
{ totalRooms: bookingRooms.length }
|
||||||
|
)
|
||||||
|
|
||||||
|
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
|
||||||
|
|
||||||
|
const totalRoomsRequired = bookingRooms.length
|
||||||
|
const isAllRoomsSelected = rateSummary.length === totalRoomsRequired
|
||||||
|
const hasMemberRates = rateSummary.some((room) => room.member)
|
||||||
|
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
||||||
|
|
||||||
|
const rates = getRates(roomsAvailability.rateDefinitions)
|
||||||
|
|
||||||
|
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||||
|
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
||||||
|
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
|
||||||
|
const payLater = intl.formatMessage({ id: "Pay later" })
|
||||||
|
const payNow = intl.formatMessage({ id: "Pay now" })
|
||||||
|
|
||||||
|
function getRateDetails(rateCode: string) {
|
||||||
|
const rate = Object.keys(rates).find((k) =>
|
||||||
|
rates[k as keyof typeof rates].find((a) => a.rateCode === rateCode)
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (rate) {
|
||||||
|
case "change":
|
||||||
|
return `${freeBooking}, ${payNow}`
|
||||||
|
case "flex":
|
||||||
|
return `${freeCancelation}, ${payLater}`
|
||||||
|
case "save":
|
||||||
|
default:
|
||||||
|
return `${nonRefundable}, ${payNow}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`details?${params}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rateSummary.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPriceToShow = calculateTotalPrice(
|
||||||
|
rateSummary,
|
||||||
|
isUserLoggedIn,
|
||||||
|
petRoomPackage
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={`details?${params}`} method="GET" onSubmit={handleSubmit}>
|
||||||
|
<div className={styles.summary}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.summaryText}>
|
||||||
|
{rateSummary.map((room, index) => (
|
||||||
|
<div key={index} className={styles.roomSummary}>
|
||||||
|
<Subtitle color="uiTextHighContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "Room {roomIndex}" },
|
||||||
|
{ roomIndex: index + 1 }
|
||||||
|
)}
|
||||||
|
</Subtitle>
|
||||||
|
<Body color="uiTextMediumContrast">{room.roomType}</Body>
|
||||||
|
<Caption color="uiTextMediumContrast">
|
||||||
|
{getRateDetails(
|
||||||
|
isUserLoggedIn && room.member
|
||||||
|
? room.member?.rateCode
|
||||||
|
: room.public.rateCode
|
||||||
|
)}
|
||||||
|
</Caption>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Render unselected rooms */}
|
||||||
|
{Array.from({
|
||||||
|
length: totalRoomsRequired - rateSummary.length,
|
||||||
|
}).map((_, index) => (
|
||||||
|
<div key={`unselected-${index}`} className={styles.roomSummary}>
|
||||||
|
<Subtitle color="uiTextPlaceholder">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "Room {roomIndex}" },
|
||||||
|
{ roomIndex: rateSummary.length + index + 1 }
|
||||||
|
)}
|
||||||
|
</Subtitle>
|
||||||
|
<Body color="uiTextPlaceholder">
|
||||||
|
{intl.formatMessage({ id: "Select room" })}
|
||||||
|
</Body>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryPriceContainer}>
|
||||||
|
{showMemberDiscountBanner && (
|
||||||
|
<div className={styles.promoContainer}>
|
||||||
|
<SignupPromoDesktop
|
||||||
|
memberPrice={{
|
||||||
|
amount: rateSummary.reduce((total, room) => {
|
||||||
|
const memberPrice =
|
||||||
|
room.member?.localPrice.pricePerStay ?? 0
|
||||||
|
const isPetRoom = room.features.find(
|
||||||
|
(feature) =>
|
||||||
|
feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||||
|
)
|
||||||
|
const petRoomPrice =
|
||||||
|
isPetRoom && petRoomPackage
|
||||||
|
? Number(petRoomPackage.localPrice.totalPrice)
|
||||||
|
: 0
|
||||||
|
return total + memberPrice + petRoomPrice
|
||||||
|
}, 0),
|
||||||
|
currency:
|
||||||
|
rateSummary[0].member?.localPrice.currency ??
|
||||||
|
rateSummary[0].public.localPrice.currency,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.summaryPriceTextDesktop}>
|
||||||
|
<Body>
|
||||||
|
{intl.formatMessage<React.ReactNode>(
|
||||||
|
{ id: "<b>Total price</b> (incl VAT)" },
|
||||||
|
{ b: (str) => <b>{str}</b> }
|
||||||
|
)}
|
||||||
|
</Body>
|
||||||
|
<Caption color="uiTextMediumContrast">{summaryPriceText}</Caption>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryPrice}>
|
||||||
|
<div className={styles.summaryPriceTextDesktop}>
|
||||||
|
<Subtitle
|
||||||
|
color={isUserLoggedIn ? "red" : "uiTextHighContrast"}
|
||||||
|
textAlign="right"
|
||||||
|
>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
totalPriceToShow.local.price,
|
||||||
|
totalPriceToShow.local.currency
|
||||||
|
)}
|
||||||
|
</Subtitle>
|
||||||
|
{totalPriceToShow.requested ? (
|
||||||
|
<Body color="uiTextMediumContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "Approx. {value}" },
|
||||||
|
{
|
||||||
|
value: formatPrice(
|
||||||
|
intl,
|
||||||
|
totalPriceToShow.requested.price,
|
||||||
|
totalPriceToShow.requested.currency
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Body>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryPriceTextMobile}>
|
||||||
|
<Caption color="uiTextHighContrast">
|
||||||
|
{intl.formatMessage({ id: "Total price" })}
|
||||||
|
</Caption>
|
||||||
|
<Subtitle color={isUserLoggedIn ? "red" : "uiTextHighContrast"}>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
totalPriceToShow.local.price,
|
||||||
|
totalPriceToShow.local.currency
|
||||||
|
)}
|
||||||
|
</Subtitle>
|
||||||
|
<Footnote
|
||||||
|
color="uiTextMediumContrast"
|
||||||
|
className={styles.summaryPriceTextMobile}
|
||||||
|
>
|
||||||
|
{summaryPriceText}
|
||||||
|
</Footnote>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={styles.continueButton}
|
||||||
|
disabled={!isAllRoomsSelected}
|
||||||
|
theme="base"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "Continue" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.mobileSummary}>
|
||||||
|
{showMemberDiscountBanner ? <SignupPromoMobile /> : null}
|
||||||
|
<MobileSummary
|
||||||
|
isAllRoomsSelected={isAllRoomsSelected}
|
||||||
|
isUserLoggedIn={isUserLoggedIn}
|
||||||
|
totalPriceToShow={totalPriceToShow}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
|
@keyframes slideUp {
|
||||||
|
0% {
|
||||||
|
bottom: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
bottom: 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.summary {
|
.summary {
|
||||||
position: fixed;
|
align-items: center;
|
||||||
z-index: 10;
|
animation: slideUp 300ms ease forwards;
|
||||||
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
border-top: 1px solid var(--Base-Border-Subtle);
|
||||||
bottom: -100%;
|
bottom: -100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
position: fixed;
|
||||||
right: 0;
|
right: 0;
|
||||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
z-index: 10;
|
||||||
align-items: center;
|
|
||||||
transition: bottom 300ms ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
width: 100%;
|
|
||||||
max-width: var(--max-width-page);
|
|
||||||
margin: 0 auto;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary[data-visible="true"] {
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summaryPriceContainer {
|
.summaryPriceContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
display: none;
|
display: none;
|
||||||
max-width: 264px;
|
max-width: 264px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryPrice {
|
.summaryPrice {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -74,34 +76,46 @@
|
|||||||
|
|
||||||
@media (min-width: 1367px) {
|
@media (min-width: 1367px) {
|
||||||
.summary {
|
.summary {
|
||||||
padding: var(--Spacing-x3) 0 var(--Spacing-x5);
|
|
||||||
border-top: 1px solid var(--Base-Border-Subtle);
|
border-top: 1px solid var(--Base-Border-Subtle);
|
||||||
|
padding: var(--Spacing-x3) 0 var(--Spacing-x5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: var(--max-width-page);
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.petInfo,
|
.petInfo,
|
||||||
.promoContainer,
|
.promoContainer,
|
||||||
.summaryPriceTextDesktop {
|
.summaryPriceTextDesktop {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryText {
|
.summaryText {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryPriceTextMobile {
|
.summaryPriceTextMobile {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryPrice,
|
.summaryPrice,
|
||||||
.continueButton {
|
.continueButton {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryPriceContainer {
|
.summaryPriceContainer {
|
||||||
width: auto;
|
width: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileSummary {
|
.mobileSummary {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -14,15 +14,18 @@ export const calculateTotalPrice = (
|
|||||||
(total, room) => {
|
(total, room) => {
|
||||||
const priceToUse =
|
const priceToUse =
|
||||||
isUserLoggedIn && room.member ? room.member : room.public
|
isUserLoggedIn && room.member ? room.member : room.public
|
||||||
const isPetRoom = room.features.some(
|
const isPetRoom = room.features.find(
|
||||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||||
)
|
)
|
||||||
const petRoomPrice =
|
|
||||||
isPetRoom && petRoomPackage
|
let petRoomPrice = 0
|
||||||
? isUserLoggedIn
|
if (
|
||||||
? Number(petRoomPackage.localPrice.totalPrice || 0)
|
petRoomPackage &&
|
||||||
: Number(petRoomPackage.requestedPrice.totalPrice || 0)
|
isPetRoom &&
|
||||||
: 0
|
room.package === RoomPackageCodeEnum.PET_ROOM
|
||||||
|
) {
|
||||||
|
petRoomPrice = Number(petRoomPackage.localPrice.totalPrice)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
local: {
|
local: {
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client"
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
|
import { getRates } from "@/components/HotelReservation/SelectRate/utils"
|
||||||
|
import { EditIcon } from "@/components/Icons"
|
||||||
|
import Image from "@/components/Image"
|
||||||
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import { useRoomContext } from "@/contexts/Room"
|
||||||
|
import { isValidClientSession } from "@/utils/clientSession"
|
||||||
|
|
||||||
|
import styles from "./selectedRoomPanel.module.css"
|
||||||
|
|
||||||
|
import type { Room as SelectedRateRoom } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
|
||||||
|
interface SelectedRoomPanelProps {
|
||||||
|
room: SelectedRateRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectedRoomPanel({ room }: SelectedRoomPanelProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const isUserLoggedIn = isValidClientSession(session)
|
||||||
|
const { rateDefinitions, roomCategories } = useRatesStore((state) => ({
|
||||||
|
rateDefinitions: state.roomsAvailability?.rateDefinitions,
|
||||||
|
roomCategories: state.roomCategories,
|
||||||
|
}))
|
||||||
|
const {
|
||||||
|
actions: { modifyRate },
|
||||||
|
roomNr,
|
||||||
|
selectedRate,
|
||||||
|
} = useRoomContext()
|
||||||
|
|
||||||
|
const images = roomCategories.find((roomCategory) =>
|
||||||
|
roomCategory.roomTypes.some(
|
||||||
|
(roomType) => roomType.code === selectedRate?.roomTypeCode
|
||||||
|
)
|
||||||
|
)?.images
|
||||||
|
|
||||||
|
if (!rateDefinitions) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rates = getRates(rateDefinitions)
|
||||||
|
|
||||||
|
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||||
|
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
||||||
|
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
|
||||||
|
const payLater = intl.formatMessage({ id: "Pay later" })
|
||||||
|
const payNow = intl.formatMessage({ id: "Pay now" })
|
||||||
|
|
||||||
|
function getRateDetails(rateCode: string) {
|
||||||
|
const rate = Object.keys(rates).find((k) =>
|
||||||
|
rates[k as keyof typeof rates].find((a) => a.rateCode === rateCode)
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (rate) {
|
||||||
|
case "change":
|
||||||
|
return `${freeBooking}, ${payNow}`
|
||||||
|
case "flex":
|
||||||
|
return `${freeCancelation}, ${payLater}`
|
||||||
|
case "save":
|
||||||
|
default:
|
||||||
|
return `${nonRefundable}, ${payNow}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateCode =
|
||||||
|
isUserLoggedIn && selectedRate?.product.productType.member
|
||||||
|
? selectedRate?.product.productType.member?.rateCode
|
||||||
|
: selectedRate?.product.productType.public.rateCode
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.selectedRoomPanel}>
|
||||||
|
<div>
|
||||||
|
<Caption color="uiTextHighContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "Room {roomIndex}" },
|
||||||
|
{ roomIndex: roomNr }
|
||||||
|
)}
|
||||||
|
</Caption>
|
||||||
|
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
|
||||||
|
{selectedRate?.roomType}
|
||||||
|
</Subtitle>
|
||||||
|
<Body color="uiTextMediumContrast">
|
||||||
|
{rateCode ? getRateDetails(rateCode) : null}
|
||||||
|
</Body>
|
||||||
|
<Body color="uiTextHighContrast">
|
||||||
|
{selectedRate?.product.productType.public.localPrice.pricePerNight}{" "}
|
||||||
|
{selectedRate?.product.productType.public.localPrice.currency}/
|
||||||
|
{intl.formatMessage({ id: "night" })}
|
||||||
|
</Body>
|
||||||
|
</div>
|
||||||
|
<div className={styles.imageAndModifyButtonContainer}>
|
||||||
|
{images?.[0]?.imageSizes?.tiny && (
|
||||||
|
<div className={styles.imageContainer}>
|
||||||
|
<Image
|
||||||
|
alt={selectedRate?.roomType ?? images[0].metaData?.altText ?? ""}
|
||||||
|
fill
|
||||||
|
src={images[0].imageSizes.tiny}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.modifyButtonContainer}>
|
||||||
|
<Button variant="icon" size="small" onClick={modifyRate}>
|
||||||
|
<EditIcon />
|
||||||
|
{intl.formatMessage({ id: "Modify" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,17 +25,23 @@
|
|||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.selectedRoomPanel p.subtitle {
|
||||||
|
padding-bottom: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.imageContainer {
|
.imageContainer {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.imageAndModifyButtonContainer {
|
.imageAndModifyButtonContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modifyButtonContainer {
|
.modifyButtonContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import { useRoomContext } from "@/contexts/Room"
|
||||||
|
|
||||||
|
import SelectedRoomPanel from "./SelectedRoomPanel"
|
||||||
|
import { roomSelectionPanelVariants } from "./variants"
|
||||||
|
|
||||||
|
import styles from "./multiRoomWrapper.module.css"
|
||||||
|
|
||||||
|
export default function MultiRoomWrapper({
|
||||||
|
children,
|
||||||
|
isMultiRoom,
|
||||||
|
}: React.PropsWithChildren<{ isMultiRoom: boolean }>) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const activeRoom = useRatesStore((state) => state.activeRoom)
|
||||||
|
const { bookingRoom, isActiveRoom, roomNr, selectedRate } = useRoomContext()
|
||||||
|
|
||||||
|
const onlyAdultsMsg = intl.formatMessage(
|
||||||
|
{ id: "{adults} adults" },
|
||||||
|
{ adults: bookingRoom.adults }
|
||||||
|
)
|
||||||
|
const adultsAndChildrenMsg = intl.formatMessage(
|
||||||
|
{ id: "{adults} adults, {children} children" },
|
||||||
|
{
|
||||||
|
adults: bookingRoom.adults,
|
||||||
|
children: bookingRoom.childrenInRoom?.length,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const SCROLL_OFFSET = 100
|
||||||
|
const roomElements = document.querySelectorAll(`.${styles.roomContainer}`)
|
||||||
|
|
||||||
|
const selectedRoom = roomElements[activeRoom]
|
||||||
|
|
||||||
|
if (selectedRoom) {
|
||||||
|
const elementPosition = selectedRoom.getBoundingClientRect().top
|
||||||
|
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [activeRoom])
|
||||||
|
|
||||||
|
if (isMultiRoom) {
|
||||||
|
const classNames = roomSelectionPanelVariants({
|
||||||
|
active: isActiveRoom,
|
||||||
|
selected: !!selectedRate && !isActiveRoom,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div className={styles.roomContainer}>
|
||||||
|
{selectedRate && !isActiveRoom ? null : (
|
||||||
|
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "Room {roomIndex}" },
|
||||||
|
{ roomIndex: roomNr }
|
||||||
|
)}
|
||||||
|
,{" "}
|
||||||
|
{bookingRoom.childrenInRoom?.length
|
||||||
|
? adultsAndChildrenMsg
|
||||||
|
: onlyAdultsMsg}
|
||||||
|
</Subtitle>
|
||||||
|
)}
|
||||||
|
<div className={classNames}>
|
||||||
|
<div className={styles.roomPanel}>
|
||||||
|
<SelectedRoomPanel room={bookingRoom} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.roomSelectionPanel}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -1,27 +1,10 @@
|
|||||||
.content {
|
|
||||||
max-width: var(--max-width-page);
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--Spacing-x2);
|
|
||||||
padding: var(--Spacing-x2) 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roomContainer {
|
.roomContainer {
|
||||||
display: flex;
|
background: var(--Base-Surface-Primary-light-Normal);
|
||||||
flex-direction: column;
|
|
||||||
border: 1px solid var(--Base-Border-Subtle);
|
border: 1px solid var(--Base-Border-Subtle);
|
||||||
border-radius: var(--Corner-radius-Large);
|
border-radius: var(--Corner-radius-Large);
|
||||||
padding: var(--Spacing-x3);
|
display: flex;
|
||||||
background: var(--Base-Surface-Primary-light-Normal);
|
flex-direction: column;
|
||||||
|
padding: var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.roomPanel,
|
.roomPanel,
|
||||||
@@ -36,7 +19,7 @@
|
|||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
.roomPanel > * {
|
.roomPanel>* {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +36,7 @@
|
|||||||
.roomSelectionPanelContainer.selected .roomSelectionPanel {
|
.roomSelectionPanelContainer.selected .roomSelectionPanel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.roomSelectionPanelContainer.active .roomSelectionPanel {
|
.roomSelectionPanelContainer.active .roomSelectionPanel {
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -60,14 +44,12 @@
|
|||||||
padding-top: var(--Spacing-x1);
|
padding-top: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotelAlert {
|
div.roomContainer p.subtitle {
|
||||||
width: 100%;
|
padding-bottom: var(--Spacing-x1);
|
||||||
margin: 0 auto;
|
|
||||||
padding: var(--Spacing-x-one-and-half);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.roomContainer {
|
.roomContainer {
|
||||||
padding: var(--Spacing-x2);
|
padding: var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cva } from "class-variance-authority"
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
import styles from "./rooms.module.css"
|
import styles from "./multiRoomWrapper.module.css"
|
||||||
|
|
||||||
export const roomSelectionPanelVariants = cva(
|
export const roomSelectionPanelVariants = cva(
|
||||||
styles.roomSelectionPanelContainer,
|
styles.roomSelectionPanelContainer,
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
|
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
|
||||||
@@ -8,6 +7,7 @@ import Button from "@/components/TempDesignSystem/Button"
|
|||||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
import { useRoomContext } from "@/contexts/Room"
|
||||||
|
|
||||||
import PriceTable from "./PriceList"
|
import PriceTable from "./PriceList"
|
||||||
|
|
||||||
@@ -16,16 +16,32 @@ import styles from "./flexibilityOption.module.css"
|
|||||||
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||||
|
|
||||||
export default function FlexibilityOption({
|
export default function FlexibilityOption({
|
||||||
handleSelect,
|
features,
|
||||||
isSelected,
|
isSelected,
|
||||||
isUserLoggedIn,
|
isUserLoggedIn,
|
||||||
paymentTerm,
|
paymentTerm,
|
||||||
priceInformation,
|
priceInformation,
|
||||||
petRoomPackage,
|
petRoomPackage,
|
||||||
product,
|
product,
|
||||||
|
roomType,
|
||||||
|
roomTypeCode,
|
||||||
title,
|
title,
|
||||||
}: FlexibilityOptionProps) {
|
}: FlexibilityOptionProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
const {
|
||||||
|
actions: { selectRate },
|
||||||
|
} = useRoomContext()
|
||||||
|
|
||||||
|
function handleSelect() {
|
||||||
|
if (product) {
|
||||||
|
selectRate({
|
||||||
|
features,
|
||||||
|
product,
|
||||||
|
roomType,
|
||||||
|
roomTypeCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return (
|
return (
|
||||||
@@ -48,18 +64,14 @@ export default function FlexibilityOption({
|
|||||||
|
|
||||||
const { public: publicPrice, member: memberPrice } = product.productType
|
const { public: publicPrice, member: memberPrice } = product.productType
|
||||||
|
|
||||||
function handleOnSelect() {
|
|
||||||
handleSelect(publicPrice.rateCode, title, paymentTerm)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
|
||||||
name={`rateCode-${product.productType.public.rateCode}`}
|
|
||||||
value={publicPrice?.rateCode}
|
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={handleOnSelect}
|
name={`rateCode-${product.productType.public.rateCode}`}
|
||||||
|
onChange={handleSelect}
|
||||||
|
type="radio"
|
||||||
|
value={publicPrice?.rateCode}
|
||||||
/>
|
/>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
|
||||||
|
import type { RoomSizeProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||||
|
|
||||||
|
export default function RoomSize({ roomSize }: RoomSizeProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
if (!roomSize) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomSize.min === roomSize.max) {
|
||||||
|
return (
|
||||||
|
<Caption color="uiTextMediumContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "{roomSize} m²" },
|
||||||
|
{ roomSize: roomSize.min }
|
||||||
|
)}
|
||||||
|
</Caption>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Caption color="uiTextMediumContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "{roomSizeMin} - {roomSizeMax} m²" },
|
||||||
|
{
|
||||||
|
roomSizeMin: roomSize.min,
|
||||||
|
roomSizeMax: roomSize.max,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Caption>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
import { createElement } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
|
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
|
||||||
|
import { getRates } from "@/components/HotelReservation/SelectRate/utils"
|
||||||
|
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||||
|
import { ErrorCircleIcon } from "@/components/Icons"
|
||||||
|
import ImageGallery from "@/components/ImageGallery"
|
||||||
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import { useRoomContext } from "@/contexts/Room"
|
||||||
|
import { isValidClientSession } from "@/utils/clientSession"
|
||||||
|
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||||
|
|
||||||
|
import { cardVariants } from "./cardVariants"
|
||||||
|
import FlexibilityOption from "./FlexibilityOption"
|
||||||
|
import RoomSize from "./RoomSize"
|
||||||
|
|
||||||
|
import styles from "./roomCard.module.css"
|
||||||
|
|
||||||
|
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||||
|
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||||
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
|
import { HotelTypeEnum } from "@/types/enums/hotelType"
|
||||||
|
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
|
|
||||||
|
function getBreakfastMessage(
|
||||||
|
publicBreakfastIncluded: boolean,
|
||||||
|
memberBreakfastIncluded: boolean,
|
||||||
|
hotelType: string | undefined,
|
||||||
|
userIsLoggedIn: boolean,
|
||||||
|
msgs: Record<"included" | "noSelection" | "scandicgo" | "notIncluded", string>
|
||||||
|
) {
|
||||||
|
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||||
|
return msgs.scandicgo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIsLoggedIn && memberBreakfastIncluded) {
|
||||||
|
return msgs.included
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicBreakfastIncluded && memberBreakfastIncluded) {
|
||||||
|
return msgs.included
|
||||||
|
}
|
||||||
|
|
||||||
|
/** selected and rate does not include breakfast */
|
||||||
|
if (false) {
|
||||||
|
return msgs.notIncluded
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
|
||||||
|
return msgs.notIncluded
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs.noSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoomCard({ roomConfiguration }: RoomCardProps) {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const isUserLoggedIn = isValidClientSession(session)
|
||||||
|
const intl = useIntl()
|
||||||
|
const lessThanFiveRoomsLeft =
|
||||||
|
roomConfiguration.roomsLeft > 0 && roomConfiguration.roomsLeft < 5
|
||||||
|
|
||||||
|
const {
|
||||||
|
hotelId,
|
||||||
|
hotelType,
|
||||||
|
petRoomPackage,
|
||||||
|
rateDefinitions,
|
||||||
|
roomCategories,
|
||||||
|
} = useRatesStore((state) => ({
|
||||||
|
hotelId: state.booking.hotelId,
|
||||||
|
hotelType: state.hotelType,
|
||||||
|
petRoomPackage: state.petRoomPackage,
|
||||||
|
rateDefinitions: state.roomsAvailability?.rateDefinitions,
|
||||||
|
roomCategories: state.roomCategories,
|
||||||
|
}))
|
||||||
|
const { selectedPackage, selectedRate } = useRoomContext()
|
||||||
|
|
||||||
|
const classNames = cardVariants({
|
||||||
|
availability:
|
||||||
|
roomConfiguration.status === AvailabilityEnum.NotAvailable
|
||||||
|
? "noAvailability"
|
||||||
|
: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
const breakfastMessages = {
|
||||||
|
included: intl.formatMessage({ id: "Breakfast is included." }),
|
||||||
|
notIncluded: intl.formatMessage({
|
||||||
|
id: "Breakfast selection in next step.",
|
||||||
|
}),
|
||||||
|
noSelection: intl.formatMessage({ id: "Select a rate" }),
|
||||||
|
scandicgo: intl.formatMessage({
|
||||||
|
id: "Breakfast deal can be purchased at the hotel.",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const breakfastMessage = getBreakfastMessage(
|
||||||
|
roomConfiguration.breakfastIncludedInAllRatesPublic,
|
||||||
|
roomConfiguration.breakfastIncludedInAllRatesMember,
|
||||||
|
hotelType,
|
||||||
|
isUserLoggedIn,
|
||||||
|
breakfastMessages
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!rateDefinitions) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rates = getRates(rateDefinitions)
|
||||||
|
|
||||||
|
const petRoomPackageSelected =
|
||||||
|
(selectedPackage === RoomPackageCodeEnum.PET_ROOM && petRoomPackage) ||
|
||||||
|
undefined
|
||||||
|
|
||||||
|
const selectedRoom = roomCategories.find((roomCategory) =>
|
||||||
|
roomCategory.roomTypes.find(
|
||||||
|
(roomType) => roomType.code === roomConfiguration.roomTypeCode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { name, roomSize, totalOccupancy, images } = selectedRoom || {}
|
||||||
|
const galleryImages = mapApiImagesToGalleryImages(images || [])
|
||||||
|
|
||||||
|
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||||
|
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
||||||
|
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
|
||||||
|
const payLater = intl.formatMessage({ id: "Pay later" })
|
||||||
|
const payNow = intl.formatMessage({ id: "Pay now" })
|
||||||
|
|
||||||
|
function getRate(rateCode: string) {
|
||||||
|
switch (rateCode) {
|
||||||
|
case "change":
|
||||||
|
return {
|
||||||
|
isFlex: false,
|
||||||
|
notAvailable: false,
|
||||||
|
title: freeBooking,
|
||||||
|
}
|
||||||
|
case "flex":
|
||||||
|
return {
|
||||||
|
isFlex: true,
|
||||||
|
notAvailable: false,
|
||||||
|
title: freeCancelation,
|
||||||
|
}
|
||||||
|
case "save":
|
||||||
|
return {
|
||||||
|
isFlex: false,
|
||||||
|
notAvailable: false,
|
||||||
|
title: nonRefundable,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unknown key for rate, should be "change", "flex" or "save", but got ${rateCode}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRateInfo(product: Product) {
|
||||||
|
if (
|
||||||
|
!product.productType.public.rateCode &&
|
||||||
|
!product.productType.member?.rateCode
|
||||||
|
) {
|
||||||
|
const possibleRate = getRate(product.productType.public.rate)
|
||||||
|
if (possibleRate) {
|
||||||
|
return {
|
||||||
|
...possibleRate,
|
||||||
|
notAvailable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isFlex: false,
|
||||||
|
notAvailable: true,
|
||||||
|
title: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicRate = Object.keys(rates).find((k) =>
|
||||||
|
rates[k as keyof typeof rates].find(
|
||||||
|
(a) => a.rateCode === product.productType.public.rateCode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let memberRate
|
||||||
|
if (product.productType.member) {
|
||||||
|
memberRate = Object.keys(rates).find((k) =>
|
||||||
|
rates[k as keyof typeof rates].find(
|
||||||
|
(a) => a.rateCode === product.productType.member!.rateCode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publicRate || !memberRate) {
|
||||||
|
throw new Error("We should never make it where without rateCodes")
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = isUserLoggedIn ? memberRate : publicRate
|
||||||
|
return getRate(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={classNames}>
|
||||||
|
<div>
|
||||||
|
<div className={styles.imageContainer}>
|
||||||
|
<div className={styles.chipContainer}>
|
||||||
|
{lessThanFiveRoomsLeft ? (
|
||||||
|
<span className={styles.chip}>
|
||||||
|
<Footnote color="burgundy" textTransform="uppercase">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "{amount, number} left" },
|
||||||
|
{ amount: roomConfiguration.roomsLeft }
|
||||||
|
)}
|
||||||
|
</Footnote>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{roomConfiguration.features
|
||||||
|
.filter((feature) => selectedPackage === feature.code)
|
||||||
|
.map((feature) => (
|
||||||
|
<span className={styles.chip} key={feature.code}>
|
||||||
|
{createElement(getIconForFeatureCode(feature.code), {
|
||||||
|
color: "burgundy",
|
||||||
|
height: 16,
|
||||||
|
width: 16,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ImageGallery
|
||||||
|
images={galleryImages}
|
||||||
|
title={roomConfiguration.roomType}
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.specification}>
|
||||||
|
{totalOccupancy && (
|
||||||
|
<Caption color="uiTextMediumContrast" className={styles.guests}>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "Max {max, plural, one {{range} guest} other {{range} guests}}",
|
||||||
|
},
|
||||||
|
{ max: totalOccupancy.max, range: totalOccupancy.range }
|
||||||
|
)}
|
||||||
|
</Caption>
|
||||||
|
)}
|
||||||
|
<RoomSize roomSize={roomSize} />
|
||||||
|
<div className={styles.toggleSidePeek}>
|
||||||
|
{roomConfiguration.roomTypeCode && (
|
||||||
|
<ToggleSidePeek
|
||||||
|
hotelId={hotelId.toString()}
|
||||||
|
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.roomDetails}>
|
||||||
|
<Subtitle className={styles.name} type="two">
|
||||||
|
{name}
|
||||||
|
</Subtitle>
|
||||||
|
{/* Out of scope for now
|
||||||
|
<Body>{descriptions?.short}</Body>
|
||||||
|
*/}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.container}>
|
||||||
|
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
|
||||||
|
<div className={styles.noRoomsContainer}>
|
||||||
|
<div className={styles.noRooms}>
|
||||||
|
<ErrorCircleIcon color="red" width={16} />
|
||||||
|
<Caption color="uiTextHighContrast" type="bold">
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "This room is not available",
|
||||||
|
})}
|
||||||
|
</Caption>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Caption color="uiTextHighContrast" type="bold">
|
||||||
|
{breakfastMessage}
|
||||||
|
</Caption>
|
||||||
|
{roomConfiguration.products.map((product) => {
|
||||||
|
const rate = getRateInfo(product)
|
||||||
|
const isSelectedRateCode =
|
||||||
|
selectedRate?.product.productType.public.rateCode ===
|
||||||
|
product.productType.public.rateCode ||
|
||||||
|
selectedRate?.product.productType.member?.rateCode ===
|
||||||
|
product.productType.member?.rateCode
|
||||||
|
return (
|
||||||
|
<FlexibilityOption
|
||||||
|
key={product.productType.public.rateCode}
|
||||||
|
features={roomConfiguration.features}
|
||||||
|
isSelected={
|
||||||
|
isSelectedRateCode &&
|
||||||
|
selectedRate?.roomTypeCode ===
|
||||||
|
roomConfiguration.roomTypeCode
|
||||||
|
}
|
||||||
|
isUserLoggedIn={isUserLoggedIn}
|
||||||
|
paymentTerm={rate.isFlex ? payLater : payNow}
|
||||||
|
petRoomPackage={petRoomPackageSelected}
|
||||||
|
product={rate.notAvailable ? undefined : product}
|
||||||
|
roomType={roomConfiguration.roomType}
|
||||||
|
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||||
|
title={rate.title}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client"
|
||||||
|
import {
|
||||||
|
type Key,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
} from "react-aria-components"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
import { useRoomContext } from "@/contexts/Room"
|
||||||
|
|
||||||
|
import styles from "./roomFilter.module.css"
|
||||||
|
|
||||||
|
import type { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
|
|
||||||
|
export default function RoomTypeFilter() {
|
||||||
|
const filterOptions = useRatesStore((state) => state.filterOptions)
|
||||||
|
const {
|
||||||
|
actions: { selectFilter },
|
||||||
|
rooms,
|
||||||
|
selectedPackage,
|
||||||
|
} = useRoomContext()
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
// const tooltipText = intl.formatMessage({
|
||||||
|
// id: "Pet-friendly rooms have an additional fee of 20 EUR per stay",
|
||||||
|
// })
|
||||||
|
|
||||||
|
function handleChange(selectedFilter: Set<Key>) {
|
||||||
|
if (selectedFilter.size) {
|
||||||
|
const selected = selectedFilter.values().next()
|
||||||
|
selectFilter(selected.value as RoomPackageCodeEnum)
|
||||||
|
} else {
|
||||||
|
selectFilter(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Caption color="uiTextHighContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
||||||
|
},
|
||||||
|
{ numberOfRooms: rooms.length }
|
||||||
|
)}
|
||||||
|
</Caption>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
aria-label="Filter"
|
||||||
|
className={styles.roomsFilter}
|
||||||
|
defaultSelectedKeys={selectedPackage ? [selectedPackage] : undefined}
|
||||||
|
onSelectionChange={handleChange}
|
||||||
|
orientation="horizontal"
|
||||||
|
>
|
||||||
|
{filterOptions.map((option) => (
|
||||||
|
<ToggleButton
|
||||||
|
aria-label={option.description}
|
||||||
|
className={styles.radio}
|
||||||
|
id={option.code}
|
||||||
|
key={option.itemCode}
|
||||||
|
>
|
||||||
|
<div className={styles.circle} />
|
||||||
|
<Caption color="uiTextHighContrast">{option.description}</Caption>
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
.container {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Spacing-x3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomsFilter {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Spacing-x-one-and-half);
|
||||||
|
outline: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
border: 1px solid var(--UI-Input-Controls-Border-Normal);
|
||||||
|
border-radius: 50%;
|
||||||
|
grid-area: input;
|
||||||
|
height: 24px;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio:hover .circle {
|
||||||
|
background-color: var(--UI-Input-Controls-Fill-Selected-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio[data-selected="true"] .circle {
|
||||||
|
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio[data-selected="true"]:hover .circle {
|
||||||
|
background-color: var(--UI-Input-Controls-Fill-Selected-hover);
|
||||||
|
border-color: var(--UI-Input-Controls-Border-Hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio[data-selected="true"] .circle::after {
|
||||||
|
background-color: var(--Main-Grey-White);
|
||||||
|
border-radius: 50%;
|
||||||
|
content: "";
|
||||||
|
height: 8px;
|
||||||
|
left: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomsFilter {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { alternativeHotels } from "@/constants/routes/hotelReservation"
|
||||||
|
|
||||||
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
|
import { useRoomContext } from "@/contexts/Room"
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
import RoomCard from "./RoomCard"
|
||||||
|
import RoomTypeFilter from "./RoomTypeFilter"
|
||||||
|
|
||||||
|
import styles from "./roomSelectionPanel.module.css"
|
||||||
|
|
||||||
|
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||||
|
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||||
|
|
||||||
|
export default function RoomSelectionPanel() {
|
||||||
|
const { rooms } = useRoomContext()
|
||||||
|
const intl = useIntl()
|
||||||
|
const lang = useLang()
|
||||||
|
const noAvailableRooms = rooms.every(
|
||||||
|
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{noAvailableRooms ? (
|
||||||
|
<div className={styles.hotelAlert}>
|
||||||
|
<Alert
|
||||||
|
type={AlertTypeEnum.Info}
|
||||||
|
heading={intl.formatMessage({ id: "No availability" })}
|
||||||
|
text={intl.formatMessage({
|
||||||
|
id: "There are no rooms available that match your request.",
|
||||||
|
})}
|
||||||
|
link={{
|
||||||
|
title: intl.formatMessage({ id: "See alternative hotels" }),
|
||||||
|
url: `${alternativeHotels(lang)}`,
|
||||||
|
keepSearchParams: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<RoomTypeFilter />
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<ul className={styles.roomList}>
|
||||||
|
{rooms.map((roomConfiguration) => (
|
||||||
|
<RoomCard
|
||||||
|
key={roomConfiguration.roomTypeCode}
|
||||||
|
roomConfiguration={roomConfiguration}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,3 +14,9 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hotelAlert {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--Spacing-x-one-and-half);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { useRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
|
import RoomProvider from "@/providers/RoomProvider"
|
||||||
|
import { trackLowestRoomPrice } from "@/utils/tracking"
|
||||||
|
|
||||||
|
import MultiRoomWrapper from "./MultiRoomWrapper"
|
||||||
|
import RoomSelectionPanel from "./RoomSelectionPanel"
|
||||||
|
|
||||||
|
import styles from "./rooms.module.css"
|
||||||
|
|
||||||
|
export default function Rooms() {
|
||||||
|
const {
|
||||||
|
arrivalDate,
|
||||||
|
bookingRooms,
|
||||||
|
departureDate,
|
||||||
|
hotelId,
|
||||||
|
rooms,
|
||||||
|
visibleRooms,
|
||||||
|
} = useRatesStore((state) => ({
|
||||||
|
arrivalDate: state.booking.fromDate,
|
||||||
|
bookingRooms: state.booking.rooms,
|
||||||
|
departureDate: state.booking.toDate,
|
||||||
|
hotelId: state.booking.hotelId,
|
||||||
|
rooms: state.rooms,
|
||||||
|
visibleRooms: state.allRooms,
|
||||||
|
}))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pricesWithCurrencies = visibleRooms.flatMap((room) =>
|
||||||
|
room.products.map((product) => ({
|
||||||
|
price: product.productType.public.localPrice.pricePerNight,
|
||||||
|
currency: product.productType.public.localPrice.currency,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
const lowestPrice = pricesWithCurrencies.reduce(
|
||||||
|
(minPrice, { price }) => Math.min(minPrice, price),
|
||||||
|
Infinity
|
||||||
|
)
|
||||||
|
|
||||||
|
const currency = pricesWithCurrencies[0]?.currency
|
||||||
|
|
||||||
|
trackLowestRoomPrice({
|
||||||
|
hotelId,
|
||||||
|
arrivalDate,
|
||||||
|
departureDate,
|
||||||
|
lowestPrice: lowestPrice,
|
||||||
|
currency: currency,
|
||||||
|
})
|
||||||
|
}, [arrivalDate, departureDate, hotelId, visibleRooms])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.content}>
|
||||||
|
{bookingRooms.map((room, idx) => (
|
||||||
|
<RoomProvider
|
||||||
|
key={`${room.rateCode}-${room.roomTypeCode}-${idx}`}
|
||||||
|
idx={idx}
|
||||||
|
room={rooms[idx]}
|
||||||
|
>
|
||||||
|
<MultiRoomWrapper isMultiRoom={bookingRooms.length > 1}>
|
||||||
|
<RoomSelectionPanel />
|
||||||
|
</MultiRoomWrapper>
|
||||||
|
</RoomProvider>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.content {
|
||||||
|
max-width: var(--max-width-page);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
padding: var(--Spacing-x5) 0;
|
||||||
|
}
|
||||||
131
components/HotelReservation/SelectRate/RoomsContainer/index.tsx
Normal file
131
components/HotelReservation/SelectRate/RoomsContainer/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { dt } from "@/lib/dt"
|
||||||
|
import {
|
||||||
|
getHotel,
|
||||||
|
getPackages,
|
||||||
|
getRoomsAvailability,
|
||||||
|
} from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { generateChildrenString } from "@/components/HotelReservation/utils"
|
||||||
|
import RatesProvider from "@/providers/RatesProvider"
|
||||||
|
import { isValidSession } from "@/utils/session"
|
||||||
|
|
||||||
|
import { combineRoomAvailabilities } from "../utils"
|
||||||
|
import RateSummary from "./RateSummary"
|
||||||
|
import Rooms from "./Rooms"
|
||||||
|
|
||||||
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
|
import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer"
|
||||||
|
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
import type { Lang } from "@/constants/languages"
|
||||||
|
|
||||||
|
export function preload(
|
||||||
|
hotelId: string,
|
||||||
|
lang: Lang,
|
||||||
|
fromDate: string,
|
||||||
|
toDate: string,
|
||||||
|
adults: number[],
|
||||||
|
children?: Child[]
|
||||||
|
) {
|
||||||
|
void getHotel({ hotelId, isCardOnlyPayment: false, language: lang })
|
||||||
|
void getPackages({
|
||||||
|
adults: adults[0],
|
||||||
|
children: children ? children?.length : undefined,
|
||||||
|
endDate: toDate,
|
||||||
|
hotelId,
|
||||||
|
packageCodes: [
|
||||||
|
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||||
|
RoomPackageCodeEnum.PET_ROOM,
|
||||||
|
RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||||
|
],
|
||||||
|
startDate: fromDate,
|
||||||
|
})
|
||||||
|
|
||||||
|
const uniqueAdultsCount = Array.from(new Set(adults))
|
||||||
|
uniqueAdultsCount.forEach((adultsInRoom) => {
|
||||||
|
void getRoomsAvailability({
|
||||||
|
adults: adultsInRoom,
|
||||||
|
children: children ? generateChildrenString(children) : undefined,
|
||||||
|
hotelId: +hotelId,
|
||||||
|
roomStayEndDate: toDate,
|
||||||
|
roomStayStartDate: fromDate,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function RoomsContainer({
|
||||||
|
adultArray,
|
||||||
|
booking,
|
||||||
|
childArray,
|
||||||
|
fromDate,
|
||||||
|
hotelId,
|
||||||
|
lang,
|
||||||
|
toDate,
|
||||||
|
}: RoomsContainerProps) {
|
||||||
|
const session = await auth()
|
||||||
|
const isUserLoggedIn = isValidSession(session)
|
||||||
|
|
||||||
|
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
|
||||||
|
const toDateString = dt(toDate).format("YYYY-MM-DD")
|
||||||
|
|
||||||
|
const hotelData = await getHotel({
|
||||||
|
hotelId: hotelId.toString(),
|
||||||
|
isCardOnlyPayment: false,
|
||||||
|
language: lang,
|
||||||
|
})
|
||||||
|
|
||||||
|
const uniqueAdultsCount = Array.from(new Set(adultArray))
|
||||||
|
const roomsAvailabilityResults = await Promise.allSettled(
|
||||||
|
uniqueAdultsCount.map((adultCount) =>
|
||||||
|
getRoomsAvailability({
|
||||||
|
adults: adultCount,
|
||||||
|
hotelId: hotelId,
|
||||||
|
roomStayEndDate: toDateString,
|
||||||
|
roomStayStartDate: fromDateString,
|
||||||
|
children:
|
||||||
|
childArray && childArray.length > 0
|
||||||
|
? generateChildrenString(childArray)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const roomsAvailability = combineRoomAvailabilities(roomsAvailabilityResults)
|
||||||
|
|
||||||
|
const packages = await getPackages({
|
||||||
|
adults: adultArray[0],
|
||||||
|
children: childArray ? childArray.length : undefined,
|
||||||
|
endDate: toDateString,
|
||||||
|
hotelId: hotelId.toString(),
|
||||||
|
packageCodes: [
|
||||||
|
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||||
|
RoomPackageCodeEnum.PET_ROOM,
|
||||||
|
RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||||
|
],
|
||||||
|
startDate: fromDateString,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hotelData?.hotel) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packages === null) {
|
||||||
|
// TODO: Log packages error
|
||||||
|
console.error("[RoomsContainer] unable to fetch packages")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RatesProvider
|
||||||
|
booking={booking}
|
||||||
|
hotelType={hotelData.hotel.hotelType}
|
||||||
|
isUserLoggedIn={isUserLoggedIn}
|
||||||
|
packages={packages}
|
||||||
|
roomCategories={hotelData.roomCategories}
|
||||||
|
roomsAvailability={roomsAvailability}
|
||||||
|
vat={hotelData.hotel.vat}
|
||||||
|
>
|
||||||
|
<Rooms />
|
||||||
|
<RateSummary isUserLoggedIn={isUserLoggedIn} />
|
||||||
|
</RatesProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { useCallback } from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
|
||||||
|
|
||||||
import { EditIcon } from "@/components/Icons"
|
|
||||||
import Image from "@/components/Image"
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
|
||||||
|
|
||||||
import styles from "./selectedRoomPanel.module.css"
|
|
||||||
|
|
||||||
import type { Room as SelectedRateRoom } from "@/types/components/hotelReservation/selectRate/selectRate"
|
|
||||||
import type { Room } from "@/types/hotel"
|
|
||||||
|
|
||||||
interface SelectedRoomPanelProps {
|
|
||||||
roomIndex: number
|
|
||||||
room: SelectedRateRoom
|
|
||||||
roomCategories: Room[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SelectedRoomPanel({
|
|
||||||
roomIndex,
|
|
||||||
room,
|
|
||||||
roomCategories,
|
|
||||||
}: SelectedRoomPanelProps) {
|
|
||||||
const intl = useIntl()
|
|
||||||
const { rateSummary, modifyRate } = useRateSelectionStore()
|
|
||||||
|
|
||||||
const selectedRate = rateSummary[roomIndex]
|
|
||||||
const images = roomCategories.find((roomCategory) =>
|
|
||||||
roomCategory.roomTypes.some(
|
|
||||||
(roomType) => roomType.code === selectedRate?.roomTypeCode
|
|
||||||
)
|
|
||||||
)?.images
|
|
||||||
|
|
||||||
const handleModify = useCallback(() => {
|
|
||||||
modifyRate(roomIndex)
|
|
||||||
}, [modifyRate, roomIndex])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.selectedRoomPanel}>
|
|
||||||
<div className={styles.titleContainer}>
|
|
||||||
<div>
|
|
||||||
<Caption type="regular">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "Room {roomIndex}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
roomIndex: roomIndex + 1,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Caption>
|
|
||||||
<Subtitle color="uiTextHighContrast">
|
|
||||||
{selectedRate?.roomType},{" "}
|
|
||||||
{intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: room.childrenInRoom?.length
|
|
||||||
? "{adults} adults, {children} children"
|
|
||||||
: "{adults} adults",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
adults: room.adults,
|
|
||||||
children: room.childrenInRoom?.length,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Subtitle>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Body>
|
|
||||||
{selectedRate?.priceName}, {selectedRate?.priceTerm}
|
|
||||||
</Body>
|
|
||||||
<Body>
|
|
||||||
{selectedRate?.public.localPrice.pricePerNight}{" "}
|
|
||||||
{selectedRate?.public.localPrice.currency}/
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "night",
|
|
||||||
})}
|
|
||||||
</Body>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.imageAndModifyButtonContainer}>
|
|
||||||
{images?.[0]?.imageSizes?.tiny && (
|
|
||||||
<div className={styles.imageContainer}>
|
|
||||||
<Image
|
|
||||||
src={images[0].imageSizes.tiny}
|
|
||||||
alt={selectedRate?.roomType ?? images[0].metaData?.altText ?? ""}
|
|
||||||
fill
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles.modifyButtonContainer}>
|
|
||||||
<Button variant="icon" size="small" onClick={handleModify}>
|
|
||||||
<EditIcon />
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "Modify",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
|
||||||
import { formatPrice } from "@/utils/numberFormatting"
|
|
||||||
|
|
||||||
import styles from "./selectionCard.module.css"
|
|
||||||
|
|
||||||
import type { SelectionCardProps } from "@/types/components/hotelReservation/selectRate/selectionCard"
|
|
||||||
|
|
||||||
export default function SelectionCard({
|
|
||||||
price,
|
|
||||||
membersPrice,
|
|
||||||
currency,
|
|
||||||
title,
|
|
||||||
subtext,
|
|
||||||
}: SelectionCardProps) {
|
|
||||||
const intl = useIntl()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.card}>
|
|
||||||
<div>
|
|
||||||
<Title className={styles.name} as="h4" level="h3">
|
|
||||||
{title}
|
|
||||||
</Title>
|
|
||||||
<div className={styles.nameInfo}>i</div>
|
|
||||||
</div>
|
|
||||||
<Caption color="burgundy">{subtext}</Caption>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Caption color="burgundy" className={styles.price}>
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "{price}/night" },
|
|
||||||
{
|
|
||||||
price: formatPrice(intl, price, currency),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Caption>
|
|
||||||
|
|
||||||
{membersPrice && (
|
|
||||||
<Caption color="burgundy" className={styles.membersPrice}>
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "Members {price}/night" },
|
|
||||||
{ price: formatPrice(intl, membersPrice, currency) }
|
|
||||||
)}
|
|
||||||
</Caption>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
.card {
|
|
||||||
padding: var(--Spacing-x2);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--Spacing-x2);
|
|
||||||
background-color: #fff;
|
|
||||||
border-radius: var(--Corner-radius-Small);
|
|
||||||
border: 1px solid rgba(77, 0, 27, 0.1);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="radio"]:checked + .card {
|
|
||||||
border: 3px solid var(--Scandic-Brand-Scandic-Red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.nameInfo {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price {
|
|
||||||
font-size: var(--typography-Body-Bold-fontSize);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membersPrice {
|
|
||||||
font-size: var(--typography-Footnote-Regular-fontSize);
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,39 @@
|
|||||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
|
|
||||||
export function combineRoomAvailabilities({
|
export function combineRoomAvailabilities(
|
||||||
availabilityResults,
|
availabilityResults: PromiseSettledResult<RoomsAvailability | null>[]
|
||||||
}: {
|
) {
|
||||||
availabilityResults: Array<[RoomsAvailability | undefined | null, unknown]>
|
return availabilityResults.reduce<RoomsAvailability | null>((acc, result) => {
|
||||||
}): RoomsAvailability | null {
|
if (result.status === "fulfilled" && result.value) {
|
||||||
return availabilityResults.reduce<RoomsAvailability | null>(
|
if (acc) {
|
||||||
(combinedResult, [currentResult, error]) => {
|
acc.roomConfigurations.push(...result.value.roomConfigurations)
|
||||||
if (error || !currentResult) return combinedResult
|
} else {
|
||||||
if (!combinedResult) return currentResult
|
acc = result.value
|
||||||
|
|
||||||
return {
|
|
||||||
...currentResult,
|
|
||||||
roomConfigurations: [
|
|
||||||
...combinedResult.roomConfigurations,
|
|
||||||
...currentResult.roomConfigurations,
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
null
|
|
||||||
)
|
// Ping monitoring about fail?
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
console.info(`RoomAvailability fetch failed`)
|
||||||
|
console.error(result.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRates(
|
||||||
|
rateDefinitions: RoomsAvailability["rateDefinitions"]
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
change: rateDefinitions.filter(
|
||||||
|
(rate) => rate.cancellationRule === "Changeable"
|
||||||
|
),
|
||||||
|
flex: rateDefinitions.filter(
|
||||||
|
(rate) => rate.cancellationRule === "CancellableBefore6PM"
|
||||||
|
),
|
||||||
|
save: rateDefinitions.filter(
|
||||||
|
(rate) => rate.cancellationRule === "NotCancellable"
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export function getIconForFeatureCode(featureCode: RoomPackageCodes) {
|
|||||||
case RoomPackageCodeEnum.ALLERGY_ROOM:
|
case RoomPackageCodeEnum.ALLERGY_ROOM:
|
||||||
return AllergyIcon
|
return AllergyIcon
|
||||||
case RoomPackageCodeEnum.PET_ROOM:
|
case RoomPackageCodeEnum.PET_ROOM:
|
||||||
return PetsIcon
|
|
||||||
default:
|
default:
|
||||||
return PetsIcon
|
return PetsIcon
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function CountrySelect({
|
|||||||
rules: registerOptions,
|
rules: registerOptions,
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleChange(country: Key) {
|
function handleChange(country: Key | null) {
|
||||||
setValue(name, country ?? "")
|
setValue(name, country ?? "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
contexts/Rates.ts
Normal file
5
contexts/Rates.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createContext } from "react"
|
||||||
|
|
||||||
|
import type { RatesStore } from "@/types/contexts/rates"
|
||||||
|
|
||||||
|
export const RatesContext = createContext<RatesStore | null>(null)
|
||||||
13
contexts/Room.ts
Normal file
13
contexts/Room.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createContext, useContext } from "react"
|
||||||
|
|
||||||
|
import type { RoomContextValue } from "@/types/contexts/room"
|
||||||
|
|
||||||
|
export const RoomContext = createContext<RoomContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useRoomContext() {
|
||||||
|
const ctx = useContext(RoomContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("Missing context value [RoomContext]")
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -717,6 +717,7 @@
|
|||||||
"{price}/night": "{price}/nat",
|
"{price}/night": "{price}/nat",
|
||||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||||
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} anmeldelser på Tripadvisor)",
|
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} anmeldelser på Tripadvisor)",
|
||||||
|
"{roomSizeMin} - {roomSizeMax} m²": "{roomSizeMin} - {roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Plads til {max, plural, one {{range} person} other {op til {range} personer}}",
|
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Plads til {max, plural, one {{range} person} other {op til {range} personer}}",
|
||||||
"{roomSize} m²": "{roomSize} m²",
|
"{roomSize} m²": "{roomSize} m²",
|
||||||
|
|||||||
@@ -717,6 +717,7 @@
|
|||||||
"{price}/night": "{price}/nacht",
|
"{price}/night": "{price}/nacht",
|
||||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||||
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} Bewertungen auf Tripadvisor)",
|
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} Bewertungen auf Tripadvisor)",
|
||||||
|
"{roomSizeMin} - {roomSizeMax} m²": "{roomSizeMin} - {roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Bietet Platz für {max, plural, one {{range} Person } other {bis zu {range} Personen}}",
|
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Bietet Platz für {max, plural, one {{range} Person } other {bis zu {range} Personen}}",
|
||||||
"{roomSize} m²": "{roomSize} m²",
|
"{roomSize} m²": "{roomSize} m²",
|
||||||
|
|||||||
@@ -720,6 +720,7 @@
|
|||||||
"{price}/night": "{price}/night",
|
"{price}/night": "{price}/night",
|
||||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||||
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} reviews on Tripadvisor)",
|
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} reviews on Tripadvisor)",
|
||||||
|
"{roomSizeMin} - {roomSizeMax} m²": "{roomSizeMin} - {roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}",
|
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}",
|
||||||
"{roomSize} m²": "{roomSize} m²",
|
"{roomSize} m²": "{roomSize} m²",
|
||||||
|
|||||||
@@ -717,6 +717,7 @@
|
|||||||
"{price}/night": "{price}/yö",
|
"{price}/night": "{price}/yö",
|
||||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||||
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} arvostelua TripAdvisorissa)",
|
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} arvostelua TripAdvisorissa)",
|
||||||
|
"{roomSizeMin} - {roomSizeMax} m²": "{roomSizeMin} - {roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Huoneeseen {max, plural, one {{range} henkilö} other {mahtuu enintään {range} henkilöä}",
|
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Huoneeseen {max, plural, one {{range} henkilö} other {mahtuu enintään {range} henkilöä}",
|
||||||
"{roomSize} m²": "{roomSize} m²",
|
"{roomSize} m²": "{roomSize} m²",
|
||||||
|
|||||||
@@ -715,6 +715,7 @@
|
|||||||
"{price}/night": "{price}/natt",
|
"{price}/night": "{price}/natt",
|
||||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||||
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} anmeldelser på Tripadvisor)",
|
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} anmeldelser på Tripadvisor)",
|
||||||
|
"{roomSizeMin} - {roomSizeMax} m²": "{roomSizeMin} - {roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Plass til {max, plural, one {{range} person} other {opptil {range} personer}}",
|
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Plass til {max, plural, one {{range} person} other {opptil {range} personer}}",
|
||||||
"{roomSize} m²": "{roomSize} m²",
|
"{roomSize} m²": "{roomSize} m²",
|
||||||
|
|||||||
@@ -717,6 +717,7 @@
|
|||||||
"{price}/night": "{price}/natt",
|
"{price}/night": "{price}/natt",
|
||||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||||
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} recensioner på Tripadvisor)",
|
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} recensioner på Tripadvisor)",
|
||||||
|
"{roomSizeMin} - {roomSizeMax} m²": "{roomSizeMin} - {roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
"{roomSizeMin}–{roomSizeMax} m²": "{roomSizeMin}–{roomSizeMax} m²",
|
||||||
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Rymmer {max, plural, one {{range} person} other {upp till {range} personer}}",
|
"{roomSizeMin}–{roomSizeMax} m². Accommodates up to {max, plural, one {{range} person} other {{range} people}}": "{roomSizeMin}–{roomSizeMax} m². Rymmer {max, plural, one {{range} person} other {upp till {range} personer}}",
|
||||||
"{roomSize} m²": "{roomSize} m²",
|
"{roomSize} m²": "{roomSize} m²",
|
||||||
|
|||||||
2147
package-lock.json
generated
2147
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,7 @@
|
|||||||
"next": "^14.2.18",
|
"next": "^14.2.18",
|
||||||
"next-auth": "5.0.0-beta.19",
|
"next-auth": "5.0.0-beta.19",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
|
"react-aria-components": "^1.6.0",
|
||||||
"react-day-picker": "^9.0.8",
|
"react-day-picker": "^9.0.8",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-feather": "^2.0.10",
|
"react-feather": "^2.0.10",
|
||||||
|
|||||||
52
providers/RatesProvider.tsx
Normal file
52
providers/RatesProvider.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client"
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation"
|
||||||
|
import { useRef } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { createRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
|
import { RatesContext } from "@/contexts/Rates"
|
||||||
|
|
||||||
|
import type { RatesStore } from "@/types/contexts/rates"
|
||||||
|
import type { RatesProviderProps } from "@/types/providers/rates"
|
||||||
|
|
||||||
|
export default function RatesProvider({
|
||||||
|
booking,
|
||||||
|
children,
|
||||||
|
hotelType,
|
||||||
|
isUserLoggedIn,
|
||||||
|
packages,
|
||||||
|
roomCategories,
|
||||||
|
roomsAvailability,
|
||||||
|
vat,
|
||||||
|
}: RatesProviderProps) {
|
||||||
|
const storeRef = useRef<RatesStore>()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
if (!storeRef.current) {
|
||||||
|
storeRef.current = createRatesStore({
|
||||||
|
booking,
|
||||||
|
hotelType,
|
||||||
|
isUserLoggedIn,
|
||||||
|
labels: {
|
||||||
|
accessibilityRoom: intl.formatMessage({ id: "Accessible room" }),
|
||||||
|
allergyRoom: intl.formatMessage({ id: "Allergy-friendly room" }),
|
||||||
|
petRoom: intl.formatMessage({ id: "Pet room" }),
|
||||||
|
},
|
||||||
|
packages: packages ?? [],
|
||||||
|
pathname,
|
||||||
|
roomCategories,
|
||||||
|
roomsAvailability,
|
||||||
|
searchParams,
|
||||||
|
vat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RatesContext.Provider value={storeRef.current}>
|
||||||
|
{children}
|
||||||
|
</RatesContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
providers/RoomProvider.tsx
Normal file
34
providers/RoomProvider.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
|
import { RoomContext } from "@/contexts/Room"
|
||||||
|
|
||||||
|
import type { RoomProviderProps } from "@/types/providers/room"
|
||||||
|
|
||||||
|
export default function RoomProvider({
|
||||||
|
children,
|
||||||
|
idx,
|
||||||
|
room,
|
||||||
|
}: RoomProviderProps) {
|
||||||
|
const activeRoom = useRatesStore((state) => state.activeRoom)
|
||||||
|
const modifyRate = useRatesStore((state) => state.actions.modifyRate(idx))
|
||||||
|
const selectFilter = useRatesStore((state) => state.actions.selectFilter(idx))
|
||||||
|
const selectRate = useRatesStore((state) => state.actions.selectRate(idx))
|
||||||
|
return (
|
||||||
|
<RoomContext.Provider
|
||||||
|
value={{
|
||||||
|
...room,
|
||||||
|
actions: {
|
||||||
|
modifyRate,
|
||||||
|
selectFilter,
|
||||||
|
selectRate,
|
||||||
|
},
|
||||||
|
isActiveRoom: activeRoom === idx,
|
||||||
|
roomNr: idx + 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RoomContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -567,7 +567,7 @@ export const hotelQueryRouter = router({
|
|||||||
error: validateAvailabilityData.error,
|
error: validateAvailabilityData.error,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
throw badRequestError()
|
return null
|
||||||
}
|
}
|
||||||
metrics.roomAvailability.success.add(1, {
|
metrics.roomAvailability.success.add(1, {
|
||||||
hotelId,
|
hotelId,
|
||||||
@@ -1547,6 +1547,7 @@ export const hotelQueryRouter = router({
|
|||||||
"api.hotels.packages error",
|
"api.hotels.packages error",
|
||||||
JSON.stringify({ query: { hotelId, params } })
|
JSON.stringify({ query: { hotelId, params } })
|
||||||
)
|
)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiJson = await apiResponse.json()
|
const apiJson = await apiResponse.json()
|
||||||
@@ -1565,7 +1566,7 @@ export const hotelQueryRouter = router({
|
|||||||
error: validatedPackagesData.error,
|
error: validatedPackagesData.error,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
throw badRequestError()
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics.packages.success.add(1, {
|
metrics.packages.success.add(1, {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||||
|
import {
|
||||||
|
RoomPackageCodeEnum,
|
||||||
|
type RoomPackages,
|
||||||
|
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import type {
|
import type {
|
||||||
Rate,
|
Rate,
|
||||||
RateCode,
|
RateCode,
|
||||||
} from "@/types/components/hotelReservation/selectRate/selectRate"
|
} from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import type { RateSummaryParams } from "./rate-selection"
|
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
|
|
||||||
interface CalculateRoomSummaryParams extends RateSummaryParams {
|
interface CalculateRoomSummaryParams {
|
||||||
|
availablePackages: RoomPackages
|
||||||
|
getFilteredRooms: (roomIndex: number) => RoomConfiguration[]
|
||||||
|
roomCategories: Array<{ name: string; roomTypes: Array<{ code: string }> }>
|
||||||
|
selectedPackagesByRoom: Record<number, RoomPackageCodeEnum[]>
|
||||||
selectedRate: RateCode
|
selectedRate: RateCode
|
||||||
roomIndex: number
|
roomIndex: number
|
||||||
}
|
}
|
||||||
@@ -56,3 +64,134 @@ export function calculateRoomSummary({
|
|||||||
roomTypeCode: room.roomTypeCode,
|
roomTypeCode: room.roomTypeCode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the lowest priced room for each room type that appears more than once.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function filterDuplicateRoomTypesByLowestPrice(
|
||||||
|
roomConfigurations: RoomConfiguration[]
|
||||||
|
): RoomConfiguration[] {
|
||||||
|
const roomTypeCount = roomConfigurations.reduce<Record<string, number>>(
|
||||||
|
(roomTypeTally, currentRoom) => {
|
||||||
|
const currentRoomType = currentRoom.roomType
|
||||||
|
const currentCount = roomTypeTally[currentRoomType] || 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
...roomTypeTally,
|
||||||
|
[currentRoomType]: currentCount + 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
const duplicateRoomTypes = new Set(
|
||||||
|
Object.keys(roomTypeCount).filter((roomType) => roomTypeCount[roomType] > 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
const roomMap = new Map()
|
||||||
|
|
||||||
|
roomConfigurations.forEach((room) => {
|
||||||
|
const { roomType, products, status } = room
|
||||||
|
|
||||||
|
if (!duplicateRoomTypes.has(roomType)) {
|
||||||
|
roomMap.set(roomType, room)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousRoom = roomMap.get(roomType)
|
||||||
|
|
||||||
|
// Prioritize 'Available' status
|
||||||
|
if (
|
||||||
|
status === AvailabilityEnum.Available &&
|
||||||
|
previousRoom?.status === AvailabilityEnum.NotAvailable
|
||||||
|
) {
|
||||||
|
roomMap.set(roomType, room)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
status === AvailabilityEnum.NotAvailable &&
|
||||||
|
previousRoom?.status === AvailabilityEnum.Available
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousRoom) {
|
||||||
|
products.forEach((product) => {
|
||||||
|
const { productType } = product
|
||||||
|
const publicProduct = productType.public || {
|
||||||
|
requestedPrice: null,
|
||||||
|
localPrice: null,
|
||||||
|
}
|
||||||
|
const memberProduct = productType.member || {
|
||||||
|
requestedPrice: null,
|
||||||
|
localPrice: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
requestedPrice: publicRequestedPrice,
|
||||||
|
localPrice: publicLocalPrice,
|
||||||
|
} = publicProduct
|
||||||
|
const {
|
||||||
|
requestedPrice: memberRequestedPrice,
|
||||||
|
localPrice: memberLocalPrice,
|
||||||
|
} = memberProduct
|
||||||
|
|
||||||
|
const previousLowest = roomMap.get(roomType)
|
||||||
|
|
||||||
|
const currentRequestedPrice = Math.min(
|
||||||
|
Number(publicRequestedPrice?.pricePerNight) ?? Infinity,
|
||||||
|
Number(memberRequestedPrice?.pricePerNight) ?? Infinity
|
||||||
|
)
|
||||||
|
const currentLocalPrice = Math.min(
|
||||||
|
Number(publicLocalPrice?.pricePerNight) ?? Infinity,
|
||||||
|
Number(memberLocalPrice?.pricePerNight) ?? Infinity
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!previousLowest ||
|
||||||
|
currentRequestedPrice <
|
||||||
|
Math.min(
|
||||||
|
Number(
|
||||||
|
previousLowest.products[0].productType.public.requestedPrice
|
||||||
|
?.pricePerNight
|
||||||
|
) ?? Infinity,
|
||||||
|
Number(
|
||||||
|
previousLowest.products[0].productType.member?.requestedPrice
|
||||||
|
?.pricePerNight
|
||||||
|
) ?? Infinity
|
||||||
|
) ||
|
||||||
|
(currentRequestedPrice ===
|
||||||
|
Math.min(
|
||||||
|
Number(
|
||||||
|
previousLowest.products[0].productType.public.requestedPrice
|
||||||
|
?.pricePerNight
|
||||||
|
) ?? Infinity,
|
||||||
|
Number(
|
||||||
|
previousLowest.products[0].productType.member?.requestedPrice
|
||||||
|
?.pricePerNight
|
||||||
|
) ?? Infinity
|
||||||
|
) &&
|
||||||
|
currentLocalPrice <
|
||||||
|
Math.min(
|
||||||
|
Number(
|
||||||
|
previousLowest.products[0].productType.public.localPrice
|
||||||
|
?.pricePerNight
|
||||||
|
) ?? Infinity,
|
||||||
|
Number(
|
||||||
|
previousLowest.products[0].productType.member?.localPrice
|
||||||
|
?.pricePerNight
|
||||||
|
) ?? Infinity
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
roomMap.set(roomType, room)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
roomMap.set(roomType, room)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(roomMap.values())
|
||||||
|
}
|
||||||
|
|||||||
262
stores/select-rate/index.ts
Normal file
262
stores/select-rate/index.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { produce } from "immer"
|
||||||
|
import { ReadonlyURLSearchParams } from "next/navigation"
|
||||||
|
import { useContext } from "react"
|
||||||
|
import { create, useStore } from "zustand"
|
||||||
|
|
||||||
|
import { filterDuplicateRoomTypesByLowestPrice } from "@/stores/select-rate/helper"
|
||||||
|
|
||||||
|
import { RatesContext } from "@/contexts/Rates"
|
||||||
|
|
||||||
|
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||||
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
|
import type { InitialState, RatesState } from "@/types/stores/rates"
|
||||||
|
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
|
|
||||||
|
const statusLookup = {
|
||||||
|
[AvailabilityEnum.Available]: 1,
|
||||||
|
[AvailabilityEnum.NotAvailable]: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSelectedRate(
|
||||||
|
rateCode: string,
|
||||||
|
roomTypeCode: string,
|
||||||
|
rooms: RoomConfiguration[]
|
||||||
|
) {
|
||||||
|
return rooms.find(
|
||||||
|
(room) =>
|
||||||
|
room.roomTypeCode === roomTypeCode &&
|
||||||
|
room.products.find(
|
||||||
|
(product) =>
|
||||||
|
product.productType.public.rateCode === rateCode ||
|
||||||
|
product.productType.member?.rateCode === rateCode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRatesStore({
|
||||||
|
booking,
|
||||||
|
hotelType,
|
||||||
|
isUserLoggedIn,
|
||||||
|
labels,
|
||||||
|
packages,
|
||||||
|
pathname,
|
||||||
|
roomCategories,
|
||||||
|
roomsAvailability,
|
||||||
|
searchParams,
|
||||||
|
vat,
|
||||||
|
}: InitialState) {
|
||||||
|
const filterOptions = [
|
||||||
|
{
|
||||||
|
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||||
|
description: labels.accessibilityRoom,
|
||||||
|
itemCode: packages.find(
|
||||||
|
(pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||||
|
)?.itemCode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||||
|
description: labels.allergyRoom,
|
||||||
|
itemCode: packages.find(
|
||||||
|
(pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||||
|
)?.itemCode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: RoomPackageCodeEnum.PET_ROOM,
|
||||||
|
description: labels.petRoom,
|
||||||
|
itemCode: packages.find(
|
||||||
|
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||||
|
)?.itemCode,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
let allRooms: RoomConfiguration[] = []
|
||||||
|
if (roomsAvailability?.roomConfigurations) {
|
||||||
|
allRooms = filterDuplicateRoomTypesByLowestPrice(
|
||||||
|
roomsAvailability.roomConfigurations
|
||||||
|
).sort(
|
||||||
|
// @ts-expect-error - array indexing
|
||||||
|
(a, b) => statusLookup[a.status] - statusLookup[b.status]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateSummary: RatesState["rateSummary"] = []
|
||||||
|
booking.rooms.forEach((room, idx) => {
|
||||||
|
if (room.rateCode && room.roomTypeCode) {
|
||||||
|
const selectedRoom = roomsAvailability?.roomConfigurations.find(
|
||||||
|
(roomConf) =>
|
||||||
|
roomConf.roomTypeCode === room.roomTypeCode &&
|
||||||
|
roomConf.products.find(
|
||||||
|
(product) =>
|
||||||
|
product.productType.public.rateCode === room.rateCode ||
|
||||||
|
product.productType.member?.rateCode === room.rateCode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const product = selectedRoom?.products.find(
|
||||||
|
(p) =>
|
||||||
|
p.productType.public.rateCode === room.rateCode ||
|
||||||
|
p.productType.member?.rateCode === room.rateCode
|
||||||
|
)
|
||||||
|
if (selectedRoom && product) {
|
||||||
|
rateSummary[idx] = {
|
||||||
|
features: selectedRoom.features,
|
||||||
|
member: product.productType.member,
|
||||||
|
public: product.productType.public,
|
||||||
|
roomType: selectedRoom.roomType,
|
||||||
|
roomTypeCode: selectedRoom.roomTypeCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return create<RatesState>()((set) => ({
|
||||||
|
actions: {
|
||||||
|
modifyRate(idx) {
|
||||||
|
return function () {
|
||||||
|
return set(
|
||||||
|
produce((state: RatesState) => {
|
||||||
|
state.activeRoom = idx
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectFilter(idx) {
|
||||||
|
return function (code) {
|
||||||
|
return set(
|
||||||
|
produce((state: RatesState) => {
|
||||||
|
state.rooms[idx].selectedPackage = code
|
||||||
|
const searchParams = new URLSearchParams(state.searchParams)
|
||||||
|
if (code) {
|
||||||
|
state.rooms[idx].rooms = state.allRooms.filter((room) =>
|
||||||
|
room.features.find((feat) => feat.code === code)
|
||||||
|
)
|
||||||
|
searchParams.set(`room[${idx}].packages`, code)
|
||||||
|
|
||||||
|
if (state.rateSummary[idx]) {
|
||||||
|
state.rateSummary[idx].package = code
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.rooms[idx].rooms = state.allRooms
|
||||||
|
searchParams.delete(`room[${idx}].packages`)
|
||||||
|
|
||||||
|
if (state.rateSummary[idx]) {
|
||||||
|
state.rateSummary[idx].package = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.searchParams = new ReadonlyURLSearchParams(searchParams)
|
||||||
|
window.history.pushState(
|
||||||
|
{},
|
||||||
|
"",
|
||||||
|
`${state.pathname}?${searchParams}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectRate(idx) {
|
||||||
|
return function (selectedRate) {
|
||||||
|
return set(
|
||||||
|
produce((state: RatesState) => {
|
||||||
|
state.rooms[idx].selectedRate = selectedRate
|
||||||
|
state.rateSummary[idx] = {
|
||||||
|
features: selectedRate.features,
|
||||||
|
member: selectedRate.product.productType.member,
|
||||||
|
package: state.rooms[idx].selectedPackage,
|
||||||
|
public: selectedRate.product.productType.public,
|
||||||
|
roomType: selectedRate.roomType,
|
||||||
|
roomTypeCode: selectedRate.roomTypeCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(state.searchParams)
|
||||||
|
searchParams.set(
|
||||||
|
`room[${idx}].counterratecode`,
|
||||||
|
isUserLoggedIn && selectedRate.product.productType.member
|
||||||
|
? selectedRate.product.productType.public.rateCode
|
||||||
|
: selectedRate.product.productType.member?.rateCode ?? ""
|
||||||
|
)
|
||||||
|
searchParams.set(
|
||||||
|
`room[${idx}].ratecode`,
|
||||||
|
isUserLoggedIn && selectedRate.product.productType.member
|
||||||
|
? selectedRate.product.productType.member.rateCode
|
||||||
|
: selectedRate.product.productType.public.rateCode
|
||||||
|
)
|
||||||
|
searchParams.set(
|
||||||
|
`room[${idx}].roomtype`,
|
||||||
|
selectedRate.roomTypeCode
|
||||||
|
)
|
||||||
|
|
||||||
|
state.activeRoom =
|
||||||
|
idx + 1 < state.booking.rooms.length
|
||||||
|
? idx + 1
|
||||||
|
: state.booking.rooms.length
|
||||||
|
|
||||||
|
state.searchParams = new ReadonlyURLSearchParams(searchParams)
|
||||||
|
window.history.pushState(
|
||||||
|
{},
|
||||||
|
"",
|
||||||
|
`${state.pathname}?${searchParams}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
activeRoom: rateSummary.length,
|
||||||
|
allRooms,
|
||||||
|
booking,
|
||||||
|
filterOptions,
|
||||||
|
hotelType,
|
||||||
|
packages,
|
||||||
|
pathname,
|
||||||
|
petRoomPackage: packages.find(
|
||||||
|
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||||
|
),
|
||||||
|
rateSummary,
|
||||||
|
rooms: booking.rooms.map((room) => {
|
||||||
|
const selectedRate =
|
||||||
|
findSelectedRate(room.rateCode, room.roomTypeCode, allRooms) ?? null
|
||||||
|
|
||||||
|
const product = selectedRate?.products.find(
|
||||||
|
(prd) =>
|
||||||
|
prd.productType.public.rateCode === room.rateCode ||
|
||||||
|
prd.productType.member?.rateCode === room.rateCode
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedPackage = room.packages?.[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookingRoom: room,
|
||||||
|
rooms: selectedPackage
|
||||||
|
? allRooms.filter((r) =>
|
||||||
|
r.features.find((f) => f.code === selectedPackage)
|
||||||
|
)
|
||||||
|
: allRooms,
|
||||||
|
selectedPackage,
|
||||||
|
selectedRate:
|
||||||
|
selectedRate && product
|
||||||
|
? {
|
||||||
|
features: selectedRate.features,
|
||||||
|
product,
|
||||||
|
roomType: selectedRate.roomType,
|
||||||
|
roomTypeCode: selectedRate.roomTypeCode,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
roomCategories,
|
||||||
|
roomsAvailability,
|
||||||
|
searchParams,
|
||||||
|
vat,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRatesStore<T>(selector: (store: RatesState) => T) {
|
||||||
|
const store = useContext(RatesContext)
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
throw new Error("useRatesStore must be used within RatesProvider")
|
||||||
|
}
|
||||||
|
|
||||||
|
return useStore(store, selector)
|
||||||
|
}
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { create } from "zustand"
|
|
||||||
|
|
||||||
import { filterDuplicateRoomTypesByLowestPrice } from "@/components/HotelReservation/SelectRate/Rooms/utils"
|
|
||||||
|
|
||||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
|
||||||
import type {
|
|
||||||
FilterValues,
|
|
||||||
RoomPackageCodeEnum,
|
|
||||||
RoomPackageCodes,
|
|
||||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
|
||||||
import type {
|
|
||||||
RoomConfiguration,
|
|
||||||
RoomsAvailability,
|
|
||||||
} from "@/types/trpc/routers/hotel/roomAvailability"
|
|
||||||
|
|
||||||
interface RoomFilteringState {
|
|
||||||
selectedPackagesByRoom: Record<number, RoomPackageCodes[]>
|
|
||||||
roomsAvailability: RoomsAvailability | null
|
|
||||||
visibleRooms: RoomConfiguration[]
|
|
||||||
setVisibleRooms: () => void
|
|
||||||
setRoomsAvailability: (rooms: RoomsAvailability) => void
|
|
||||||
handleFilter: (filter: FilterValues, roomIndex: number) => void
|
|
||||||
getFilteredRooms: (roomIndex: number) => RoomConfiguration[]
|
|
||||||
getRooms: (roomIndex: number) => RoomsAvailability | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useRoomFilteringStore = create<RoomFilteringState>((set, get) => ({
|
|
||||||
selectedPackagesByRoom: {},
|
|
||||||
roomsAvailability: null,
|
|
||||||
visibleRooms: [],
|
|
||||||
setRoomsAvailability: (rooms) => {
|
|
||||||
set({ roomsAvailability: rooms })
|
|
||||||
},
|
|
||||||
setVisibleRooms: () => {
|
|
||||||
const { roomsAvailability } = get()
|
|
||||||
if (!roomsAvailability) return null
|
|
||||||
|
|
||||||
const deduped = filterDuplicateRoomTypesByLowestPrice(
|
|
||||||
roomsAvailability.roomConfigurations
|
|
||||||
)
|
|
||||||
|
|
||||||
const separated = deduped.reduce<{
|
|
||||||
available: RoomConfiguration[]
|
|
||||||
notAvailable: RoomConfiguration[]
|
|
||||||
}>(
|
|
||||||
(acc, curr) => {
|
|
||||||
if (curr?.status === AvailabilityEnum.NotAvailable)
|
|
||||||
return { ...acc, notAvailable: [...acc.notAvailable, curr] }
|
|
||||||
return { ...acc, available: [...acc.available, curr] }
|
|
||||||
},
|
|
||||||
{ available: [], notAvailable: [] }
|
|
||||||
)
|
|
||||||
|
|
||||||
set({ visibleRooms: [...separated.available, ...separated.notAvailable] })
|
|
||||||
},
|
|
||||||
|
|
||||||
handleFilter: (filter, roomIndex) => {
|
|
||||||
const filteredPackages = Object.entries(filter)
|
|
||||||
.filter(([_, isSelected]) => isSelected)
|
|
||||||
.map(([code]) => code) as RoomPackageCodeEnum[]
|
|
||||||
|
|
||||||
set((state) => {
|
|
||||||
const currentPackages = state.selectedPackagesByRoom[roomIndex] || []
|
|
||||||
const sortedCurrent = [...currentPackages].sort()
|
|
||||||
const sortedNew = [...filteredPackages].sort()
|
|
||||||
|
|
||||||
if (JSON.stringify(sortedCurrent) === JSON.stringify(sortedNew)) {
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
selectedPackagesByRoom: {
|
|
||||||
...state.selectedPackagesByRoom,
|
|
||||||
[roomIndex]: filteredPackages,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
getFilteredRooms: (roomIndex) => {
|
|
||||||
const state = get()
|
|
||||||
const selectedPackages = state.selectedPackagesByRoom[roomIndex] || []
|
|
||||||
|
|
||||||
return state.visibleRooms.filter((room) =>
|
|
||||||
selectedPackages.every((filteredPackage) =>
|
|
||||||
room?.features.some((feature) => feature.code === filteredPackage)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
getRooms: (roomIndex) => {
|
|
||||||
const state = get()
|
|
||||||
if (!state.roomsAvailability) return null
|
|
||||||
|
|
||||||
const selectedPackages = state.selectedPackagesByRoom[roomIndex] || []
|
|
||||||
const filteredRooms = state.getFilteredRooms(roomIndex)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state.roomsAvailability,
|
|
||||||
roomConfigurations:
|
|
||||||
selectedPackages.length === 0 ? state.visibleRooms : filteredRooms,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
15
types/components/hotelReservation/enterDetails/payment.ts
Normal file
15
types/components/hotelReservation/enterDetails/payment.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { CreditCard, SafeUser } from "@/types/user"
|
||||||
|
import type { PaymentMethodEnum } from "@/constants/booking"
|
||||||
|
import type { Child } from "../selectRate/selectRate"
|
||||||
|
|
||||||
|
export interface PaymentProps {
|
||||||
|
user: SafeUser
|
||||||
|
otherPaymentOptions: PaymentMethodEnum[]
|
||||||
|
mustBeGuaranteed: boolean
|
||||||
|
supportedCards: PaymentMethodEnum[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentClientProps
|
||||||
|
extends Omit<PaymentProps, "supportedCards"> {
|
||||||
|
savedCreditCards: CreditCard[] | null
|
||||||
|
}
|
||||||
@@ -14,17 +14,14 @@ type ProductPrice = z.output<typeof productTypePriceSchema>
|
|||||||
export type RoomPriceSchema = z.output<typeof priceSchema>
|
export type RoomPriceSchema = z.output<typeof priceSchema>
|
||||||
|
|
||||||
export type FlexibilityOptionProps = {
|
export type FlexibilityOptionProps = {
|
||||||
handleSelect: (
|
features: RoomConfiguration["features"]
|
||||||
rateCode: string,
|
|
||||||
rateName: string,
|
|
||||||
paymentTerm: string
|
|
||||||
) => void
|
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
isUserLoggedIn: boolean
|
isUserLoggedIn: boolean
|
||||||
paymentTerm: string
|
paymentTerm: string
|
||||||
petRoomPackage: RoomPackage | undefined
|
petRoomPackage: RoomPackage | undefined
|
||||||
priceInformation?: Array<string>
|
priceInformation?: Array<string>
|
||||||
product: Product | undefined
|
product: Product | undefined
|
||||||
|
roomType: RoomConfiguration["roomType"]
|
||||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
|
||||||
import type { Price } from "../price"
|
import type { Price } from "../price"
|
||||||
import type { RoomPackages } from "./roomFilter"
|
|
||||||
import type { SelectRateSearchParams } from "./selectRate"
|
|
||||||
|
|
||||||
export interface RateSummaryProps {
|
export interface RateSummaryProps {
|
||||||
isUserLoggedIn: boolean
|
isUserLoggedIn: boolean
|
||||||
packages: RoomPackages | undefined
|
|
||||||
roomsAvailability: RoomsAvailability
|
|
||||||
booking: SelectRateSearchParams
|
|
||||||
vat: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MobileSummaryProps {
|
export interface MobileSummaryProps extends RateSummaryProps {
|
||||||
totalPriceToShow: Price
|
|
||||||
isAllRoomsSelected: boolean
|
isAllRoomsSelected: boolean
|
||||||
booking: SelectRateSearchParams
|
|
||||||
isUserLoggedIn: boolean
|
isUserLoggedIn: boolean
|
||||||
vat: number
|
totalPriceToShow: Price
|
||||||
roomsAvailability: RoomsAvailability
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import type { z } from "zod"
|
import type { z } from "zod"
|
||||||
|
|
||||||
import type { Room } from "@/types/hotel"
|
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
import type {
|
|
||||||
RateDefinition,
|
|
||||||
RoomConfiguration,
|
|
||||||
} from "@/types/trpc/routers/hotel/roomAvailability"
|
|
||||||
import type { packagePriceSchema } from "@/server/routers/hotels/schemas/packages"
|
import type { packagePriceSchema } from "@/server/routers/hotels/schemas/packages"
|
||||||
import type { RoomPriceSchema } from "./flexibilityOption"
|
import type { RoomPriceSchema } from "./flexibilityOption"
|
||||||
import type { RoomPackageCodes, RoomPackages } from "./roomFilter"
|
|
||||||
|
|
||||||
export type RoomCardProps = {
|
export type RoomCardProps = {
|
||||||
hotelId: string
|
|
||||||
hotelType: string | undefined
|
|
||||||
roomConfiguration: RoomConfiguration
|
roomConfiguration: RoomConfiguration
|
||||||
rateDefinitions: RateDefinition[]
|
|
||||||
roomCategories: Room[]
|
|
||||||
selectedPackages: RoomPackageCodes[]
|
|
||||||
roomListIndex: number
|
|
||||||
packages: RoomPackages | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoomPackagePriceSchema = z.output<typeof packagePriceSchema>
|
type RoomPackagePriceSchema = z.output<typeof packagePriceSchema>
|
||||||
@@ -31,3 +19,12 @@ export type CalculatePricesPerNightProps = {
|
|||||||
petRoomRequestedPrice?: RoomPackagePriceSchema
|
petRoomRequestedPrice?: RoomPackagePriceSchema
|
||||||
nights: number
|
nights: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoomSizeProps {
|
||||||
|
roomSize:
|
||||||
|
| {
|
||||||
|
max: number
|
||||||
|
min: number
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,12 +17,6 @@ export interface DefaultFilterOptions {
|
|||||||
export type FilterValues = {
|
export type FilterValues = {
|
||||||
[K in RoomPackageCodeEnum]?: boolean
|
[K in RoomPackageCodeEnum]?: boolean
|
||||||
}
|
}
|
||||||
export interface RoomFilterProps {
|
|
||||||
numberOfRooms: number
|
|
||||||
filterOptions: DefaultFilterOptions[]
|
|
||||||
initialFilterValues: FilterValues
|
|
||||||
roomListIndex: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RoomPackage = z.output<typeof packageSchema>
|
export type RoomPackage = z.output<typeof packageSchema>
|
||||||
export type RoomPackageCodes = RoomPackage["code"]
|
export type RoomPackageCodes = RoomPackage["code"]
|
||||||
|
|||||||
@@ -1,19 +1,6 @@
|
|||||||
import type { Room } from "@/types/hotel"
|
import type { Room } from "@/types/hotel"
|
||||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
import type {
|
import type { RoomPackageCodes, RoomPackages } from "./roomFilter"
|
||||||
DefaultFilterOptions,
|
|
||||||
RoomPackageCodes,
|
|
||||||
RoomPackages,
|
|
||||||
} from "./roomFilter"
|
|
||||||
|
|
||||||
export interface RoomTypeListProps {
|
|
||||||
availablePackages: RoomPackages | undefined
|
|
||||||
hotelType: string | undefined
|
|
||||||
roomCategories: Room[]
|
|
||||||
roomListIndex: number
|
|
||||||
roomsAvailability: RoomsAvailability
|
|
||||||
selectedPackages: RoomPackageCodes[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectRateProps {
|
export interface SelectRateProps {
|
||||||
availablePackages: RoomPackages
|
availablePackages: RoomPackages
|
||||||
@@ -23,12 +10,3 @@ export interface SelectRateProps {
|
|||||||
roomCategories: Room[]
|
roomCategories: Room[]
|
||||||
vat: number
|
vat: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomSelectionPanelProps {
|
|
||||||
availablePackages: RoomPackages
|
|
||||||
defaultPackages: DefaultFilterOptions[]
|
|
||||||
hotelType: string | undefined
|
|
||||||
roomCategories: Room[]
|
|
||||||
roomListIndex: number
|
|
||||||
selectedPackages: RoomPackageCodes[]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import type { CreditCard, SafeUser } from "@/types/user"
|
|
||||||
import type { PaymentMethodEnum } from "@/constants/booking"
|
|
||||||
import type { Child } from "./selectRate"
|
|
||||||
|
|
||||||
export interface SectionProps {
|
|
||||||
nextPath: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BedSelectionProps extends SectionProps {
|
|
||||||
alternatives: {
|
|
||||||
value: string
|
|
||||||
name: string
|
|
||||||
payment: string
|
|
||||||
pricePerNight: number
|
|
||||||
membersPricePerNight: number
|
|
||||||
currency: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BreakfastSelectionProps extends SectionProps {
|
|
||||||
alternatives: {
|
|
||||||
value: string
|
|
||||||
name: string
|
|
||||||
payment: string
|
|
||||||
pricePerNight: number
|
|
||||||
currency: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailsProps extends SectionProps {}
|
|
||||||
|
|
||||||
export interface PaymentProps {
|
|
||||||
user: SafeUser
|
|
||||||
otherPaymentOptions: PaymentMethodEnum[]
|
|
||||||
mustBeGuaranteed: boolean
|
|
||||||
supportedCards: PaymentMethodEnum[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentClientProps
|
|
||||||
extends Omit<PaymentProps, "supportedCards"> {
|
|
||||||
savedCreditCards: CreditCard[] | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomParam {
|
|
||||||
adults: number
|
|
||||||
children?: Child[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SectionPageProps {
|
|
||||||
breakfast?: string
|
|
||||||
bed?: string
|
|
||||||
fromDate: string
|
|
||||||
toDate: string
|
|
||||||
room: RoomParam[]
|
|
||||||
}
|
|
||||||
@@ -12,31 +12,35 @@ export interface Child {
|
|||||||
|
|
||||||
export interface Room {
|
export interface Room {
|
||||||
adults: number
|
adults: number
|
||||||
roomTypeCode: string
|
|
||||||
rateCode: string
|
|
||||||
counterRateCode: string
|
|
||||||
childrenInRoom?: Child[]
|
childrenInRoom?: Child[]
|
||||||
|
counterRateCode: string
|
||||||
packages?: RoomPackageCodeEnum[]
|
packages?: RoomPackageCodeEnum[]
|
||||||
|
rateCode: string
|
||||||
|
roomTypeCode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectRateSearchParams {
|
export interface SelectRateSearchParams {
|
||||||
city?: string
|
|
||||||
hotelId: string
|
|
||||||
fromDate: string
|
|
||||||
toDate: string
|
|
||||||
rooms: Room[]
|
|
||||||
bookingCode?: string
|
bookingCode?: string
|
||||||
|
city?: string
|
||||||
|
fromDate: string
|
||||||
|
hotelId: string
|
||||||
|
rooms: Room[]
|
||||||
|
toDate: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Rate {
|
export interface Rate {
|
||||||
roomType: RoomConfiguration["roomType"]
|
features: RoomConfiguration["features"]
|
||||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
member?: Product["productType"]["member"]
|
||||||
|
package?: RoomPackageCodeEnum | undefined
|
||||||
priceName?: string
|
priceName?: string
|
||||||
priceTerm?: string
|
priceTerm?: string
|
||||||
public: Product["productType"]["public"]
|
public: Product["productType"]["public"]
|
||||||
member?: Product["productType"]["member"]
|
roomRates?: {
|
||||||
features: RoomConfiguration["features"]
|
rate: Rate
|
||||||
roomRates?: Array<{ roomIndex: number; rate: Rate }>
|
roomIndex: number
|
||||||
|
}[]
|
||||||
|
roomType: RoomConfiguration["roomType"]
|
||||||
|
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RateCode = {
|
export type RateCode = {
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
export type SelectionCardProps = {
|
|
||||||
title: string
|
|
||||||
subtext: string
|
|
||||||
price: number
|
|
||||||
membersPrice?: number
|
|
||||||
currency: string
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ export interface SummaryUIProps {
|
|||||||
isMember: boolean
|
isMember: boolean
|
||||||
totalPrice: Price
|
totalPrice: Price
|
||||||
toggleSummaryOpen: () => void
|
toggleSummaryOpen: () => void
|
||||||
togglePriceDetailsModalOpen: () => void
|
|
||||||
vat: number
|
vat: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
types/contexts/rates.ts
Normal file
3
types/contexts/rates.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { createRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
|
export type RatesStore = ReturnType<typeof createRatesStore>
|
||||||
12
types/contexts/room.ts
Normal file
12
types/contexts/room.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
|
import type { SelectedRate, SelectedRoom } from "@/types/stores/rates"
|
||||||
|
|
||||||
|
export interface RoomContextValue extends SelectedRoom {
|
||||||
|
actions: {
|
||||||
|
modifyRate: () => void
|
||||||
|
selectFilter: (code: RoomPackageCodeEnum | undefined) => void
|
||||||
|
selectRate: (rate: SelectedRate) => void
|
||||||
|
}
|
||||||
|
isActiveRoom: boolean
|
||||||
|
roomNr: number
|
||||||
|
}
|
||||||
14
types/providers/rates.ts
Normal file
14
types/providers/rates.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Room } from "@/types/hotel"
|
||||||
|
import type { Packages } from "@/types/requests/packages"
|
||||||
|
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
|
import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate"
|
||||||
|
|
||||||
|
export interface RatesProviderProps extends React.PropsWithChildren {
|
||||||
|
booking: SelectRateSearchParams
|
||||||
|
hotelType: string | undefined
|
||||||
|
isUserLoggedIn: boolean
|
||||||
|
packages: Packages | null
|
||||||
|
roomCategories: Room[]
|
||||||
|
roomsAvailability: RoomsAvailability | null
|
||||||
|
vat: number
|
||||||
|
}
|
||||||
6
types/providers/room.ts
Normal file
6
types/providers/room.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { SelectedRoom } from "@/types/stores/rates"
|
||||||
|
|
||||||
|
export interface RoomProviderProps extends React.PropsWithChildren {
|
||||||
|
idx: number
|
||||||
|
room: SelectedRoom
|
||||||
|
}
|
||||||
76
types/stores/rates.ts
Normal file
76
types/stores/rates.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { ReadonlyURLSearchParams } from "next/navigation"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DefaultFilterOptions,
|
||||||
|
RoomPackageCodeEnum,
|
||||||
|
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
|
import type {
|
||||||
|
Rate,
|
||||||
|
Room as RoomBooking,
|
||||||
|
SelectRateSearchParams,
|
||||||
|
} from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
import type { Room } from "@/types/hotel"
|
||||||
|
import type { Packages } from "@/types/requests/packages"
|
||||||
|
import type {
|
||||||
|
Product,
|
||||||
|
RoomConfiguration,
|
||||||
|
RoomsAvailability,
|
||||||
|
} from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
|
|
||||||
|
interface Actions {
|
||||||
|
modifyRate: (idx: number) => () => void
|
||||||
|
selectFilter: (idx: number) => (code: RoomPackageCodeEnum | undefined) => void
|
||||||
|
selectRate: (idx: number) => (rate: SelectedRate) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedRate {
|
||||||
|
features: RoomConfiguration["features"]
|
||||||
|
product: Product
|
||||||
|
roomType: RoomConfiguration["roomType"]
|
||||||
|
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedRoom {
|
||||||
|
bookingRoom: RoomBooking
|
||||||
|
rooms: RoomConfiguration[]
|
||||||
|
selectedPackage: RoomPackageCodeEnum | undefined
|
||||||
|
selectedRate: SelectedRate | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatesState {
|
||||||
|
actions: Actions
|
||||||
|
activeRoom: number
|
||||||
|
allRooms: RoomConfiguration[]
|
||||||
|
booking: SelectRateSearchParams
|
||||||
|
filterOptions: DefaultFilterOptions[]
|
||||||
|
hotelType: string | undefined
|
||||||
|
packages: NonNullable<Packages>
|
||||||
|
pathname: string
|
||||||
|
petRoomPackage: NonNullable<Packages>[number] | undefined
|
||||||
|
rateSummary: Rate[]
|
||||||
|
rooms: SelectedRoom[]
|
||||||
|
roomCategories: Room[]
|
||||||
|
roomsAvailability: RoomsAvailability | null
|
||||||
|
searchParams: ReadonlyURLSearchParams
|
||||||
|
vat: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitialState
|
||||||
|
extends Pick<
|
||||||
|
RatesState,
|
||||||
|
| "booking"
|
||||||
|
| "hotelType"
|
||||||
|
| "packages"
|
||||||
|
| "pathname"
|
||||||
|
| "roomCategories"
|
||||||
|
| "roomsAvailability"
|
||||||
|
| "searchParams"
|
||||||
|
| "vat"
|
||||||
|
> {
|
||||||
|
isUserLoggedIn: boolean
|
||||||
|
labels: {
|
||||||
|
accessibilityRoom: string
|
||||||
|
allergyRoom: string
|
||||||
|
petRoom: string
|
||||||
|
}
|
||||||
|
}
|
||||||
14
utils/clientSession.ts
Normal file
14
utils/clientSession.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Session } from "next-auth"
|
||||||
|
|
||||||
|
export function isValidClientSession(session: Session | null) {
|
||||||
|
if (!session) {
|
||||||
|
console.log("No session available (user not authenticated).")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (session.error) {
|
||||||
|
console.log(`Session error: ${session.error}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user