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, {
|
||||
HotelInfoCardSkeleton,
|
||||
} from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
||||
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainer"
|
||||
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainerSkeleton"
|
||||
import {
|
||||
preload,
|
||||
RoomsContainer,
|
||||
} from "@/components/HotelReservation/SelectRate/RoomsContainer"
|
||||
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/RoomsContainer/RoomsContainerSkeleton"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import { convertSearchParamsToObj } from "@/utils/url"
|
||||
@@ -37,17 +40,26 @@ export default async function SelectRatePage({
|
||||
const { hotel, adultsInRoom, childrenInRoom, selectHotelParams } =
|
||||
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({
|
||||
hotelId: hotel.id,
|
||||
isCardOnlyPayment: false,
|
||||
language: params.lang,
|
||||
})
|
||||
|
||||
const { fromDate, toDate } = getValidDates(
|
||||
selectHotelParams.fromDate,
|
||||
selectHotelParams.toDate
|
||||
)
|
||||
|
||||
const arrivalDate = fromDate.toDate()
|
||||
const departureDate = toDate.toDate()
|
||||
|
||||
@@ -96,13 +108,13 @@ export default async function SelectRatePage({
|
||||
fallback={<RoomsContainerSkeleton />}
|
||||
>
|
||||
<RoomsContainer
|
||||
adultArray={adultsInRoom}
|
||||
booking={booking}
|
||||
childArray={childrenInRoom}
|
||||
fromDate={arrivalDate}
|
||||
hotelId={hotelId}
|
||||
lang={params.lang}
|
||||
fromDate={fromDate.toDate()}
|
||||
toDate={toDate.toDate()}
|
||||
adultArray={adultsInRoom}
|
||||
childArray={childrenInRoom}
|
||||
booking={booking}
|
||||
toDate={departureDate}
|
||||
/>
|
||||
</Suspense>
|
||||
<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 type { PaymentClientProps } from "@/types/components/hotelReservation/enterDetails/payment"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { PaymentClientProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
const maxRetries = 15
|
||||
const retryInterval = 2000
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
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({
|
||||
user,
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
export default function DesktopSummary(props: SummaryProps) {
|
||||
const {
|
||||
booking,
|
||||
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
|
||||
actions: { toggleSummaryOpen },
|
||||
totalPrice,
|
||||
vat,
|
||||
} = useEnterDetailsStore((state) => state)
|
||||
@@ -28,7 +28,6 @@ export default function DesktopSummary(props: SummaryProps) {
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
||||
/>
|
||||
</SidePanel>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
export default function MobileSummary(props: SummaryProps) {
|
||||
const {
|
||||
booking,
|
||||
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
|
||||
actions: { toggleSummaryOpen },
|
||||
totalPrice,
|
||||
vat,
|
||||
} = useEnterDetailsStore((state) => state)
|
||||
@@ -40,7 +40,6 @@ export default function MobileSummary(props: SummaryProps) {
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
||||
/>
|
||||
</div>
|
||||
</SummaryBottomSheet>
|
||||
|
||||
@@ -35,7 +35,6 @@ export default function SummaryUI({
|
||||
breakfastIncluded,
|
||||
vat,
|
||||
toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen,
|
||||
}: EnterDetailsSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
@@ -53,12 +52,6 @@ export default function SummaryUI({
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePriceDetailsModal() {
|
||||
if (togglePriceDetailsModalOpen) {
|
||||
togglePriceDetailsModalOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
return roomRate?.memberRate
|
||||
? {
|
||||
@@ -369,7 +362,6 @@ export default function SummaryUI({
|
||||
}))}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleModal={handleTogglePriceDetailsModal}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -102,7 +102,6 @@ describe("EnterDetails Summary", () => {
|
||||
}}
|
||||
vat={12}
|
||||
toggleSummaryOpen={jest.fn()}
|
||||
togglePriceDetailsModalOpen={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(intl),
|
||||
@@ -141,7 +140,6 @@ describe("EnterDetails Summary", () => {
|
||||
}}
|
||||
vat={12}
|
||||
toggleSummaryOpen={jest.fn()}
|
||||
togglePriceDetailsModalOpen={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(intl),
|
||||
|
||||
@@ -9,6 +9,7 @@ import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
|
||||
import HotelCardDialogImage from "../HotelCardDialogImage"
|
||||
@@ -33,7 +34,7 @@ export default function ListingHotelCardDialog({
|
||||
}: ListingHotelCardProps) {
|
||||
const intl = useIntl()
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = !!session
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const {
|
||||
name,
|
||||
publicPrice,
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
|
||||
import HotelCardDialogImage from "../HotelCardDialogImage"
|
||||
@@ -35,7 +36,7 @@ export default function StandaloneHotelCardDialog({
|
||||
}: StandaloneHotelCardProps) {
|
||||
const intl = useIntl()
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = !!session
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const {
|
||||
name,
|
||||
publicPrice,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
import { useScrollToTop } from "@/hooks/useScrollToTop"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import HotelCard from "../HotelCard"
|
||||
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
|
||||
@@ -29,7 +30,7 @@ export default function HotelCardListing({
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
}: HotelCardListingProps) {
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = !!session
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const searchParams = useSearchParams()
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||
|
||||
@@ -26,7 +26,6 @@ interface PriceDetailsModalProps {
|
||||
}[]
|
||||
totalPrice: Price
|
||||
vat: number
|
||||
toggleModal: () => void
|
||||
}
|
||||
|
||||
export default function PriceDetailsModal({
|
||||
@@ -35,7 +34,6 @@ export default function PriceDetailsModal({
|
||||
rooms,
|
||||
totalPrice,
|
||||
vat,
|
||||
toggleModal,
|
||||
}: PriceDetailsModalProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
@@ -43,7 +41,7 @@ export default function PriceDetailsModal({
|
||||
<Modal
|
||||
title={intl.formatMessage({ id: "Price details" })}
|
||||
trigger={
|
||||
<Button intent="text" onPress={toggleModal}>
|
||||
<Button intent="text">
|
||||
<Caption color="burgundy">
|
||||
{intl.formatMessage({ id: "Price details" })}
|
||||
</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"
|
||||
|
||||
import React from "react"
|
||||
import { Fragment } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
@@ -34,7 +33,6 @@ export default function Summary({
|
||||
isMember,
|
||||
vat,
|
||||
toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen,
|
||||
}: SelectRateSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
@@ -46,18 +44,6 @@ export default function Summary({
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
function handleToggleSummary() {
|
||||
if (toggleSummaryOpen) {
|
||||
toggleSummaryOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePriceDetailsModal() {
|
||||
if (togglePriceDetailsModalOpen) {
|
||||
togglePriceDetailsModalOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
return roomRate?.memberRate
|
||||
? {
|
||||
@@ -85,7 +71,7 @@ export default function Summary({
|
||||
intent="text"
|
||||
size="small"
|
||||
className={styles.chevronButton}
|
||||
onClick={handleToggleSummary}
|
||||
onClick={toggleSummaryOpen}
|
||||
>
|
||||
<ChevronDownSmallIcon height="20" width="20" />
|
||||
</Button>
|
||||
@@ -135,7 +121,7 @@ export default function Summary({
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<Fragment key={idx}>
|
||||
<div
|
||||
className={styles.addOns}
|
||||
data-testid={`summary-room-${roomNumber}`}
|
||||
@@ -237,7 +223,7 @@ export default function Summary({
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
<div className={styles.total}>
|
||||
@@ -260,7 +246,6 @@ export default function Summary({
|
||||
}))}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleModal={handleTogglePriceDetailsModal}
|
||||
/>
|
||||
</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 { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
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"
|
||||
|
||||
export default function MobileSummary({
|
||||
totalPriceToShow,
|
||||
isAllRoomsSelected,
|
||||
booking,
|
||||
isUserLoggedIn,
|
||||
vat,
|
||||
roomsAvailability,
|
||||
totalPriceToShow,
|
||||
}: MobileSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const scrollY = useRef(0)
|
||||
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
|
||||
|
||||
const {
|
||||
guestsInRooms,
|
||||
isSummaryOpen,
|
||||
getSelectedRateSummary,
|
||||
toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen,
|
||||
} = useRateSelectionStore()
|
||||
const { booking, bookingRooms, rateDefinitions, rateSummary, vat } =
|
||||
useRatesStore((state) => ({
|
||||
booking: state.booking,
|
||||
bookingRooms: state.booking.rooms,
|
||||
rateDefinitions: state.roomsAvailability?.rateDefinitions,
|
||||
rateSummary: state.rateSummary,
|
||||
vat: state.vat,
|
||||
}))
|
||||
|
||||
const selectedRateSummary = getSelectedRateSummary()
|
||||
|
||||
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 ?? "",
|
||||
}))
|
||||
function toggleSummaryOpen() {
|
||||
setIsSummaryOpen(!isSummaryOpen)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSummaryOpen) {
|
||||
@@ -93,6 +60,44 @@ export default function MobileSummary({
|
||||
}
|
||||
}, [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 (
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>
|
||||
@@ -104,7 +109,6 @@ export default function MobileSummary({
|
||||
totalPrice={totalPriceToShow}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
||||
/>
|
||||
</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 {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
align-items: center;
|
||||
animation: slideUp 300ms ease forwards;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
bottom: -100%;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
align-items: center;
|
||||
transition: bottom 300ms ease-in-out;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary[data-visible="true"] {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.summaryPriceContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -35,6 +36,7 @@
|
||||
display: none;
|
||||
max-width: 264px;
|
||||
}
|
||||
|
||||
.summaryPrice {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
@@ -74,34 +76,46 @@
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.summary {
|
||||
padding: var(--Spacing-x3) 0 var(--Spacing-x5);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
padding: var(--Spacing-x3) 0 var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width-page);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.petInfo,
|
||||
.promoContainer,
|
||||
.summaryPriceTextDesktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.summaryPriceTextMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summaryPrice,
|
||||
.continueButton {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.summaryPriceContainer {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobileSummary {
|
||||
display: none;
|
||||
}
|
||||
@@ -14,15 +14,18 @@ export const calculateTotalPrice = (
|
||||
(total, room) => {
|
||||
const priceToUse =
|
||||
isUserLoggedIn && room.member ? room.member : room.public
|
||||
const isPetRoom = room.features.some(
|
||||
const isPetRoom = room.features.find(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const petRoomPrice =
|
||||
isPetRoom && petRoomPackage
|
||||
? isUserLoggedIn
|
||||
? Number(petRoomPackage.localPrice.totalPrice || 0)
|
||||
: Number(petRoomPackage.requestedPrice.totalPrice || 0)
|
||||
: 0
|
||||
|
||||
let petRoomPrice = 0
|
||||
if (
|
||||
petRoomPackage &&
|
||||
isPetRoom &&
|
||||
room.package === RoomPackageCodeEnum.PET_ROOM
|
||||
) {
|
||||
petRoomPrice = Number(petRoomPackage.localPrice.totalPrice)
|
||||
}
|
||||
|
||||
return {
|
||||
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);
|
||||
}
|
||||
|
||||
div.selectedRoomPanel p.subtitle {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.imageContainer {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.imageAndModifyButtonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.modifyButtonContainer {
|
||||
position: relative;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
padding: var(--Spacing-x3);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.roomPanel,
|
||||
@@ -36,7 +19,7 @@
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.roomPanel > * {
|
||||
.roomPanel>* {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -53,6 +36,7 @@
|
||||
.roomSelectionPanelContainer.selected .roomSelectionPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.roomSelectionPanelContainer.active .roomSelectionPanel {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
@@ -60,14 +44,12 @@
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelAlert {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
div.roomContainer p.subtitle {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.roomContainer {
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
import styles from "./multiRoomWrapper.module.css"
|
||||
|
||||
export const roomSelectionPanelVariants = cva(
|
||||
styles.roomSelectionPanelContainer,
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
|
||||
@@ -8,6 +7,7 @@ import Button from "@/components/TempDesignSystem/Button"
|
||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { useRoomContext } from "@/contexts/Room"
|
||||
|
||||
import PriceTable from "./PriceList"
|
||||
|
||||
@@ -16,16 +16,32 @@ import styles from "./flexibilityOption.module.css"
|
||||
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
|
||||
export default function FlexibilityOption({
|
||||
handleSelect,
|
||||
features,
|
||||
isSelected,
|
||||
isUserLoggedIn,
|
||||
paymentTerm,
|
||||
priceInformation,
|
||||
petRoomPackage,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
title,
|
||||
}: FlexibilityOptionProps) {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
actions: { selectRate },
|
||||
} = useRoomContext()
|
||||
|
||||
function handleSelect() {
|
||||
if (product) {
|
||||
selectRate({
|
||||
features,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
@@ -48,18 +64,14 @@ export default function FlexibilityOption({
|
||||
|
||||
const { public: publicPrice, member: memberPrice } = product.productType
|
||||
|
||||
function handleOnSelect() {
|
||||
handleSelect(publicPrice.rateCode, title, paymentTerm)
|
||||
}
|
||||
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name={`rateCode-${product.productType.public.rateCode}`}
|
||||
value={publicPrice?.rateCode}
|
||||
checked={isSelected}
|
||||
onChange={handleOnSelect}
|
||||
name={`rateCode-${product.productType.public.rateCode}`}
|
||||
onChange={handleSelect}
|
||||
type="radio"
|
||||
value={publicPrice?.rateCode}
|
||||
/>
|
||||
<div className={styles.card}>
|
||||
<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;
|
||||
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"
|
||||
|
||||
export function combineRoomAvailabilities({
|
||||
availabilityResults,
|
||||
}: {
|
||||
availabilityResults: Array<[RoomsAvailability | undefined | null, unknown]>
|
||||
}): RoomsAvailability | null {
|
||||
return availabilityResults.reduce<RoomsAvailability | null>(
|
||||
(combinedResult, [currentResult, error]) => {
|
||||
if (error || !currentResult) return combinedResult
|
||||
if (!combinedResult) return currentResult
|
||||
|
||||
return {
|
||||
...currentResult,
|
||||
roomConfigurations: [
|
||||
...combinedResult.roomConfigurations,
|
||||
...currentResult.roomConfigurations,
|
||||
],
|
||||
export function combineRoomAvailabilities(
|
||||
availabilityResults: PromiseSettledResult<RoomsAvailability | null>[]
|
||||
) {
|
||||
return availabilityResults.reduce<RoomsAvailability | null>((acc, result) => {
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
if (acc) {
|
||||
acc.roomConfigurations.push(...result.value.roomConfigurations)
|
||||
} else {
|
||||
acc = result.value
|
||||
}
|
||||
},
|
||||
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:
|
||||
return AllergyIcon
|
||||
case RoomPackageCodeEnum.PET_ROOM:
|
||||
return PetsIcon
|
||||
default:
|
||||
return PetsIcon
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function CountrySelect({
|
||||
rules: registerOptions,
|
||||
})
|
||||
|
||||
function handleChange(country: Key) {
|
||||
function handleChange(country: Key | null) {
|
||||
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",
|
||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||
"{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². 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²",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
"{price}/night": "{price}/nacht",
|
||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||
"{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². 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²",
|
||||
|
||||
@@ -720,6 +720,7 @@
|
||||
"{price}/night": "{price}/night",
|
||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||
"{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². 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²",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
"{price}/night": "{price}/yö",
|
||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||
"{rating} ({count} reviews on Tripadvisor)": "{rating} ({count} arvostelua TripAdvisorissa)",
|
||||
"{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öä}",
|
||||
"{roomSize} m²": "{roomSize} m²",
|
||||
|
||||
@@ -715,6 +715,7 @@
|
||||
"{price}/night": "{price}/natt",
|
||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||
"{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². 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²",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
"{price}/night": "{price}/natt",
|
||||
"{publicPrice}/{memberPrice} {currency}": "{publicPrice}/{memberPrice} {currency}",
|
||||
"{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². 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²",
|
||||
|
||||
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-auth": "5.0.0-beta.19",
|
||||
"react": "^18",
|
||||
"react-aria-components": "^1.6.0",
|
||||
"react-day-picker": "^9.0.8",
|
||||
"react-dom": "^18",
|
||||
"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,
|
||||
})
|
||||
)
|
||||
throw badRequestError()
|
||||
return null
|
||||
}
|
||||
metrics.roomAvailability.success.add(1, {
|
||||
hotelId,
|
||||
@@ -1547,6 +1547,7 @@ export const hotelQueryRouter = router({
|
||||
"api.hotels.packages error",
|
||||
JSON.stringify({ query: { hotelId, params } })
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
@@ -1565,7 +1566,7 @@ export const hotelQueryRouter = router({
|
||||
error: validatedPackagesData.error,
|
||||
})
|
||||
)
|
||||
throw badRequestError()
|
||||
return null
|
||||
}
|
||||
|
||||
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 {
|
||||
Rate,
|
||||
RateCode,
|
||||
} 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
|
||||
roomIndex: number
|
||||
}
|
||||
@@ -56,3 +64,134 @@ export function calculateRoomSummary({
|
||||
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 FlexibilityOptionProps = {
|
||||
handleSelect: (
|
||||
rateCode: string,
|
||||
rateName: string,
|
||||
paymentTerm: string
|
||||
) => void
|
||||
features: RoomConfiguration["features"]
|
||||
isSelected: boolean
|
||||
isUserLoggedIn: boolean
|
||||
paymentTerm: string
|
||||
petRoomPackage: RoomPackage | undefined
|
||||
priceInformation?: Array<string>
|
||||
product: Product | undefined
|
||||
roomType: RoomConfiguration["roomType"]
|
||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||
title: string
|
||||
}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { Price } from "../price"
|
||||
import type { RoomPackages } from "./roomFilter"
|
||||
import type { SelectRateSearchParams } from "./selectRate"
|
||||
|
||||
export interface RateSummaryProps {
|
||||
isUserLoggedIn: boolean
|
||||
packages: RoomPackages | undefined
|
||||
roomsAvailability: RoomsAvailability
|
||||
booking: SelectRateSearchParams
|
||||
vat: number
|
||||
}
|
||||
|
||||
export interface MobileSummaryProps {
|
||||
totalPriceToShow: Price
|
||||
export interface MobileSummaryProps extends RateSummaryProps {
|
||||
isAllRoomsSelected: boolean
|
||||
booking: SelectRateSearchParams
|
||||
isUserLoggedIn: boolean
|
||||
vat: number
|
||||
roomsAvailability: RoomsAvailability
|
||||
totalPriceToShow: Price
|
||||
}
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import type { z } from "zod"
|
||||
|
||||
import type { Room } from "@/types/hotel"
|
||||
import type {
|
||||
RateDefinition,
|
||||
RoomConfiguration,
|
||||
} from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { packagePriceSchema } from "@/server/routers/hotels/schemas/packages"
|
||||
import type { RoomPriceSchema } from "./flexibilityOption"
|
||||
import type { RoomPackageCodes, RoomPackages } from "./roomFilter"
|
||||
|
||||
export type RoomCardProps = {
|
||||
hotelId: string
|
||||
hotelType: string | undefined
|
||||
roomConfiguration: RoomConfiguration
|
||||
rateDefinitions: RateDefinition[]
|
||||
roomCategories: Room[]
|
||||
selectedPackages: RoomPackageCodes[]
|
||||
roomListIndex: number
|
||||
packages: RoomPackages | undefined
|
||||
}
|
||||
|
||||
type RoomPackagePriceSchema = z.output<typeof packagePriceSchema>
|
||||
@@ -31,3 +19,12 @@ export type CalculatePricesPerNightProps = {
|
||||
petRoomRequestedPrice?: RoomPackagePriceSchema
|
||||
nights: number
|
||||
}
|
||||
|
||||
export interface RoomSizeProps {
|
||||
roomSize:
|
||||
| {
|
||||
max: number
|
||||
min: number
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
@@ -17,12 +17,6 @@ export interface DefaultFilterOptions {
|
||||
export type FilterValues = {
|
||||
[K in RoomPackageCodeEnum]?: boolean
|
||||
}
|
||||
export interface RoomFilterProps {
|
||||
numberOfRooms: number
|
||||
filterOptions: DefaultFilterOptions[]
|
||||
initialFilterValues: FilterValues
|
||||
roomListIndex: number
|
||||
}
|
||||
|
||||
export type RoomPackage = z.output<typeof packageSchema>
|
||||
export type RoomPackageCodes = RoomPackage["code"]
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import type { Room } from "@/types/hotel"
|
||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type {
|
||||
DefaultFilterOptions,
|
||||
RoomPackageCodes,
|
||||
RoomPackages,
|
||||
} from "./roomFilter"
|
||||
|
||||
export interface RoomTypeListProps {
|
||||
availablePackages: RoomPackages | undefined
|
||||
hotelType: string | undefined
|
||||
roomCategories: Room[]
|
||||
roomListIndex: number
|
||||
roomsAvailability: RoomsAvailability
|
||||
selectedPackages: RoomPackageCodes[]
|
||||
}
|
||||
import type { RoomPackageCodes, RoomPackages } from "./roomFilter"
|
||||
|
||||
export interface SelectRateProps {
|
||||
availablePackages: RoomPackages
|
||||
@@ -23,12 +10,3 @@ export interface SelectRateProps {
|
||||
roomCategories: Room[]
|
||||
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 {
|
||||
adults: number
|
||||
roomTypeCode: string
|
||||
rateCode: string
|
||||
counterRateCode: string
|
||||
childrenInRoom?: Child[]
|
||||
counterRateCode: string
|
||||
packages?: RoomPackageCodeEnum[]
|
||||
rateCode: string
|
||||
roomTypeCode: string
|
||||
}
|
||||
|
||||
export interface SelectRateSearchParams {
|
||||
city?: string
|
||||
hotelId: string
|
||||
fromDate: string
|
||||
toDate: string
|
||||
rooms: Room[]
|
||||
bookingCode?: string
|
||||
city?: string
|
||||
fromDate: string
|
||||
hotelId: string
|
||||
rooms: Room[]
|
||||
toDate: string
|
||||
}
|
||||
|
||||
export interface Rate {
|
||||
roomType: RoomConfiguration["roomType"]
|
||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||
features: RoomConfiguration["features"]
|
||||
member?: Product["productType"]["member"]
|
||||
package?: RoomPackageCodeEnum | undefined
|
||||
priceName?: string
|
||||
priceTerm?: string
|
||||
public: Product["productType"]["public"]
|
||||
member?: Product["productType"]["member"]
|
||||
features: RoomConfiguration["features"]
|
||||
roomRates?: Array<{ roomIndex: number; rate: Rate }>
|
||||
roomRates?: {
|
||||
rate: Rate
|
||||
roomIndex: number
|
||||
}[]
|
||||
roomType: RoomConfiguration["roomType"]
|
||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||
}
|
||||
|
||||
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
|
||||
totalPrice: Price
|
||||
toggleSummaryOpen: () => void
|
||||
togglePriceDetailsModalOpen: () => void
|
||||
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