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:
Simon.Emanuelsson
2025-02-14 13:51:37 +00:00
95 changed files with 3269 additions and 3527 deletions

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
.wrapper {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,3 +14,9 @@
position: fixed;
width: 0;
}
.hotelAlert {
width: 100%;
margin: 0 auto;
padding: var(--Spacing-x-one-and-half);
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ export function getIconForFeatureCode(featureCode: RoomPackageCodes) {
case RoomPackageCodeEnum.ALLERGY_ROOM:
return AllergyIcon
case RoomPackageCodeEnum.PET_ROOM:
return PetsIcon
default:
return PetsIcon
}

View File

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

View File

@@ -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²",

View File

@@ -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²",

View File

@@ -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²",

View File

@@ -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²",

View File

@@ -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²",

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

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

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

View File

@@ -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, {

View File

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

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

@@ -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"]

View File

@@ -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[]
}

View File

@@ -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[]
}

View File

@@ -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 = {

View File

@@ -1,7 +0,0 @@
export type SelectionCardProps = {
title: string
subtext: string
price: number
membersPrice?: number
currency: string
}

View File

@@ -24,7 +24,6 @@ export interface SummaryUIProps {
isMember: boolean
totalPrice: Price
toggleSummaryOpen: () => void
togglePriceDetailsModalOpen: () => void
vat: number
}

3
types/contexts/rates.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}