chore: cleaning up select-rate
This commit is contained in:
@@ -42,8 +42,8 @@ import { type PaymentFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import type { PaymentClientProps } from "@/types/components/hotelReservation/enterDetails/payment"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { PaymentClientProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
const maxRetries = 15
|
||||
const retryInterval = 2000
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import PaymentClient from "./PaymentClient"
|
||||
|
||||
import type { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
import type { PaymentProps } from "@/types/components/hotelReservation/enterDetails/payment"
|
||||
|
||||
export default async function Payment({
|
||||
user,
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
export default function DesktopSummary(props: SummaryProps) {
|
||||
const {
|
||||
booking,
|
||||
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
|
||||
actions: { toggleSummaryOpen },
|
||||
totalPrice,
|
||||
vat,
|
||||
} = useEnterDetailsStore((state) => state)
|
||||
@@ -28,7 +28,6 @@ export default function DesktopSummary(props: SummaryProps) {
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
||||
/>
|
||||
</SidePanel>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
export default function MobileSummary(props: SummaryProps) {
|
||||
const {
|
||||
booking,
|
||||
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
|
||||
actions: { toggleSummaryOpen },
|
||||
totalPrice,
|
||||
vat,
|
||||
} = useEnterDetailsStore((state) => state)
|
||||
@@ -40,7 +40,6 @@ export default function MobileSummary(props: SummaryProps) {
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
||||
/>
|
||||
</div>
|
||||
</SummaryBottomSheet>
|
||||
|
||||
@@ -35,7 +35,6 @@ export default function SummaryUI({
|
||||
breakfastIncluded,
|
||||
vat,
|
||||
toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen,
|
||||
}: EnterDetailsSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
@@ -53,12 +52,6 @@ export default function SummaryUI({
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePriceDetailsModal() {
|
||||
if (togglePriceDetailsModalOpen) {
|
||||
togglePriceDetailsModalOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
return roomRate?.memberRate
|
||||
? {
|
||||
@@ -369,7 +362,6 @@ export default function SummaryUI({
|
||||
}))}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleModal={handleTogglePriceDetailsModal}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -102,7 +102,6 @@ describe("EnterDetails Summary", () => {
|
||||
}}
|
||||
vat={12}
|
||||
toggleSummaryOpen={jest.fn()}
|
||||
togglePriceDetailsModalOpen={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(intl),
|
||||
@@ -141,7 +140,6 @@ describe("EnterDetails Summary", () => {
|
||||
}}
|
||||
vat={12}
|
||||
toggleSummaryOpen={jest.fn()}
|
||||
togglePriceDetailsModalOpen={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(intl),
|
||||
|
||||
@@ -9,6 +9,7 @@ import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
|
||||
import HotelCardDialogImage from "../HotelCardDialogImage"
|
||||
@@ -33,7 +34,7 @@ export default function ListingHotelCardDialog({
|
||||
}: ListingHotelCardProps) {
|
||||
const intl = useIntl()
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = !!session
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const {
|
||||
name,
|
||||
publicPrice,
|
||||
|
||||
@@ -11,6 +11,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
|
||||
import HotelCardDialogImage from "../HotelCardDialogImage"
|
||||
@@ -35,7 +36,7 @@ export default function StandaloneHotelCardDialog({
|
||||
}: StandaloneHotelCardProps) {
|
||||
const intl = useIntl()
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = !!session
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const {
|
||||
name,
|
||||
publicPrice,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
import { useScrollToTop } from "@/hooks/useScrollToTop"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import HotelCard from "../HotelCard"
|
||||
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
|
||||
@@ -29,7 +30,7 @@ export default function HotelCardListing({
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
}: HotelCardListingProps) {
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = !!session
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const searchParams = useSearchParams()
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||
|
||||
@@ -26,7 +26,6 @@ interface PriceDetailsModalProps {
|
||||
}[]
|
||||
totalPrice: Price
|
||||
vat: number
|
||||
toggleModal: () => void
|
||||
}
|
||||
|
||||
export default function PriceDetailsModal({
|
||||
@@ -35,7 +34,6 @@ export default function PriceDetailsModal({
|
||||
rooms,
|
||||
totalPrice,
|
||||
vat,
|
||||
toggleModal,
|
||||
}: PriceDetailsModalProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
@@ -43,7 +41,7 @@ export default function PriceDetailsModal({
|
||||
<Modal
|
||||
title={intl.formatMessage({ id: "Price details" })}
|
||||
trigger={
|
||||
<Button intent="text" onPress={toggleModal}>
|
||||
<Button intent="text">
|
||||
<Caption color="burgundy">
|
||||
{intl.formatMessage({ id: "Price details" })}
|
||||
</Caption>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
.wrapper {
|
||||
border-bottom: 1px solid rgba(17, 17, 17, 0.2);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-top: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.list {
|
||||
margin-top: var(--Spacing-x4);
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
column-gap: var(--Spacing-x2);
|
||||
row-gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.list > li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list input[type="radio"] {
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
width: 0;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import SelectionCard from "../SelectionCard"
|
||||
|
||||
import styles from "./bedSelection.module.css"
|
||||
|
||||
import type { BedSelectionProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
export default function BedSelection({
|
||||
alternatives,
|
||||
nextPath,
|
||||
}: BedSelectionProps) {
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const queryParams = new URLSearchParams(searchParams)
|
||||
queryParams.set("bed", e.currentTarget.bed?.value)
|
||||
router.push(`${nextPath}?${queryParams}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<form
|
||||
method="GET"
|
||||
action={`${nextPath}?${searchParams}`}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<ul className={styles.list}>
|
||||
{alternatives.map((alternative) => (
|
||||
<li key={alternative.value}>
|
||||
<label>
|
||||
<input type="radio" name="bed" value={alternative.value} />
|
||||
<SelectionCard
|
||||
title={alternative.name}
|
||||
subtext={`(${alternative.payment})`}
|
||||
price={alternative.pricePerNight}
|
||||
membersPrice={alternative.membersPricePerNight}
|
||||
currency={alternative.currency}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button type="submit" hidden>
|
||||
{intl.formatMessage({ id: "Submit" })}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
.wrapper {
|
||||
border-bottom: 1px solid rgba(17, 17, 17, 0.2);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-top: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.list {
|
||||
margin-top: var(--Spacing-x4);
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: var(--Spacing-x2);
|
||||
row-gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.list > li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list input[type="radio"] {
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
width: 0;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import SelectionCard from "../SelectionCard"
|
||||
|
||||
import styles from "./breakfastSelection.module.css"
|
||||
|
||||
import type { BreakfastSelectionProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
export default function BreakfastSelection({
|
||||
alternatives,
|
||||
nextPath,
|
||||
}: BreakfastSelectionProps) {
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const queryParams = new URLSearchParams(searchParams)
|
||||
queryParams.set("breakfast", e.currentTarget.breakfast?.value)
|
||||
router.push(`${nextPath}?${queryParams}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<form
|
||||
method="GET"
|
||||
action={`${nextPath}?${searchParams}`}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<ul className={styles.list}>
|
||||
{alternatives.map((alternative) => (
|
||||
<li key={alternative.value}>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="breakfast"
|
||||
value={alternative.value}
|
||||
/>
|
||||
<SelectionCard
|
||||
title={alternative.name}
|
||||
subtext={alternative.payment}
|
||||
price={alternative.pricePerNight}
|
||||
currency={alternative.currency}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button type="submit" hidden>
|
||||
{intl.formatMessage({ id: "Submit" })}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
.wrapper {
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
import type { DetailsProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
export default function Details({ nextPath }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<form method="GET" action={`${nextPath}?${searchParams}`}>
|
||||
<Button type="submit">{intl.formatMessage({ id: "Submit" })}</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
||||
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import MobileSummary from "./MobileSummary"
|
||||
import { calculateTotalPrice } from "./utils"
|
||||
|
||||
import styles from "./rateSummary.module.css"
|
||||
|
||||
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
export default function RateSummary({
|
||||
isUserLoggedIn,
|
||||
packages,
|
||||
roomsAvailability,
|
||||
booking,
|
||||
vat,
|
||||
}: RateSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
const { getSelectedRateSummary } = useRateSelectionStore()
|
||||
|
||||
const { rooms } = booking
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const selectedRateSummary = getSelectedRateSummary()
|
||||
const totalRoomsRequired = rooms?.length || 1
|
||||
if (selectedRateSummary.length === 0) return null
|
||||
|
||||
const petRoomPackage = packages?.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
|
||||
const totalPriceToShow = calculateTotalPrice(
|
||||
selectedRateSummary,
|
||||
isUserLoggedIn,
|
||||
petRoomPackage
|
||||
)
|
||||
const isAllRoomsSelected = selectedRateSummary.length === totalRoomsRequired
|
||||
|
||||
const checkInDate = new Date(roomsAvailability.checkInDate)
|
||||
const checkOutDate = new Date(roomsAvailability.checkOutDate)
|
||||
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
|
||||
|
||||
const hasMemberRates = selectedRateSummary.some((room) => room.member)
|
||||
|
||||
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
||||
|
||||
const summaryPriceText = `${intl.formatMessage(
|
||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
{ totalNights: nights }
|
||||
)}, ${intl.formatMessage(
|
||||
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
||||
{ totalAdults: rooms.reduce((acc, room) => acc + room.adults, 0) }
|
||||
)}${
|
||||
rooms.some((room) => room.childrenInRoom?.length)
|
||||
? `, ${intl.formatMessage(
|
||||
{ id: "{totalChildren, plural, one {# child} other {# children}}" },
|
||||
{
|
||||
totalChildren: rooms.reduce(
|
||||
(acc, room) => acc + (room.childrenInRoom?.length ?? 0),
|
||||
0
|
||||
),
|
||||
}
|
||||
)}`
|
||||
: ""
|
||||
}, ${intl.formatMessage(
|
||||
{ id: "{totalRooms, plural, one {# room} other {# rooms}}" },
|
||||
{
|
||||
totalRooms: rooms.length,
|
||||
}
|
||||
)}`
|
||||
|
||||
return (
|
||||
<div className={styles.summary} data-visible={isVisible}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryText}>
|
||||
{selectedRateSummary.map((room, index) => (
|
||||
<div key={index} className={styles.roomSummary}>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: index + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{room.roomType}</Body>
|
||||
<Caption color="uiTextMediumContrast">{`${room.priceName}, ${room.priceTerm}`}</Caption>
|
||||
</div>
|
||||
))}
|
||||
{/* Render unselected rooms */}
|
||||
{Array.from({
|
||||
length: totalRoomsRequired - selectedRateSummary.length,
|
||||
}).map((_, index) => (
|
||||
<div key={`unselected-${index}`} className={styles.roomSummary}>
|
||||
<Subtitle color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: selectedRateSummary.length + index + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Select room" })}
|
||||
</Body>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.summaryPriceContainer}>
|
||||
{showMemberDiscountBanner && (
|
||||
<div className={styles.promoContainer}>
|
||||
<SignupPromoDesktop
|
||||
memberPrice={{
|
||||
amount: selectedRateSummary.reduce((total, room) => {
|
||||
const memberPrice =
|
||||
room.member?.localPrice.pricePerStay ?? 0
|
||||
const isPetRoom = room.features.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const petRoomPrice =
|
||||
isPetRoom && petRoomPackage
|
||||
? Number(petRoomPackage.localPrice.totalPrice || 0)
|
||||
: 0
|
||||
return total + memberPrice + petRoomPrice
|
||||
}, 0),
|
||||
currency:
|
||||
selectedRateSummary[0].member?.localPrice.currency ??
|
||||
selectedRateSummary[0].public.localPrice.currency,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Body>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">{summaryPriceText}</Caption>
|
||||
</div>
|
||||
<div className={styles.summaryPrice}>
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Subtitle
|
||||
color={isUserLoggedIn ? "red" : "uiTextHighContrast"}
|
||||
textAlign="right"
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.price,
|
||||
totalPriceToShow.local.currency
|
||||
)}
|
||||
</Subtitle>
|
||||
{totalPriceToShow?.requested ? (
|
||||
<Body color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Approx. {value}" },
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.requested.price,
|
||||
totalPriceToShow.requested.currency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
intent="primary"
|
||||
theme="base"
|
||||
type="submit"
|
||||
className={styles.continueButton}
|
||||
disabled={!isAllRoomsSelected}
|
||||
>
|
||||
{intl.formatMessage({ id: "Continue" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mobileSummary}>
|
||||
{showMemberDiscountBanner ? <SignupPromoMobile /> : null}
|
||||
<MobileSummary
|
||||
totalPriceToShow={totalPriceToShow}
|
||||
isAllRoomsSelected={isAllRoomsSelected}
|
||||
booking={booking}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
vat={vat}
|
||||
roomsAvailability={roomsAvailability}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { alternativeHotels } from "@/constants/routes/hotelReservation"
|
||||
import { useRoomFilteringStore } from "@/stores/select-rate/room-filtering"
|
||||
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import RoomTypeFilter from "../RoomTypeFilter"
|
||||
import RoomTypeList from "../RoomTypeList"
|
||||
|
||||
import styles from "../Rooms/rooms.module.css"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type { FilterValues } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { RoomSelectionPanelProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
|
||||
export function RoomSelectionPanel({
|
||||
availablePackages,
|
||||
defaultPackages,
|
||||
hotelType,
|
||||
roomCategories,
|
||||
roomListIndex,
|
||||
selectedPackages,
|
||||
}: RoomSelectionPanelProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const { getRooms } = useRoomFilteringStore()
|
||||
|
||||
const rooms = getRooms(roomListIndex)
|
||||
|
||||
const initialFilterValues = useMemo(() => {
|
||||
const packagesFromSearchParams =
|
||||
searchParams.get(`room[${roomListIndex}].packages`)?.split(",") ?? []
|
||||
|
||||
return defaultPackages.reduce<FilterValues>((acc, option) => {
|
||||
acc[option.code] = packagesFromSearchParams.includes(option.code)
|
||||
return acc
|
||||
}, {})
|
||||
}, [defaultPackages, searchParams, roomListIndex])
|
||||
|
||||
return (
|
||||
<>
|
||||
{rooms?.roomConfigurations.every(
|
||||
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
|
||||
) && (
|
||||
<div className={styles.hotelAlert}>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({ id: "No availability" })}
|
||||
text={intl.formatMessage({
|
||||
id: "There are no rooms available that match your request.",
|
||||
})}
|
||||
link={{
|
||||
title: intl.formatMessage({ id: "See alternative hotels" }),
|
||||
url: `${alternativeHotels(lang)}`,
|
||||
keepSearchParams: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RoomTypeFilter
|
||||
numberOfRooms={rooms?.roomConfigurations.length ?? 0}
|
||||
filterOptions={defaultPackages}
|
||||
initialFilterValues={initialFilterValues}
|
||||
roomListIndex={roomListIndex}
|
||||
/>
|
||||
{rooms && (
|
||||
<RoomTypeList
|
||||
availablePackages={availablePackages}
|
||||
hotelType={hotelType}
|
||||
roomCategories={roomCategories}
|
||||
roomListIndex={roomListIndex}
|
||||
roomsAvailability={rooms}
|
||||
selectedPackages={selectedPackages}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
import { z } from "zod"
|
||||
|
||||
import { useRoomFilteringStore } from "@/stores/select-rate/room-filtering"
|
||||
|
||||
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import { InfoCircleIcon } from "@/components/Icons"
|
||||
import CheckboxChip from "@/components/TempDesignSystem/Form/FilterChip/Checkbox"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { Tooltip } from "@/components/TempDesignSystem/Tooltip"
|
||||
|
||||
import styles from "./roomFilter.module.css"
|
||||
|
||||
import {
|
||||
type FilterValues,
|
||||
type RoomFilterProps,
|
||||
RoomPackageCodeEnum,
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
export default function RoomFilter({
|
||||
numberOfRooms,
|
||||
filterOptions,
|
||||
initialFilterValues,
|
||||
roomListIndex,
|
||||
}: RoomFilterProps) {
|
||||
const isTabletAndUp = useMediaQuery("(min-width: 768px)")
|
||||
const [isAboveMobile, setIsAboveMobile] = useState(false)
|
||||
|
||||
const intl = useIntl()
|
||||
const methods = useForm<FilterValues>({
|
||||
defaultValues: initialFilterValues,
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(z.object({})),
|
||||
})
|
||||
|
||||
const { handleFilter } = useRoomFilteringStore()
|
||||
|
||||
const { watch, getValues } = methods
|
||||
const petFriendly = watch(RoomPackageCodeEnum.PET_ROOM)
|
||||
const allergyFriendly = watch(RoomPackageCodeEnum.ALLERGY_ROOM)
|
||||
|
||||
const selectedFilters = getValues()
|
||||
|
||||
const tooltipText = intl.formatMessage({
|
||||
id: "Pet-friendly rooms have an additional fee of 20 EUR per stay",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFilterValues) return
|
||||
|
||||
handleFilter(initialFilterValues, roomListIndex)
|
||||
}, [initialFilterValues, handleFilter, roomListIndex])
|
||||
|
||||
// Watch for filter changes
|
||||
useEffect(() => {
|
||||
const subscription = watch((value, { name }) => {
|
||||
if (name) handleFilter(getValues(), roomListIndex)
|
||||
})
|
||||
return () => subscription.unsubscribe()
|
||||
}, [watch, getValues, handleFilter, roomListIndex])
|
||||
|
||||
useEffect(() => {
|
||||
setIsAboveMobile(isTabletAndUp)
|
||||
}, [isTabletAndUp])
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.infoDesktop}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
||||
},
|
||||
{
|
||||
numberOfRooms,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.infoMobile}>
|
||||
<div className={styles.filterInfo}>
|
||||
<Tooltip
|
||||
text={tooltipText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
isTouchable
|
||||
>
|
||||
<InfoCircleIcon color="uiTextHighContrast" height={20} width={20} />
|
||||
<div className={styles.filter}>
|
||||
<Caption
|
||||
type="label"
|
||||
color="baseTextMediumContrast"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Filter" })}
|
||||
</Caption>
|
||||
<Caption type="regular" color="baseTextMediumContrast">
|
||||
{Object.entries(selectedFilters)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key]) => intl.formatMessage({ id: key }))
|
||||
.join(", ")}
|
||||
</Caption>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
||||
},
|
||||
{
|
||||
numberOfRooms,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
<FormProvider {...methods}>
|
||||
<form>
|
||||
<div className={styles.roomsFilter}>
|
||||
{filterOptions.map((option) => {
|
||||
const { code, description, itemCode } = option
|
||||
const isPetRoom = code === RoomPackageCodeEnum.PET_ROOM
|
||||
const isAllergyRoom = code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
const isDisabled =
|
||||
(isAllergyRoom && petFriendly) ||
|
||||
(isPetRoom && allergyFriendly) ||
|
||||
!itemCode
|
||||
|
||||
const checkboxChip = (
|
||||
<CheckboxChip
|
||||
name={code}
|
||||
key={code}
|
||||
label={description}
|
||||
disabled={isDisabled}
|
||||
selected={getValues(code)}
|
||||
Icon={getIconForFeatureCode(code)}
|
||||
hasTooltip={isPetRoom && isAboveMobile}
|
||||
/>
|
||||
)
|
||||
|
||||
return isPetRoom && isAboveMobile ? (
|
||||
<Tooltip
|
||||
key={option.code}
|
||||
text={tooltipText}
|
||||
position="bottom"
|
||||
arrow="right"
|
||||
isTouchable
|
||||
>
|
||||
{checkboxChip}
|
||||
</Tooltip>
|
||||
) : (
|
||||
checkboxChip
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.roomsFilter {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
margin-left: var(--Spacing-x-half);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.filterInfo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x-half);
|
||||
flex-wrap: wrap;
|
||||
margin-right: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.infoDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.infoMobile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.infoDesktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.infoMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { createElement, useCallback, useEffect, useMemo } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
||||
|
||||
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
|
||||
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
import FlexibilityOption from "../FlexibilityOption"
|
||||
import { cardVariants } from "./cardVariants"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { HotelTypeEnum } from "@/types/enums/hotelType"
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
function getBreakfastMessage(
|
||||
publicBreakfastIncluded: boolean,
|
||||
memberBreakfastIncluded: boolean,
|
||||
hotelType: string | undefined,
|
||||
userIsLoggedIn: boolean,
|
||||
msgs: Record<"included" | "noSelection" | "scandicgo" | "notIncluded", string>
|
||||
) {
|
||||
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||
return msgs.scandicgo
|
||||
}
|
||||
|
||||
if (userIsLoggedIn && memberBreakfastIncluded) {
|
||||
return msgs.included
|
||||
}
|
||||
|
||||
if (publicBreakfastIncluded && memberBreakfastIncluded) {
|
||||
return msgs.included
|
||||
}
|
||||
|
||||
/** selected and rate does not include breakfast */
|
||||
if (false) {
|
||||
return msgs.notIncluded
|
||||
}
|
||||
|
||||
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
|
||||
return msgs.notIncluded
|
||||
}
|
||||
|
||||
return msgs.noSelection
|
||||
}
|
||||
|
||||
export default function RoomCard({
|
||||
hotelId,
|
||||
hotelType,
|
||||
rateDefinitions,
|
||||
roomConfiguration,
|
||||
roomCategories,
|
||||
selectedPackages,
|
||||
packages,
|
||||
roomListIndex,
|
||||
}: RoomCardProps) {
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = !!session
|
||||
const intl = useIntl()
|
||||
const searchParams = useSearchParams()
|
||||
const { selectRate, selectedRates, closeModifyRate } = useRateSelectionStore(
|
||||
(state) => ({
|
||||
selectRate: state.selectRate,
|
||||
selectedRates: state.selectedRates,
|
||||
closeModifyRate: state.closeModifyRate,
|
||||
})
|
||||
)
|
||||
|
||||
const selectedRate = useRateSelectionStore(
|
||||
(state) => state.selectedRates[roomListIndex]
|
||||
)
|
||||
|
||||
const classNames = cardVariants({
|
||||
availability:
|
||||
roomConfiguration.status === AvailabilityEnum.NotAvailable
|
||||
? "noAvailability"
|
||||
: "default",
|
||||
})
|
||||
|
||||
const breakfastMessages = {
|
||||
included: intl.formatMessage({ id: "Breakfast is included." }),
|
||||
notIncluded: intl.formatMessage({
|
||||
id: "Breakfast selection in next step.",
|
||||
}),
|
||||
noSelection: intl.formatMessage({ id: "Select a rate" }),
|
||||
scandicgo: intl.formatMessage({
|
||||
id: "Breakfast deal can be purchased at the hotel.",
|
||||
}),
|
||||
}
|
||||
const breakfastMessage = getBreakfastMessage(
|
||||
roomConfiguration.breakfastIncludedInAllRatesPublic,
|
||||
roomConfiguration.breakfastIncludedInAllRatesMember,
|
||||
hotelType,
|
||||
isUserLoggedIn,
|
||||
breakfastMessages
|
||||
)
|
||||
|
||||
const rates = useMemo(
|
||||
() => ({
|
||||
change: rateDefinitions.filter(
|
||||
(rate) => rate.cancellationRule === "Changeable"
|
||||
),
|
||||
flex: rateDefinitions.filter(
|
||||
(rate) => rate.cancellationRule === "CancellableBefore6PM"
|
||||
),
|
||||
save: rateDefinitions.filter(
|
||||
(rate) => rate.cancellationRule === "NotCancellable"
|
||||
),
|
||||
}),
|
||||
[rateDefinitions]
|
||||
)
|
||||
|
||||
const petRoomPackage =
|
||||
(selectedPackages?.includes(RoomPackageCodeEnum.PET_ROOM) &&
|
||||
packages?.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
|
||||
undefined
|
||||
|
||||
const selectedRoom = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.find(
|
||||
(roomType) => roomType.code === roomConfiguration.roomTypeCode
|
||||
)
|
||||
)
|
||||
|
||||
const { name, roomSize, totalOccupancy, images } = selectedRoom || {}
|
||||
const galleryImages = mapApiImagesToGalleryImages(images || [])
|
||||
|
||||
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
||||
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
|
||||
const payLater = intl.formatMessage({ id: "Pay later" })
|
||||
const payNow = intl.formatMessage({ id: "Pay now" })
|
||||
|
||||
function handleRateSelection(
|
||||
rateCode: string,
|
||||
rateName: string,
|
||||
paymentTerm: string
|
||||
) {
|
||||
if (
|
||||
selectedRates[roomListIndex]?.publicRateCode === rateCode &&
|
||||
selectedRates[roomListIndex]?.roomTypeCode ===
|
||||
roomConfiguration.roomTypeCode
|
||||
) {
|
||||
selectRate(roomListIndex, undefined)
|
||||
} else {
|
||||
selectRate(roomListIndex, {
|
||||
publicRateCode: rateCode,
|
||||
roomTypeCode: roomConfiguration.roomTypeCode,
|
||||
name: rateName,
|
||||
paymentTerm: paymentTerm,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getRate = useCallback(
|
||||
(rateCode: string) => {
|
||||
switch (rateCode) {
|
||||
case "change":
|
||||
return {
|
||||
isFlex: false,
|
||||
notAvailable: false,
|
||||
title: freeBooking,
|
||||
}
|
||||
case "flex":
|
||||
return {
|
||||
isFlex: true,
|
||||
notAvailable: false,
|
||||
title: freeCancelation,
|
||||
}
|
||||
case "save":
|
||||
return {
|
||||
isFlex: false,
|
||||
notAvailable: false,
|
||||
title: nonRefundable,
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown key for rate, should be "change", "flex" or "save", but got ${rateCode}`
|
||||
)
|
||||
}
|
||||
},
|
||||
[freeBooking, freeCancelation, nonRefundable]
|
||||
)
|
||||
|
||||
const getRateInfo = useCallback(
|
||||
(product: Product) => {
|
||||
if (
|
||||
!product.productType.public.rateCode &&
|
||||
!product.productType.member?.rateCode
|
||||
) {
|
||||
const possibleRate = getRate(product.productType.public.rate)
|
||||
if (possibleRate) {
|
||||
return {
|
||||
...possibleRate,
|
||||
notAvailable: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
isFlex: false,
|
||||
notAvailable: true,
|
||||
title: "",
|
||||
}
|
||||
}
|
||||
const publicRate = Object.keys(rates).find((k) =>
|
||||
rates[k as keyof typeof rates].find(
|
||||
(a) => a.rateCode === product.productType.public.rateCode
|
||||
)
|
||||
)
|
||||
let memberRate
|
||||
if (product.productType.member) {
|
||||
memberRate = Object.keys(rates).find((k) =>
|
||||
rates[k as keyof typeof rates].find(
|
||||
(a) => a.rateCode === product.productType.member!.rateCode
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!publicRate || !memberRate) {
|
||||
throw new Error("We should never make it here without rateCodes")
|
||||
}
|
||||
|
||||
const key = isUserLoggedIn ? memberRate : publicRate
|
||||
return getRate(key)
|
||||
},
|
||||
[getRate, isUserLoggedIn, rates]
|
||||
)
|
||||
|
||||
// Handle URL-based preselection
|
||||
useEffect(() => {
|
||||
const ratecodeSearchParam = searchParams.get(
|
||||
`room[${roomListIndex}].ratecode`
|
||||
)
|
||||
const roomtypeSearchParam = searchParams.get(
|
||||
`room[${roomListIndex}].roomtype`
|
||||
)
|
||||
|
||||
if (!ratecodeSearchParam || !roomtypeSearchParam) return
|
||||
|
||||
// Check if there's already a selection for this room index
|
||||
const existingSelection = selectedRates[roomListIndex]
|
||||
if (existingSelection) return
|
||||
|
||||
const matchingRate = roomConfiguration.products.find(
|
||||
(product) =>
|
||||
product.productType.public.rateCode === ratecodeSearchParam &&
|
||||
roomConfiguration.roomTypeCode === roomtypeSearchParam
|
||||
)
|
||||
|
||||
if (matchingRate) {
|
||||
const rateInfo = getRateInfo(matchingRate)
|
||||
selectRate(roomListIndex, {
|
||||
publicRateCode: matchingRate.productType.public.rateCode,
|
||||
roomTypeCode: roomConfiguration.roomTypeCode,
|
||||
name: rateInfo.title,
|
||||
paymentTerm: rateInfo.isFlex ? payLater : payNow,
|
||||
})
|
||||
}
|
||||
}, [
|
||||
searchParams,
|
||||
roomListIndex,
|
||||
rates,
|
||||
roomConfiguration.products,
|
||||
roomConfiguration.roomTypeCode,
|
||||
payLater,
|
||||
payNow,
|
||||
selectRate,
|
||||
selectedRates,
|
||||
getRateInfo,
|
||||
])
|
||||
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<div>
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.chipContainer}>
|
||||
{roomConfiguration.roomsLeft > 0 &&
|
||||
roomConfiguration.roomsLeft < 5 && (
|
||||
<span className={styles.chip}>
|
||||
<Footnote color="burgundy" textTransform="uppercase">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount, number} left" },
|
||||
{ amount: roomConfiguration.roomsLeft }
|
||||
)}
|
||||
</Footnote>
|
||||
</span>
|
||||
)}
|
||||
{roomConfiguration.features
|
||||
.filter((feature) => selectedPackages?.includes(feature.code))
|
||||
.map((feature) => (
|
||||
<span className={styles.chip} key={feature.code}>
|
||||
{createElement(getIconForFeatureCode(feature.code), {
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: "burgundy",
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ImageGallery
|
||||
images={galleryImages}
|
||||
title={roomConfiguration.roomType}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.specification}>
|
||||
{totalOccupancy && (
|
||||
<Caption color="uiTextMediumContrast" className={styles.guests}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Max. {max, plural, one {{range} guest} other {{range} guests}}",
|
||||
},
|
||||
{
|
||||
max: totalOccupancy.max,
|
||||
range: totalOccupancy.range,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
{roomSize && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{roomSize.min === roomSize.max
|
||||
? intl.formatMessage(
|
||||
{ id: "{roomSize} m²" },
|
||||
{
|
||||
roomSize: roomSize.min,
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{
|
||||
id: "{roomSizeMin}–{roomSizeMax} m²",
|
||||
},
|
||||
{
|
||||
roomSizeMin: roomSize.min,
|
||||
roomSizeMax: roomSize.max,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
<div className={styles.toggleSidePeek}>
|
||||
{roomConfiguration.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<Subtitle className={styles.name} type="two">
|
||||
{name}
|
||||
</Subtitle>
|
||||
{/* Out of scope for now
|
||||
<Body>{descriptions?.short}</Body>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.container}>
|
||||
{roomConfiguration.status === AvailabilityEnum.Available ? (
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{breakfastMessage}
|
||||
</Caption>
|
||||
) : null}
|
||||
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
|
||||
<div className={styles.noRoomsContainer}>
|
||||
<div className={styles.noRooms}>
|
||||
<ErrorCircleIcon color="red" width={16} />
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({
|
||||
id: "This room is not available",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
roomConfiguration.products.map((product) => {
|
||||
const rate = getRateInfo(product)
|
||||
return (
|
||||
<FlexibilityOption
|
||||
key={product.productType.public.rateCode}
|
||||
handleSelect={(rateCode, rateName, paymentTerm) => {
|
||||
handleRateSelection(rateCode, rateName, paymentTerm)
|
||||
closeModifyRate()
|
||||
}}
|
||||
isSelected={
|
||||
selectedRate?.publicRateCode ===
|
||||
product.productType.public.rateCode &&
|
||||
selectedRate?.roomTypeCode === roomConfiguration.roomTypeCode
|
||||
}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
paymentTerm={rate.isFlex ? payLater : payNow}
|
||||
petRoomPackage={petRoomPackage}
|
||||
product={rate?.notAvailable ? undefined : product}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
title={rate.title}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import RoomCard from "./RoomCard"
|
||||
|
||||
import styles from "./roomSelection.module.css"
|
||||
|
||||
import type { RoomTypeListProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
|
||||
|
||||
export default function RoomTypeList({
|
||||
availablePackages,
|
||||
hotelType,
|
||||
roomCategories,
|
||||
roomListIndex,
|
||||
roomsAvailability,
|
||||
selectedPackages,
|
||||
}: RoomTypeListProps) {
|
||||
const { roomConfigurations, rateDefinitions } = roomsAvailability
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<ul className={styles.roomList}>
|
||||
{roomConfigurations.map((roomConfiguration) => (
|
||||
<RoomCard
|
||||
key={roomConfiguration.roomTypeCode}
|
||||
hotelId={roomsAvailability.hotelId.toString()}
|
||||
hotelType={hotelType}
|
||||
packages={availablePackages}
|
||||
rateDefinitions={rateDefinitions}
|
||||
roomCategories={roomCategories}
|
||||
roomConfiguration={roomConfiguration}
|
||||
roomListIndex={roomListIndex}
|
||||
selectedPackages={selectedPackages}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getHotel,
|
||||
getPackages,
|
||||
getRoomsAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { safeTry } from "@/utils/safeTry"
|
||||
import { isValidSession } from "@/utils/session"
|
||||
|
||||
import { generateChildrenString } from "../../utils"
|
||||
import { combineRoomAvailabilities } from "../utils"
|
||||
import Rooms from "."
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer"
|
||||
|
||||
export async function RoomsContainer({
|
||||
adultArray,
|
||||
childArray,
|
||||
fromDate,
|
||||
hotelId,
|
||||
lang,
|
||||
toDate,
|
||||
}: RoomsContainerProps) {
|
||||
const session = await auth()
|
||||
const isUserLoggedIn = isValidSession(session)
|
||||
|
||||
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
|
||||
const toDateString = dt(toDate).format("YYYY-MM-DD")
|
||||
|
||||
const hotelDataPromise = safeTry(
|
||||
getHotel({
|
||||
hotelId: hotelId.toString(),
|
||||
isCardOnlyPayment: false,
|
||||
language: lang,
|
||||
})
|
||||
)
|
||||
|
||||
const packagesPromise = safeTry(
|
||||
getPackages({
|
||||
hotelId: hotelId.toString(),
|
||||
startDate: fromDateString,
|
||||
endDate: toDateString,
|
||||
adults: adultArray[0],
|
||||
children: childArray ? childArray.length : undefined,
|
||||
packageCodes: [
|
||||
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
RoomPackageCodeEnum.PET_ROOM,
|
||||
RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const uniqueAdultCounts = [...new Set(adultArray)]
|
||||
const roomsAvailabilityPromises = uniqueAdultCounts.map((adultCount) => {
|
||||
return safeTry(
|
||||
getRoomsAvailability({
|
||||
hotelId: hotelId,
|
||||
roomStayStartDate: fromDateString,
|
||||
roomStayEndDate: toDateString,
|
||||
adults: adultCount,
|
||||
children:
|
||||
childArray && childArray.length > 0
|
||||
? generateChildrenString(childArray)
|
||||
: undefined,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const [hotelData, hotelDataError] = await hotelDataPromise
|
||||
const [packages, packagesError] = await packagesPromise
|
||||
const roomsAvailabilityResults = await Promise.all(roomsAvailabilityPromises)
|
||||
|
||||
const roomsAvailability = combineRoomAvailabilities({
|
||||
availabilityResults: roomsAvailabilityResults,
|
||||
})
|
||||
|
||||
const intl = await getIntl(lang)
|
||||
|
||||
if (packagesError) {
|
||||
// TODO: Log packages error
|
||||
console.error("[RoomsContainer] unable to fetch packages")
|
||||
}
|
||||
|
||||
if (!hotelData) {
|
||||
// TODO: Log hotel data error
|
||||
console.error("[RoomsContainer] unable to fetch hotel data")
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Rooms
|
||||
availablePackages={packages ?? []}
|
||||
hotelType={hotelData?.hotel.hotelType}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
roomsAvailability={roomsAvailability}
|
||||
roomCategories={hotelData?.roomCategories ?? []}
|
||||
vat={hotelData.hotel.vat}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useMemo, useTransition } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
||||
import { useRoomFilteringStore } from "@/stores/select-rate/room-filtering"
|
||||
|
||||
import { ChevronDownSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { trackLowestRoomPrice } from "@/utils/tracking"
|
||||
import { convertObjToSearchParams, convertSearchParamsToObj } from "@/utils/url"
|
||||
|
||||
import RateSummary from "../RateSummary"
|
||||
import { RoomSelectionPanel } from "../RoomSelectionPanel"
|
||||
import SelectedRoomPanel from "../SelectedRoomPanel"
|
||||
import { roomSelectionPanelVariants } from "./variants"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import {
|
||||
type DefaultFilterOptions,
|
||||
RoomPackageCodeEnum,
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { SelectRateProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
|
||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
|
||||
export default function Rooms({
|
||||
availablePackages,
|
||||
hotelType,
|
||||
isUserLoggedIn,
|
||||
roomsAvailability,
|
||||
roomCategories = [],
|
||||
vat,
|
||||
}: SelectRateProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const intl = useIntl()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const hotelId = searchParams.get("hotel")
|
||||
const arrivalDate = searchParams.get("fromDate")
|
||||
const departureDate = searchParams.get("toDate")
|
||||
|
||||
const {
|
||||
selectedRates,
|
||||
rateSummary,
|
||||
calculateRateSummary,
|
||||
initializeRates,
|
||||
setGuestsInRooms,
|
||||
modifyRateIndex,
|
||||
closeModifyRate,
|
||||
} = useRateSelectionStore()
|
||||
|
||||
const {
|
||||
selectedPackagesByRoom,
|
||||
visibleRooms,
|
||||
setVisibleRooms,
|
||||
setRoomsAvailability,
|
||||
getFilteredRooms,
|
||||
} = useRoomFilteringStore()
|
||||
|
||||
const bookingWidgetSearchData = useMemo(
|
||||
() =>
|
||||
convertSearchParamsToObj<SelectRateSearchParams>(
|
||||
Object.fromEntries(searchParams)
|
||||
),
|
||||
[searchParams]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
bookingWidgetSearchData.rooms.forEach((room, index) => {
|
||||
setGuestsInRooms(index, room.adults, room.childrenInRoom)
|
||||
})
|
||||
}, [bookingWidgetSearchData.rooms, setGuestsInRooms])
|
||||
|
||||
const isMultipleRooms = bookingWidgetSearchData.rooms.length > 1
|
||||
|
||||
useEffect(() => {
|
||||
initializeRates(bookingWidgetSearchData.rooms.length)
|
||||
}, [initializeRates, bookingWidgetSearchData.rooms.length])
|
||||
|
||||
const defaultPackages: DefaultFilterOptions[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
description: intl.formatMessage({ id: "Accessible room" }),
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
{
|
||||
code: RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
description: intl.formatMessage({ id: "Allergy-friendly room" }),
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
{
|
||||
code: RoomPackageCodeEnum.PET_ROOM,
|
||||
description: intl.formatMessage({ id: "Pet room" }),
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
],
|
||||
[availablePackages, intl]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (roomsAvailability) {
|
||||
setRoomsAvailability(roomsAvailability)
|
||||
}
|
||||
setVisibleRooms()
|
||||
}, [roomsAvailability, setRoomsAvailability, setVisibleRooms])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedRates.length > 0 &&
|
||||
selectedRates.some((rate) => rate !== undefined)
|
||||
) {
|
||||
calculateRateSummary({
|
||||
getFilteredRooms,
|
||||
availablePackages,
|
||||
roomCategories,
|
||||
selectedPackagesByRoom,
|
||||
})
|
||||
}
|
||||
}, [
|
||||
selectedRates,
|
||||
getFilteredRooms,
|
||||
availablePackages,
|
||||
roomCategories,
|
||||
selectedPackagesByRoom,
|
||||
calculateRateSummary,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const pricesWithCurrencies = visibleRooms.flatMap((room) =>
|
||||
room.products.map((product) => ({
|
||||
price: product.productType.public.localPrice.pricePerNight,
|
||||
currency: product.productType.public.localPrice.currency,
|
||||
}))
|
||||
)
|
||||
const lowestPrice = pricesWithCurrencies.reduce(
|
||||
(minPrice, { price }) => Math.min(minPrice, price),
|
||||
Infinity
|
||||
)
|
||||
|
||||
const currency = pricesWithCurrencies[0]?.currency
|
||||
|
||||
trackLowestRoomPrice({
|
||||
hotelId,
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
lowestPrice: lowestPrice,
|
||||
currency: currency,
|
||||
})
|
||||
}, [arrivalDate, departureDate, hotelId, visibleRooms])
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
startTransition(() => {
|
||||
const rooms = rateSummary.map((rate, index) => ({
|
||||
roomTypeCode: rate?.roomTypeCode,
|
||||
rateCode: rate?.public.rateCode,
|
||||
counterRateCode: rate?.member?.rateCode,
|
||||
packages: selectedPackagesByRoom[index] || [],
|
||||
}))
|
||||
|
||||
const newSearchParams = convertObjToSearchParams({ rooms }, searchParams)
|
||||
router.push(`details?${newSearchParams}`)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const SCROLL_OFFSET = 100
|
||||
const roomElements = document.querySelectorAll(`.${styles.roomContainer}`)
|
||||
|
||||
let targetIndex: number
|
||||
if (modifyRateIndex !== null) {
|
||||
targetIndex = modifyRateIndex
|
||||
} else {
|
||||
const index = selectedRates.findIndex((rate) => rate === undefined)
|
||||
targetIndex = index === -1 ? selectedRates.length - 1 : index - 1
|
||||
}
|
||||
|
||||
const selectedRoom = roomElements[targetIndex]
|
||||
if (selectedRoom) {
|
||||
const elementPosition = selectedRoom.getBoundingClientRect().top
|
||||
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [selectedRates, modifyRateIndex])
|
||||
|
||||
const getRoomState = useCallback(
|
||||
(index: number) => {
|
||||
const isFirstRoom = index === 0
|
||||
const hasPrevRoomBeenSelected = selectedRates[index - 1] !== undefined
|
||||
const isCurrentRoomSelected = selectedRates[index] !== undefined
|
||||
const isModifyRoom = modifyRateIndex === index
|
||||
|
||||
if (isModifyRoom && isCurrentRoomSelected) {
|
||||
return { active: true, selected: false }
|
||||
}
|
||||
|
||||
if (isCurrentRoomSelected) {
|
||||
return { active: false, selected: true }
|
||||
}
|
||||
|
||||
if (
|
||||
(isFirstRoom || hasPrevRoomBeenSelected) &&
|
||||
modifyRateIndex === null
|
||||
) {
|
||||
return { active: true, selected: false }
|
||||
}
|
||||
|
||||
return { active: false, selected: false }
|
||||
},
|
||||
[modifyRateIndex, selectedRates]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{isMultipleRooms ? (
|
||||
bookingWidgetSearchData.rooms.map((room, index) => {
|
||||
const roomState = getRoomState(index)
|
||||
const classNames = roomSelectionPanelVariants(roomState)
|
||||
|
||||
return (
|
||||
<div key={index} className={styles.roomContainer}>
|
||||
{!roomState.selected && (
|
||||
<header className={styles.header}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: index + 1 }
|
||||
)}
|
||||
,{" "}
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: room.childrenInRoom?.length
|
||||
? "{adults} adults, {children} children"
|
||||
: "{adults} adults",
|
||||
},
|
||||
{
|
||||
adults: room.adults,
|
||||
children: room.childrenInRoom?.length,
|
||||
}
|
||||
)}
|
||||
</Subtitle>
|
||||
{modifyRateIndex === index ? (
|
||||
<Button
|
||||
variant="icon"
|
||||
size="medium"
|
||||
intent="text"
|
||||
theme="base"
|
||||
onClick={closeModifyRate}
|
||||
>
|
||||
{intl.formatMessage({ id: "Close" })}
|
||||
<ChevronDownSmallIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className={classNames}>
|
||||
<div className={styles.roomPanel}>
|
||||
<SelectedRoomPanel
|
||||
roomIndex={index}
|
||||
room={room}
|
||||
roomCategories={roomCategories}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.roomSelectionPanel}>
|
||||
<RoomSelectionPanel
|
||||
availablePackages={availablePackages}
|
||||
defaultPackages={defaultPackages}
|
||||
hotelType={hotelType}
|
||||
roomCategories={roomCategories}
|
||||
roomListIndex={index}
|
||||
selectedPackages={selectedPackagesByRoom[index]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<RoomSelectionPanel
|
||||
availablePackages={availablePackages}
|
||||
defaultPackages={defaultPackages}
|
||||
hotelType={hotelType}
|
||||
roomCategories={roomCategories}
|
||||
roomListIndex={0}
|
||||
selectedPackages={selectedPackagesByRoom[0]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rateSummary && roomsAvailability && (
|
||||
<form
|
||||
method="GET"
|
||||
action={`details?${searchParams}`}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<RateSummary
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
packages={availablePackages}
|
||||
roomsAvailability={roomsAvailability}
|
||||
booking={bookingWidgetSearchData}
|
||||
vat={vat}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
/**
|
||||
* Get the lowest priced room for each room type that appears more than once.
|
||||
*/
|
||||
|
||||
export function filterDuplicateRoomTypesByLowestPrice(
|
||||
roomConfigurations: RoomConfiguration[]
|
||||
): RoomConfiguration[] {
|
||||
const roomTypeCount = roomConfigurations.reduce<Record<string, number>>(
|
||||
(roomTypeTally, currentRoom) => {
|
||||
const currentRoomType = currentRoom.roomType
|
||||
const currentCount = roomTypeTally[currentRoomType] || 0
|
||||
|
||||
return {
|
||||
...roomTypeTally,
|
||||
[currentRoomType]: currentCount + 1,
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const duplicateRoomTypes = new Set(
|
||||
Object.keys(roomTypeCount).filter((roomType) => roomTypeCount[roomType] > 1)
|
||||
)
|
||||
|
||||
const roomMap = new Map()
|
||||
|
||||
roomConfigurations.forEach((room) => {
|
||||
const { roomType, products, status } = room
|
||||
|
||||
if (!duplicateRoomTypes.has(roomType)) {
|
||||
roomMap.set(roomType, room)
|
||||
return
|
||||
}
|
||||
|
||||
const previousRoom = roomMap.get(roomType)
|
||||
|
||||
// Prioritize 'Available' status
|
||||
if (
|
||||
status === AvailabilityEnum.Available &&
|
||||
previousRoom?.status === AvailabilityEnum.NotAvailable
|
||||
) {
|
||||
roomMap.set(roomType, room)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
status === AvailabilityEnum.NotAvailable &&
|
||||
previousRoom?.status === AvailabilityEnum.Available
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (previousRoom) {
|
||||
products.forEach((product) => {
|
||||
const { productType } = product
|
||||
const publicProduct = productType.public || {
|
||||
requestedPrice: null,
|
||||
localPrice: null,
|
||||
}
|
||||
const memberProduct = productType.member || {
|
||||
requestedPrice: null,
|
||||
localPrice: null,
|
||||
}
|
||||
|
||||
const {
|
||||
requestedPrice: publicRequestedPrice,
|
||||
localPrice: publicLocalPrice,
|
||||
} = publicProduct
|
||||
const {
|
||||
requestedPrice: memberRequestedPrice,
|
||||
localPrice: memberLocalPrice,
|
||||
} = memberProduct
|
||||
|
||||
const previousLowest = roomMap.get(roomType)
|
||||
|
||||
const currentRequestedPrice = Math.min(
|
||||
Number(publicRequestedPrice?.pricePerNight) ?? Infinity,
|
||||
Number(memberRequestedPrice?.pricePerNight) ?? Infinity
|
||||
)
|
||||
const currentLocalPrice = Math.min(
|
||||
Number(publicLocalPrice?.pricePerNight) ?? Infinity,
|
||||
Number(memberLocalPrice?.pricePerNight) ?? Infinity
|
||||
)
|
||||
|
||||
if (
|
||||
!previousLowest ||
|
||||
currentRequestedPrice <
|
||||
Math.min(
|
||||
Number(
|
||||
previousLowest.products[0].productType.public.requestedPrice
|
||||
?.pricePerNight
|
||||
) ?? Infinity,
|
||||
Number(
|
||||
previousLowest.products[0].productType.member?.requestedPrice
|
||||
?.pricePerNight
|
||||
) ?? Infinity
|
||||
) ||
|
||||
(currentRequestedPrice ===
|
||||
Math.min(
|
||||
Number(
|
||||
previousLowest.products[0].productType.public.requestedPrice
|
||||
?.pricePerNight
|
||||
) ?? Infinity,
|
||||
Number(
|
||||
previousLowest.products[0].productType.member?.requestedPrice
|
||||
?.pricePerNight
|
||||
) ?? Infinity
|
||||
) &&
|
||||
currentLocalPrice <
|
||||
Math.min(
|
||||
Number(
|
||||
previousLowest.products[0].productType.public.localPrice
|
||||
?.pricePerNight
|
||||
) ?? Infinity,
|
||||
Number(
|
||||
previousLowest.products[0].productType.member?.localPrice
|
||||
?.pricePerNight
|
||||
) ?? Infinity
|
||||
))
|
||||
) {
|
||||
roomMap.set(roomType, room)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
roomMap.set(roomType, room)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(roomMap.values())
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { Fragment } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
@@ -34,7 +33,6 @@ export default function Summary({
|
||||
isMember,
|
||||
vat,
|
||||
toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen,
|
||||
}: SelectRateSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
@@ -46,18 +44,6 @@ export default function Summary({
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
function handleToggleSummary() {
|
||||
if (toggleSummaryOpen) {
|
||||
toggleSummaryOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePriceDetailsModal() {
|
||||
if (togglePriceDetailsModalOpen) {
|
||||
togglePriceDetailsModalOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
return roomRate?.memberRate
|
||||
? {
|
||||
@@ -85,7 +71,7 @@ export default function Summary({
|
||||
intent="text"
|
||||
size="small"
|
||||
className={styles.chevronButton}
|
||||
onClick={handleToggleSummary}
|
||||
onClick={toggleSummaryOpen}
|
||||
>
|
||||
<ChevronDownSmallIcon height="20" width="20" />
|
||||
</Button>
|
||||
@@ -135,7 +121,7 @@ export default function Summary({
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<Fragment key={idx}>
|
||||
<div
|
||||
className={styles.addOns}
|
||||
data-testid={`summary-room-${roomNumber}`}
|
||||
@@ -237,7 +223,7 @@ export default function Summary({
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
<div className={styles.total}>
|
||||
@@ -260,7 +246,6 @@ export default function Summary({
|
||||
}))}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleModal={handleTogglePriceDetailsModal}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -15,60 +16,26 @@ import styles from "./mobileSummary.module.css"
|
||||
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
|
||||
export default function MobileSummary({
|
||||
totalPriceToShow,
|
||||
isAllRoomsSelected,
|
||||
booking,
|
||||
isUserLoggedIn,
|
||||
vat,
|
||||
roomsAvailability,
|
||||
totalPriceToShow,
|
||||
}: MobileSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const scrollY = useRef(0)
|
||||
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
|
||||
|
||||
const {
|
||||
guestsInRooms,
|
||||
isSummaryOpen,
|
||||
getSelectedRateSummary,
|
||||
toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen,
|
||||
} = useRateSelectionStore()
|
||||
const { booking, bookingRooms, rateDefinitions, rateSummary, vat } =
|
||||
useRatesStore((state) => ({
|
||||
booking: state.booking,
|
||||
bookingRooms: state.booking.rooms,
|
||||
rateDefinitions: state.roomsAvailability?.rateDefinitions,
|
||||
rateSummary: state.rateSummary,
|
||||
vat: state.vat,
|
||||
}))
|
||||
|
||||
const selectedRateSummary = getSelectedRateSummary()
|
||||
|
||||
const rooms = selectedRateSummary.map((room, index) => ({
|
||||
adults: guestsInRooms[index].adults,
|
||||
childrenInRoom: guestsInRooms[index].children,
|
||||
roomType: room.roomType,
|
||||
roomPrice: {
|
||||
perNight: {
|
||||
local: {
|
||||
price: room.public.localPrice.pricePerNight,
|
||||
currency: room.public.localPrice.currency,
|
||||
},
|
||||
requested: undefined,
|
||||
},
|
||||
perStay: {
|
||||
local: {
|
||||
price: room.public.localPrice.pricePerStay,
|
||||
currency: room.public.localPrice.currency,
|
||||
},
|
||||
requested: undefined,
|
||||
},
|
||||
currency: room.public.localPrice.currency,
|
||||
},
|
||||
roomRate: {
|
||||
...room.public,
|
||||
memberRate: room.member,
|
||||
publicRate: room.public,
|
||||
},
|
||||
rateDetails: roomsAvailability.rateDefinitions.find(
|
||||
(rate) => rate.rateCode === room.public.rateCode
|
||||
)?.generalTerms,
|
||||
cancellationText:
|
||||
roomsAvailability.rateDefinitions.find(
|
||||
(rate) => rate.rateCode === room.public.rateCode
|
||||
)?.cancellationText ?? "",
|
||||
}))
|
||||
function toggleSummaryOpen() {
|
||||
setIsSummaryOpen(!isSummaryOpen)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSummaryOpen) {
|
||||
@@ -93,6 +60,44 @@ export default function MobileSummary({
|
||||
}
|
||||
}, [isSummaryOpen])
|
||||
|
||||
if (!rateDefinitions) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rooms = rateSummary.map((room, index) => ({
|
||||
adults: bookingRooms[index].adults,
|
||||
childrenInRoom: bookingRooms[index].childrenInRoom ?? undefined,
|
||||
roomType: room.roomType,
|
||||
roomPrice: {
|
||||
perNight: {
|
||||
local: {
|
||||
price: room.public.localPrice.pricePerNight,
|
||||
currency: room.public.localPrice.currency,
|
||||
},
|
||||
requested: undefined,
|
||||
},
|
||||
perStay: {
|
||||
local: {
|
||||
price: room.public.localPrice.pricePerStay,
|
||||
currency: room.public.localPrice.currency,
|
||||
},
|
||||
requested: undefined,
|
||||
},
|
||||
currency: room.public.localPrice.currency,
|
||||
},
|
||||
roomRate: {
|
||||
...room.public,
|
||||
memberRate: room.member,
|
||||
publicRate: room.public,
|
||||
},
|
||||
rateDetails: rateDefinitions.find(
|
||||
(rate) => rate.rateCode === room.public.rateCode
|
||||
)?.generalTerms,
|
||||
cancellationText:
|
||||
rateDefinitions.find((rate) => rate.rateCode === room.public.rateCode)
|
||||
?.cancellationText ?? "",
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>
|
||||
@@ -104,7 +109,6 @@ export default function MobileSummary({
|
||||
totalPrice={totalPriceToShow}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { getRates } from "@/components/HotelReservation/SelectRate/utils"
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import MobileSummary from "./MobileSummary"
|
||||
import { calculateTotalPrice } from "./utils"
|
||||
|
||||
import styles from "./rateSummary.module.css"
|
||||
|
||||
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
const {
|
||||
bookingRooms,
|
||||
petRoomPackage,
|
||||
rateSummary,
|
||||
roomsAvailability,
|
||||
searchParams,
|
||||
} = useRatesStore((state) => ({
|
||||
bookingRooms: state.booking.rooms,
|
||||
petRoomPackage: state.petRoomPackage,
|
||||
rateSummary: state.rateSummary,
|
||||
roomsAvailability: state.roomsAvailability,
|
||||
searchParams: state.searchParams,
|
||||
}))
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const params = new URLSearchParams(searchParams)
|
||||
const [_, startTransition] = useTransition()
|
||||
|
||||
if (!roomsAvailability) {
|
||||
return null
|
||||
}
|
||||
|
||||
const checkInDate = new Date(roomsAvailability.checkInDate)
|
||||
const checkOutDate = new Date(roomsAvailability.checkOutDate)
|
||||
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
|
||||
|
||||
const totalNights = intl.formatMessage(
|
||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
{ totalNights: nights }
|
||||
)
|
||||
const totalAdults = intl.formatMessage(
|
||||
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
||||
{ totalAdults: bookingRooms.reduce((acc, room) => acc + room.adults, 0) }
|
||||
)
|
||||
const childrenInOneOrMoreRooms = bookingRooms.some(
|
||||
(room) => room.childrenInRoom?.length
|
||||
)
|
||||
const childrenInroom = intl.formatMessage(
|
||||
{ id: "{totalChildren, plural, one {# child} other {# children}}" },
|
||||
{
|
||||
totalChildren: bookingRooms.reduce(
|
||||
(acc, room) => acc + (room.childrenInRoom?.length ?? 0),
|
||||
0
|
||||
),
|
||||
}
|
||||
)
|
||||
const totalChildren = childrenInOneOrMoreRooms ? `, ${childrenInroom}` : ""
|
||||
const totalRooms = intl.formatMessage(
|
||||
{ id: "{totalRooms, plural, one {# room} other {# rooms}}" },
|
||||
{ totalRooms: bookingRooms.length }
|
||||
)
|
||||
|
||||
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
|
||||
|
||||
const totalRoomsRequired = bookingRooms.length
|
||||
const isAllRoomsSelected = rateSummary.length === totalRoomsRequired
|
||||
const hasMemberRates = rateSummary.some((room) => room.member)
|
||||
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
||||
|
||||
const rates = getRates(roomsAvailability.rateDefinitions)
|
||||
|
||||
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
||||
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
|
||||
const payLater = intl.formatMessage({ id: "Pay later" })
|
||||
const payNow = intl.formatMessage({ id: "Pay now" })
|
||||
|
||||
function getRateDetails(rateCode: string) {
|
||||
const rate = Object.keys(rates).find((k) =>
|
||||
rates[k as keyof typeof rates].find((a) => a.rateCode === rateCode)
|
||||
)
|
||||
|
||||
switch (rate) {
|
||||
case "change":
|
||||
return `${freeBooking}, ${payNow}`
|
||||
case "flex":
|
||||
return `${freeCancelation}, ${payLater}`
|
||||
case "save":
|
||||
default:
|
||||
return `${nonRefundable}, ${payNow}`
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
startTransition(() => {
|
||||
router.push(`details?${params}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (!rateSummary.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalPriceToShow = calculateTotalPrice(
|
||||
rateSummary,
|
||||
isUserLoggedIn,
|
||||
petRoomPackage
|
||||
)
|
||||
|
||||
return (
|
||||
<form action={`details?${params}`} method="GET" onSubmit={handleSubmit}>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryText}>
|
||||
{rateSummary.map((room, index) => (
|
||||
<div key={index} className={styles.roomSummary}>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: index + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{room.roomType}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{getRateDetails(
|
||||
isUserLoggedIn && room.member
|
||||
? room.member?.rateCode
|
||||
: room.public.rateCode
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
))}
|
||||
{/* Render unselected rooms */}
|
||||
{Array.from({
|
||||
length: totalRoomsRequired - rateSummary.length,
|
||||
}).map((_, index) => (
|
||||
<div key={`unselected-${index}`} className={styles.roomSummary}>
|
||||
<Subtitle color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: rateSummary.length + index + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Select room" })}
|
||||
</Body>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.summaryPriceContainer}>
|
||||
{showMemberDiscountBanner && (
|
||||
<div className={styles.promoContainer}>
|
||||
<SignupPromoDesktop
|
||||
memberPrice={{
|
||||
amount: rateSummary.reduce((total, room) => {
|
||||
const memberPrice =
|
||||
room.member?.localPrice.pricePerStay ?? 0
|
||||
const isPetRoom = room.features.find(
|
||||
(feature) =>
|
||||
feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const petRoomPrice =
|
||||
isPetRoom && petRoomPackage
|
||||
? Number(petRoomPackage.localPrice.totalPrice)
|
||||
: 0
|
||||
return total + memberPrice + petRoomPrice
|
||||
}, 0),
|
||||
currency:
|
||||
rateSummary[0].member?.localPrice.currency ??
|
||||
rateSummary[0].public.localPrice.currency,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Body>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">{summaryPriceText}</Caption>
|
||||
</div>
|
||||
<div className={styles.summaryPrice}>
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Subtitle
|
||||
color={isUserLoggedIn ? "red" : "uiTextHighContrast"}
|
||||
textAlign="right"
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.price,
|
||||
totalPriceToShow.local.currency
|
||||
)}
|
||||
</Subtitle>
|
||||
{totalPriceToShow.requested ? (
|
||||
<Body color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Approx. {value}" },
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.requested.price,
|
||||
totalPriceToShow.requested.currency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.summaryPriceTextMobile}>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Total price" })}
|
||||
</Caption>
|
||||
<Subtitle color={isUserLoggedIn ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.price,
|
||||
totalPriceToShow.local.currency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Footnote
|
||||
color="uiTextMediumContrast"
|
||||
className={styles.summaryPriceTextMobile}
|
||||
>
|
||||
{summaryPriceText}
|
||||
</Footnote>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.continueButton}
|
||||
disabled={!isAllRoomsSelected}
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
{intl.formatMessage({ id: "Continue" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mobileSummary}>
|
||||
{showMemberDiscountBanner ? <SignupPromoMobile /> : null}
|
||||
<MobileSummary
|
||||
isAllRoomsSelected={isAllRoomsSelected}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
totalPriceToShow={totalPriceToShow}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,29 @@
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
bottom: -100%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
align-items: center;
|
||||
animation: slideUp 300ms ease forwards;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
bottom: -100%;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
align-items: center;
|
||||
transition: bottom 300ms ease-in-out;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary[data-visible="true"] {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.summaryPriceContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -35,6 +36,7 @@
|
||||
display: none;
|
||||
max-width: 264px;
|
||||
}
|
||||
|
||||
.summaryPrice {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
@@ -74,34 +76,46 @@
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.summary {
|
||||
padding: var(--Spacing-x3) 0 var(--Spacing-x5);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
padding: var(--Spacing-x3) 0 var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width-page);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.petInfo,
|
||||
.promoContainer,
|
||||
.summaryPriceTextDesktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.summaryPriceTextMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summaryPrice,
|
||||
.continueButton {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.summaryPriceContainer {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobileSummary {
|
||||
display: none;
|
||||
}
|
||||
@@ -14,15 +14,18 @@ export const calculateTotalPrice = (
|
||||
(total, room) => {
|
||||
const priceToUse =
|
||||
isUserLoggedIn && room.member ? room.member : room.public
|
||||
const isPetRoom = room.features.some(
|
||||
const isPetRoom = room.features.find(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const petRoomPrice =
|
||||
isPetRoom && petRoomPackage
|
||||
? isUserLoggedIn
|
||||
? Number(petRoomPackage.localPrice.totalPrice || 0)
|
||||
: Number(petRoomPackage.requestedPrice.totalPrice || 0)
|
||||
: 0
|
||||
|
||||
let petRoomPrice = 0
|
||||
if (
|
||||
petRoomPackage &&
|
||||
isPetRoom &&
|
||||
room.package === RoomPackageCodeEnum.PET_ROOM
|
||||
) {
|
||||
petRoomPrice = Number(petRoomPackage.localPrice.totalPrice)
|
||||
}
|
||||
|
||||
return {
|
||||
local: {
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { getRates } from "@/components/HotelReservation/SelectRate/utils"
|
||||
import { EditIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { useRoomContext } from "@/contexts/Room"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import styles from "./selectedRoomPanel.module.css"
|
||||
|
||||
import type { Room as SelectedRateRoom } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
|
||||
interface SelectedRoomPanelProps {
|
||||
room: SelectedRateRoom
|
||||
}
|
||||
|
||||
export default function SelectedRoomPanel({ room }: SelectedRoomPanelProps) {
|
||||
const intl = useIntl()
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const { rateDefinitions, roomCategories } = useRatesStore((state) => ({
|
||||
rateDefinitions: state.roomsAvailability?.rateDefinitions,
|
||||
roomCategories: state.roomCategories,
|
||||
}))
|
||||
const {
|
||||
actions: { modifyRate },
|
||||
roomNr,
|
||||
selectedRate,
|
||||
} = useRoomContext()
|
||||
|
||||
const images = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.some(
|
||||
(roomType) => roomType.code === selectedRate?.roomTypeCode
|
||||
)
|
||||
)?.images
|
||||
|
||||
if (!rateDefinitions) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rates = getRates(rateDefinitions)
|
||||
|
||||
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
||||
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
|
||||
const payLater = intl.formatMessage({ id: "Pay later" })
|
||||
const payNow = intl.formatMessage({ id: "Pay now" })
|
||||
|
||||
function getRateDetails(rateCode: string) {
|
||||
const rate = Object.keys(rates).find((k) =>
|
||||
rates[k as keyof typeof rates].find((a) => a.rateCode === rateCode)
|
||||
)
|
||||
|
||||
switch (rate) {
|
||||
case "change":
|
||||
return `${freeBooking}, ${payNow}`
|
||||
case "flex":
|
||||
return `${freeCancelation}, ${payLater}`
|
||||
case "save":
|
||||
default:
|
||||
return `${nonRefundable}, ${payNow}`
|
||||
}
|
||||
}
|
||||
|
||||
const rateCode =
|
||||
isUserLoggedIn && selectedRate?.product.productType.member
|
||||
? selectedRate?.product.productType.member?.rateCode
|
||||
: selectedRate?.product.productType.public.rateCode
|
||||
|
||||
return (
|
||||
<div className={styles.selectedRoomPanel}>
|
||||
<div>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: roomNr }
|
||||
)}
|
||||
</Caption>
|
||||
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
|
||||
{selectedRate?.roomType}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{rateCode ? getRateDetails(rateCode) : null}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{selectedRate?.product.productType.public.localPrice.pricePerNight}{" "}
|
||||
{selectedRate?.product.productType.public.localPrice.currency}/
|
||||
{intl.formatMessage({ id: "night" })}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.imageAndModifyButtonContainer}>
|
||||
{images?.[0]?.imageSizes?.tiny && (
|
||||
<div className={styles.imageContainer}>
|
||||
<Image
|
||||
alt={selectedRate?.roomType ?? images[0].metaData?.altText ?? ""}
|
||||
fill
|
||||
src={images[0].imageSizes.tiny}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.modifyButtonContainer}>
|
||||
<Button variant="icon" size="small" onClick={modifyRate}>
|
||||
<EditIcon />
|
||||
{intl.formatMessage({ id: "Modify" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,17 +25,23 @@
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
div.selectedRoomPanel p.subtitle {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.imageContainer {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.imageAndModifyButtonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.modifyButtonContainer {
|
||||
position: relative;
|
||||
bottom: 0;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { useRoomContext } from "@/contexts/Room"
|
||||
|
||||
import SelectedRoomPanel from "./SelectedRoomPanel"
|
||||
import { roomSelectionPanelVariants } from "./variants"
|
||||
|
||||
import styles from "./multiRoomWrapper.module.css"
|
||||
|
||||
export default function MultiRoomWrapper({
|
||||
children,
|
||||
isMultiRoom,
|
||||
}: React.PropsWithChildren<{ isMultiRoom: boolean }>) {
|
||||
const intl = useIntl()
|
||||
const activeRoom = useRatesStore((state) => state.activeRoom)
|
||||
const { bookingRoom, isActiveRoom, roomNr, selectedRate } = useRoomContext()
|
||||
|
||||
const onlyAdultsMsg = intl.formatMessage(
|
||||
{ id: "{adults} adults" },
|
||||
{ adults: bookingRoom.adults }
|
||||
)
|
||||
const adultsAndChildrenMsg = intl.formatMessage(
|
||||
{ id: "{adults} adults, {children} children" },
|
||||
{
|
||||
adults: bookingRoom.adults,
|
||||
children: bookingRoom.childrenInRoom?.length,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const SCROLL_OFFSET = 100
|
||||
const roomElements = document.querySelectorAll(`.${styles.roomContainer}`)
|
||||
|
||||
const selectedRoom = roomElements[activeRoom]
|
||||
|
||||
if (selectedRoom) {
|
||||
const elementPosition = selectedRoom.getBoundingClientRect().top
|
||||
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [activeRoom])
|
||||
|
||||
if (isMultiRoom) {
|
||||
const classNames = roomSelectionPanelVariants({
|
||||
active: isActiveRoom,
|
||||
selected: !!selectedRate && !isActiveRoom,
|
||||
})
|
||||
return (
|
||||
<div className={styles.roomContainer}>
|
||||
{selectedRate && !isActiveRoom ? null : (
|
||||
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: roomNr }
|
||||
)}
|
||||
,{" "}
|
||||
{bookingRoom.childrenInRoom?.length
|
||||
? adultsAndChildrenMsg
|
||||
: onlyAdultsMsg}
|
||||
</Subtitle>
|
||||
)}
|
||||
<div className={classNames}>
|
||||
<div className={styles.roomPanel}>
|
||||
<SelectedRoomPanel room={bookingRoom} />
|
||||
</div>
|
||||
<div className={styles.roomSelectionPanel}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -1,27 +1,10 @@
|
||||
.content {
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x2) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.roomContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
padding: var(--Spacing-x3);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.roomPanel,
|
||||
@@ -36,7 +19,7 @@
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.roomPanel > * {
|
||||
.roomPanel>* {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -53,6 +36,7 @@
|
||||
.roomSelectionPanelContainer.selected .roomSelectionPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.roomSelectionPanelContainer.active .roomSelectionPanel {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
@@ -60,14 +44,12 @@
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelAlert {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
div.roomContainer p.subtitle {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.roomContainer {
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
import styles from "./multiRoomWrapper.module.css"
|
||||
|
||||
export const roomSelectionPanelVariants = cva(
|
||||
styles.roomSelectionPanelContainer,
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
|
||||
@@ -8,6 +7,7 @@ import Button from "@/components/TempDesignSystem/Button"
|
||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { useRoomContext } from "@/contexts/Room"
|
||||
|
||||
import PriceTable from "./PriceList"
|
||||
|
||||
@@ -16,16 +16,32 @@ import styles from "./flexibilityOption.module.css"
|
||||
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
|
||||
export default function FlexibilityOption({
|
||||
handleSelect,
|
||||
features,
|
||||
isSelected,
|
||||
isUserLoggedIn,
|
||||
paymentTerm,
|
||||
priceInformation,
|
||||
petRoomPackage,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
title,
|
||||
}: FlexibilityOptionProps) {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
actions: { selectRate },
|
||||
} = useRoomContext()
|
||||
|
||||
function handleSelect() {
|
||||
if (product) {
|
||||
selectRate({
|
||||
features,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
@@ -48,18 +64,14 @@ export default function FlexibilityOption({
|
||||
|
||||
const { public: publicPrice, member: memberPrice } = product.productType
|
||||
|
||||
function handleOnSelect() {
|
||||
handleSelect(publicPrice.rateCode, title, paymentTerm)
|
||||
}
|
||||
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name={`rateCode-${product.productType.public.rateCode}`}
|
||||
value={publicPrice?.rateCode}
|
||||
checked={isSelected}
|
||||
onChange={handleOnSelect}
|
||||
name={`rateCode-${product.productType.public.rateCode}`}
|
||||
onChange={handleSelect}
|
||||
type="radio"
|
||||
value={publicPrice?.rateCode}
|
||||
/>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import type { RoomSizeProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
|
||||
export default function RoomSize({ roomSize }: RoomSizeProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!roomSize) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (roomSize.min === roomSize.max) {
|
||||
return (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{roomSize} m²" },
|
||||
{ roomSize: roomSize.min }
|
||||
)}
|
||||
</Caption>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{roomSizeMin} - {roomSizeMax} m²" },
|
||||
{
|
||||
roomSizeMin: roomSize.min,
|
||||
roomSizeMax: roomSize.max,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import { createElement } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
|
||||
import { getRates } from "@/components/HotelReservation/SelectRate/utils"
|
||||
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { useRoomContext } from "@/contexts/Room"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
import { cardVariants } from "./cardVariants"
|
||||
import FlexibilityOption from "./FlexibilityOption"
|
||||
import RoomSize from "./RoomSize"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { HotelTypeEnum } from "@/types/enums/hotelType"
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
function getBreakfastMessage(
|
||||
publicBreakfastIncluded: boolean,
|
||||
memberBreakfastIncluded: boolean,
|
||||
hotelType: string | undefined,
|
||||
userIsLoggedIn: boolean,
|
||||
msgs: Record<"included" | "noSelection" | "scandicgo" | "notIncluded", string>
|
||||
) {
|
||||
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||
return msgs.scandicgo
|
||||
}
|
||||
|
||||
if (userIsLoggedIn && memberBreakfastIncluded) {
|
||||
return msgs.included
|
||||
}
|
||||
|
||||
if (publicBreakfastIncluded && memberBreakfastIncluded) {
|
||||
return msgs.included
|
||||
}
|
||||
|
||||
/** selected and rate does not include breakfast */
|
||||
if (false) {
|
||||
return msgs.notIncluded
|
||||
}
|
||||
|
||||
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
|
||||
return msgs.notIncluded
|
||||
}
|
||||
|
||||
return msgs.noSelection
|
||||
}
|
||||
|
||||
export default function RoomCard({ roomConfiguration }: RoomCardProps) {
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const intl = useIntl()
|
||||
const lessThanFiveRoomsLeft =
|
||||
roomConfiguration.roomsLeft > 0 && roomConfiguration.roomsLeft < 5
|
||||
|
||||
const {
|
||||
hotelId,
|
||||
hotelType,
|
||||
petRoomPackage,
|
||||
rateDefinitions,
|
||||
roomCategories,
|
||||
} = useRatesStore((state) => ({
|
||||
hotelId: state.booking.hotelId,
|
||||
hotelType: state.hotelType,
|
||||
petRoomPackage: state.petRoomPackage,
|
||||
rateDefinitions: state.roomsAvailability?.rateDefinitions,
|
||||
roomCategories: state.roomCategories,
|
||||
}))
|
||||
const { selectedPackage, selectedRate } = useRoomContext()
|
||||
|
||||
const classNames = cardVariants({
|
||||
availability:
|
||||
roomConfiguration.status === AvailabilityEnum.NotAvailable
|
||||
? "noAvailability"
|
||||
: "default",
|
||||
})
|
||||
|
||||
const breakfastMessages = {
|
||||
included: intl.formatMessage({ id: "Breakfast is included." }),
|
||||
notIncluded: intl.formatMessage({
|
||||
id: "Breakfast selection in next step.",
|
||||
}),
|
||||
noSelection: intl.formatMessage({ id: "Select a rate" }),
|
||||
scandicgo: intl.formatMessage({
|
||||
id: "Breakfast deal can be purchased at the hotel.",
|
||||
}),
|
||||
}
|
||||
const breakfastMessage = getBreakfastMessage(
|
||||
roomConfiguration.breakfastIncludedInAllRatesPublic,
|
||||
roomConfiguration.breakfastIncludedInAllRatesMember,
|
||||
hotelType,
|
||||
isUserLoggedIn,
|
||||
breakfastMessages
|
||||
)
|
||||
|
||||
if (!rateDefinitions) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rates = getRates(rateDefinitions)
|
||||
|
||||
const petRoomPackageSelected =
|
||||
(selectedPackage === RoomPackageCodeEnum.PET_ROOM && petRoomPackage) ||
|
||||
undefined
|
||||
|
||||
const selectedRoom = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.find(
|
||||
(roomType) => roomType.code === roomConfiguration.roomTypeCode
|
||||
)
|
||||
)
|
||||
|
||||
const { name, roomSize, totalOccupancy, images } = selectedRoom || {}
|
||||
const galleryImages = mapApiImagesToGalleryImages(images || [])
|
||||
|
||||
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
||||
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
|
||||
const payLater = intl.formatMessage({ id: "Pay later" })
|
||||
const payNow = intl.formatMessage({ id: "Pay now" })
|
||||
|
||||
function getRate(rateCode: string) {
|
||||
switch (rateCode) {
|
||||
case "change":
|
||||
return {
|
||||
isFlex: false,
|
||||
notAvailable: false,
|
||||
title: freeBooking,
|
||||
}
|
||||
case "flex":
|
||||
return {
|
||||
isFlex: true,
|
||||
notAvailable: false,
|
||||
title: freeCancelation,
|
||||
}
|
||||
case "save":
|
||||
return {
|
||||
isFlex: false,
|
||||
notAvailable: false,
|
||||
title: nonRefundable,
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown key for rate, should be "change", "flex" or "save", but got ${rateCode}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getRateInfo(product: Product) {
|
||||
if (
|
||||
!product.productType.public.rateCode &&
|
||||
!product.productType.member?.rateCode
|
||||
) {
|
||||
const possibleRate = getRate(product.productType.public.rate)
|
||||
if (possibleRate) {
|
||||
return {
|
||||
...possibleRate,
|
||||
notAvailable: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
isFlex: false,
|
||||
notAvailable: true,
|
||||
title: "",
|
||||
}
|
||||
}
|
||||
|
||||
const publicRate = Object.keys(rates).find((k) =>
|
||||
rates[k as keyof typeof rates].find(
|
||||
(a) => a.rateCode === product.productType.public.rateCode
|
||||
)
|
||||
)
|
||||
let memberRate
|
||||
if (product.productType.member) {
|
||||
memberRate = Object.keys(rates).find((k) =>
|
||||
rates[k as keyof typeof rates].find(
|
||||
(a) => a.rateCode === product.productType.member!.rateCode
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!publicRate || !memberRate) {
|
||||
throw new Error("We should never make it where without rateCodes")
|
||||
}
|
||||
|
||||
const key = isUserLoggedIn ? memberRate : publicRate
|
||||
return getRate(key)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<div>
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.chipContainer}>
|
||||
{lessThanFiveRoomsLeft ? (
|
||||
<span className={styles.chip}>
|
||||
<Footnote color="burgundy" textTransform="uppercase">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount, number} left" },
|
||||
{ amount: roomConfiguration.roomsLeft }
|
||||
)}
|
||||
</Footnote>
|
||||
</span>
|
||||
) : null}
|
||||
{roomConfiguration.features
|
||||
.filter((feature) => selectedPackage === feature.code)
|
||||
.map((feature) => (
|
||||
<span className={styles.chip} key={feature.code}>
|
||||
{createElement(getIconForFeatureCode(feature.code), {
|
||||
color: "burgundy",
|
||||
height: 16,
|
||||
width: 16,
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ImageGallery
|
||||
images={galleryImages}
|
||||
title={roomConfiguration.roomType}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.specification}>
|
||||
{totalOccupancy && (
|
||||
<Caption color="uiTextMediumContrast" className={styles.guests}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Max {max, plural, one {{range} guest} other {{range} guests}}",
|
||||
},
|
||||
{ max: totalOccupancy.max, range: totalOccupancy.range }
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
<RoomSize roomSize={roomSize} />
|
||||
<div className={styles.toggleSidePeek}>
|
||||
{roomConfiguration.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId.toString()}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<Subtitle className={styles.name} type="two">
|
||||
{name}
|
||||
</Subtitle>
|
||||
{/* Out of scope for now
|
||||
<Body>{descriptions?.short}</Body>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.container}>
|
||||
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
|
||||
<div className={styles.noRoomsContainer}>
|
||||
<div className={styles.noRooms}>
|
||||
<ErrorCircleIcon color="red" width={16} />
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({
|
||||
id: "This room is not available",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{breakfastMessage}
|
||||
</Caption>
|
||||
{roomConfiguration.products.map((product) => {
|
||||
const rate = getRateInfo(product)
|
||||
const isSelectedRateCode =
|
||||
selectedRate?.product.productType.public.rateCode ===
|
||||
product.productType.public.rateCode ||
|
||||
selectedRate?.product.productType.member?.rateCode ===
|
||||
product.productType.member?.rateCode
|
||||
return (
|
||||
<FlexibilityOption
|
||||
key={product.productType.public.rateCode}
|
||||
features={roomConfiguration.features}
|
||||
isSelected={
|
||||
isSelectedRateCode &&
|
||||
selectedRate?.roomTypeCode ===
|
||||
roomConfiguration.roomTypeCode
|
||||
}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
paymentTerm={rate.isFlex ? payLater : payNow}
|
||||
petRoomPackage={petRoomPackageSelected}
|
||||
product={rate.notAvailable ? undefined : product}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
title={rate.title}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
import {
|
||||
type Key,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { useRoomContext } from "@/contexts/Room"
|
||||
|
||||
import styles from "./roomFilter.module.css"
|
||||
|
||||
import type { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
export default function RoomTypeFilter() {
|
||||
const filterOptions = useRatesStore((state) => state.filterOptions)
|
||||
const {
|
||||
actions: { selectFilter },
|
||||
rooms,
|
||||
selectedPackage,
|
||||
} = useRoomContext()
|
||||
const intl = useIntl()
|
||||
|
||||
// const tooltipText = intl.formatMessage({
|
||||
// id: "Pet-friendly rooms have an additional fee of 20 EUR per stay",
|
||||
// })
|
||||
|
||||
function handleChange(selectedFilter: Set<Key>) {
|
||||
if (selectedFilter.size) {
|
||||
const selected = selectedFilter.values().next()
|
||||
selectFilter(selected.value as RoomPackageCodeEnum)
|
||||
} else {
|
||||
selectFilter(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
||||
},
|
||||
{ numberOfRooms: rooms.length }
|
||||
)}
|
||||
</Caption>
|
||||
<ToggleButtonGroup
|
||||
aria-label="Filter"
|
||||
className={styles.roomsFilter}
|
||||
defaultSelectedKeys={selectedPackage ? [selectedPackage] : undefined}
|
||||
onSelectionChange={handleChange}
|
||||
orientation="horizontal"
|
||||
>
|
||||
{filterOptions.map((option) => (
|
||||
<ToggleButton
|
||||
aria-label={option.description}
|
||||
className={styles.radio}
|
||||
id={option.code}
|
||||
key={option.itemCode}
|
||||
>
|
||||
<div className={styles.circle} />
|
||||
<Caption color="uiTextHighContrast">{option.description}</Caption>
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.container {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.roomsFilter {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--Spacing-x2);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.radio {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.circle {
|
||||
border: 1px solid var(--UI-Input-Controls-Border-Normal);
|
||||
border-radius: 50%;
|
||||
grid-area: input;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
transition: background-color 200ms ease;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.radio:hover .circle {
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected-hover);
|
||||
}
|
||||
|
||||
.radio[data-selected="true"] .circle {
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.radio[data-selected="true"]:hover .circle {
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected-hover);
|
||||
border-color: var(--UI-Input-Controls-Border-Hover);
|
||||
}
|
||||
|
||||
.radio[data-selected="true"] .circle::after {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
height: 8px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.roomsFilter {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { alternativeHotels } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import { useRoomContext } from "@/contexts/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import RoomCard from "./RoomCard"
|
||||
import RoomTypeFilter from "./RoomTypeFilter"
|
||||
|
||||
import styles from "./roomSelectionPanel.module.css"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
|
||||
export default function RoomSelectionPanel() {
|
||||
const { rooms } = useRoomContext()
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const noAvailableRooms = rooms.every(
|
||||
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{noAvailableRooms ? (
|
||||
<div className={styles.hotelAlert}>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({ id: "No availability" })}
|
||||
text={intl.formatMessage({
|
||||
id: "There are no rooms available that match your request.",
|
||||
})}
|
||||
link={{
|
||||
title: intl.formatMessage({ id: "See alternative hotels" }),
|
||||
url: `${alternativeHotels(lang)}`,
|
||||
keepSearchParams: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<RoomTypeFilter />
|
||||
<div className={styles.wrapper}>
|
||||
<ul className={styles.roomList}>
|
||||
{rooms.map((roomConfiguration) => (
|
||||
<RoomCard
|
||||
key={roomConfiguration.roomTypeCode}
|
||||
roomConfiguration={roomConfiguration}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -14,3 +14,9 @@
|
||||
position: fixed;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.hotelAlert {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import RoomProvider from "@/providers/RoomProvider"
|
||||
import { trackLowestRoomPrice } from "@/utils/tracking"
|
||||
|
||||
import MultiRoomWrapper from "./MultiRoomWrapper"
|
||||
import RoomSelectionPanel from "./RoomSelectionPanel"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function Rooms() {
|
||||
const {
|
||||
arrivalDate,
|
||||
bookingRooms,
|
||||
departureDate,
|
||||
hotelId,
|
||||
rooms,
|
||||
visibleRooms,
|
||||
} = useRatesStore((state) => ({
|
||||
arrivalDate: state.booking.fromDate,
|
||||
bookingRooms: state.booking.rooms,
|
||||
departureDate: state.booking.toDate,
|
||||
hotelId: state.booking.hotelId,
|
||||
rooms: state.rooms,
|
||||
visibleRooms: state.allRooms,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
const pricesWithCurrencies = visibleRooms.flatMap((room) =>
|
||||
room.products.map((product) => ({
|
||||
price: product.productType.public.localPrice.pricePerNight,
|
||||
currency: product.productType.public.localPrice.currency,
|
||||
}))
|
||||
)
|
||||
const lowestPrice = pricesWithCurrencies.reduce(
|
||||
(minPrice, { price }) => Math.min(minPrice, price),
|
||||
Infinity
|
||||
)
|
||||
|
||||
const currency = pricesWithCurrencies[0]?.currency
|
||||
|
||||
trackLowestRoomPrice({
|
||||
hotelId,
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
lowestPrice: lowestPrice,
|
||||
currency: currency,
|
||||
})
|
||||
}, [arrivalDate, departureDate, hotelId, visibleRooms])
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{bookingRooms.map((room, idx) => (
|
||||
<RoomProvider
|
||||
key={`${room.rateCode}-${room.roomTypeCode}-${idx}`}
|
||||
idx={idx}
|
||||
room={rooms[idx]}
|
||||
>
|
||||
<MultiRoomWrapper isMultiRoom={bookingRooms.length > 1}>
|
||||
<RoomSelectionPanel />
|
||||
</MultiRoomWrapper>
|
||||
</RoomProvider>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.content {
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x5) 0;
|
||||
}
|
||||
131
components/HotelReservation/SelectRate/RoomsContainer/index.tsx
Normal file
131
components/HotelReservation/SelectRate/RoomsContainer/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getHotel,
|
||||
getPackages,
|
||||
getRoomsAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { generateChildrenString } from "@/components/HotelReservation/utils"
|
||||
import RatesProvider from "@/providers/RatesProvider"
|
||||
import { isValidSession } from "@/utils/session"
|
||||
|
||||
import { combineRoomAvailabilities } from "../utils"
|
||||
import RateSummary from "./RateSummary"
|
||||
import Rooms from "./Rooms"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export function preload(
|
||||
hotelId: string,
|
||||
lang: Lang,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
adults: number[],
|
||||
children?: Child[]
|
||||
) {
|
||||
void getHotel({ hotelId, isCardOnlyPayment: false, language: lang })
|
||||
void getPackages({
|
||||
adults: adults[0],
|
||||
children: children ? children?.length : undefined,
|
||||
endDate: toDate,
|
||||
hotelId,
|
||||
packageCodes: [
|
||||
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
RoomPackageCodeEnum.PET_ROOM,
|
||||
RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
],
|
||||
startDate: fromDate,
|
||||
})
|
||||
|
||||
const uniqueAdultsCount = Array.from(new Set(adults))
|
||||
uniqueAdultsCount.forEach((adultsInRoom) => {
|
||||
void getRoomsAvailability({
|
||||
adults: adultsInRoom,
|
||||
children: children ? generateChildrenString(children) : undefined,
|
||||
hotelId: +hotelId,
|
||||
roomStayEndDate: toDate,
|
||||
roomStayStartDate: fromDate,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function RoomsContainer({
|
||||
adultArray,
|
||||
booking,
|
||||
childArray,
|
||||
fromDate,
|
||||
hotelId,
|
||||
lang,
|
||||
toDate,
|
||||
}: RoomsContainerProps) {
|
||||
const session = await auth()
|
||||
const isUserLoggedIn = isValidSession(session)
|
||||
|
||||
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
|
||||
const toDateString = dt(toDate).format("YYYY-MM-DD")
|
||||
|
||||
const hotelData = await getHotel({
|
||||
hotelId: hotelId.toString(),
|
||||
isCardOnlyPayment: false,
|
||||
language: lang,
|
||||
})
|
||||
|
||||
const uniqueAdultsCount = Array.from(new Set(adultArray))
|
||||
const roomsAvailabilityResults = await Promise.allSettled(
|
||||
uniqueAdultsCount.map((adultCount) =>
|
||||
getRoomsAvailability({
|
||||
adults: adultCount,
|
||||
hotelId: hotelId,
|
||||
roomStayEndDate: toDateString,
|
||||
roomStayStartDate: fromDateString,
|
||||
children:
|
||||
childArray && childArray.length > 0
|
||||
? generateChildrenString(childArray)
|
||||
: undefined,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const roomsAvailability = combineRoomAvailabilities(roomsAvailabilityResults)
|
||||
|
||||
const packages = await getPackages({
|
||||
adults: adultArray[0],
|
||||
children: childArray ? childArray.length : undefined,
|
||||
endDate: toDateString,
|
||||
hotelId: hotelId.toString(),
|
||||
packageCodes: [
|
||||
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
RoomPackageCodeEnum.PET_ROOM,
|
||||
RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
],
|
||||
startDate: fromDateString,
|
||||
})
|
||||
|
||||
if (!hotelData?.hotel) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (packages === null) {
|
||||
// TODO: Log packages error
|
||||
console.error("[RoomsContainer] unable to fetch packages")
|
||||
}
|
||||
|
||||
return (
|
||||
<RatesProvider
|
||||
booking={booking}
|
||||
hotelType={hotelData.hotel.hotelType}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
packages={packages}
|
||||
roomCategories={hotelData.roomCategories}
|
||||
roomsAvailability={roomsAvailability}
|
||||
vat={hotelData.hotel.vat}
|
||||
>
|
||||
<Rooms />
|
||||
<RateSummary isUserLoggedIn={isUserLoggedIn} />
|
||||
</RatesProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client"
|
||||
import { useCallback } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
||||
|
||||
import { EditIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./selectedRoomPanel.module.css"
|
||||
|
||||
import type { Room as SelectedRateRoom } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Room } from "@/types/hotel"
|
||||
|
||||
interface SelectedRoomPanelProps {
|
||||
roomIndex: number
|
||||
room: SelectedRateRoom
|
||||
roomCategories: Room[]
|
||||
}
|
||||
|
||||
export default function SelectedRoomPanel({
|
||||
roomIndex,
|
||||
room,
|
||||
roomCategories,
|
||||
}: SelectedRoomPanelProps) {
|
||||
const intl = useIntl()
|
||||
const { rateSummary, modifyRate } = useRateSelectionStore()
|
||||
|
||||
const selectedRate = rateSummary[roomIndex]
|
||||
const images = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.some(
|
||||
(roomType) => roomType.code === selectedRate?.roomTypeCode
|
||||
)
|
||||
)?.images
|
||||
|
||||
const handleModify = useCallback(() => {
|
||||
modifyRate(roomIndex)
|
||||
}, [modifyRate, roomIndex])
|
||||
|
||||
return (
|
||||
<div className={styles.selectedRoomPanel}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div>
|
||||
<Caption type="regular">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: roomIndex + 1,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{selectedRate?.roomType},{" "}
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: room.childrenInRoom?.length
|
||||
? "{adults} adults, {children} children"
|
||||
: "{adults} adults",
|
||||
},
|
||||
{
|
||||
adults: room.adults,
|
||||
children: room.childrenInRoom?.length,
|
||||
}
|
||||
)}
|
||||
</Subtitle>
|
||||
</div>
|
||||
<div>
|
||||
<Body>
|
||||
{selectedRate?.priceName}, {selectedRate?.priceTerm}
|
||||
</Body>
|
||||
<Body>
|
||||
{selectedRate?.public.localPrice.pricePerNight}{" "}
|
||||
{selectedRate?.public.localPrice.currency}/
|
||||
{intl.formatMessage({
|
||||
id: "night",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.imageAndModifyButtonContainer}>
|
||||
{images?.[0]?.imageSizes?.tiny && (
|
||||
<div className={styles.imageContainer}>
|
||||
<Image
|
||||
src={images[0].imageSizes.tiny}
|
||||
alt={selectedRate?.roomType ?? images[0].metaData?.altText ?? ""}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.modifyButtonContainer}>
|
||||
<Button variant="icon" size="small" onClick={handleModify}>
|
||||
<EditIcon />
|
||||
{intl.formatMessage({
|
||||
id: "Modify",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./selectionCard.module.css"
|
||||
|
||||
import type { SelectionCardProps } from "@/types/components/hotelReservation/selectRate/selectionCard"
|
||||
|
||||
export default function SelectionCard({
|
||||
price,
|
||||
membersPrice,
|
||||
currency,
|
||||
title,
|
||||
subtext,
|
||||
}: SelectionCardProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div>
|
||||
<Title className={styles.name} as="h4" level="h3">
|
||||
{title}
|
||||
</Title>
|
||||
<div className={styles.nameInfo}>i</div>
|
||||
</div>
|
||||
<Caption color="burgundy">{subtext}</Caption>
|
||||
|
||||
<div>
|
||||
<Caption color="burgundy" className={styles.price}>
|
||||
{intl.formatMessage(
|
||||
{ id: "{price}/night" },
|
||||
{
|
||||
price: formatPrice(intl, price, currency),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
|
||||
{membersPrice && (
|
||||
<Caption color="burgundy" className={styles.membersPrice}>
|
||||
{intl.formatMessage(
|
||||
{ id: "Members {price}/night" },
|
||||
{ price: formatPrice(intl, membersPrice, currency) }
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
.card {
|
||||
padding: var(--Spacing-x2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
background-color: #fff;
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
border: 1px solid rgba(77, 0, 27, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + .card {
|
||||
border: 3px solid var(--Scandic-Brand-Scandic-Red);
|
||||
}
|
||||
|
||||
.name {
|
||||
display: inline-block;
|
||||
}
|
||||
.nameInfo {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: var(--typography-Body-Bold-fontSize);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.membersPrice {
|
||||
font-size: var(--typography-Footnote-Regular-fontSize);
|
||||
}
|
||||
@@ -1,23 +1,39 @@
|
||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export function combineRoomAvailabilities({
|
||||
availabilityResults,
|
||||
}: {
|
||||
availabilityResults: Array<[RoomsAvailability | undefined | null, unknown]>
|
||||
}): RoomsAvailability | null {
|
||||
return availabilityResults.reduce<RoomsAvailability | null>(
|
||||
(combinedResult, [currentResult, error]) => {
|
||||
if (error || !currentResult) return combinedResult
|
||||
if (!combinedResult) return currentResult
|
||||
|
||||
return {
|
||||
...currentResult,
|
||||
roomConfigurations: [
|
||||
...combinedResult.roomConfigurations,
|
||||
...currentResult.roomConfigurations,
|
||||
],
|
||||
export function combineRoomAvailabilities(
|
||||
availabilityResults: PromiseSettledResult<RoomsAvailability | null>[]
|
||||
) {
|
||||
return availabilityResults.reduce<RoomsAvailability | null>((acc, result) => {
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
if (acc) {
|
||||
acc.roomConfigurations.push(...result.value.roomConfigurations)
|
||||
} else {
|
||||
acc = result.value
|
||||
}
|
||||
},
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// Ping monitoring about fail?
|
||||
if (result.status === "rejected") {
|
||||
console.info(`RoomAvailability fetch failed`)
|
||||
console.error(result.reason)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, null)
|
||||
}
|
||||
|
||||
export function getRates(
|
||||
rateDefinitions: RoomsAvailability["rateDefinitions"]
|
||||
) {
|
||||
return {
|
||||
change: rateDefinitions.filter(
|
||||
(rate) => rate.cancellationRule === "Changeable"
|
||||
),
|
||||
flex: rateDefinitions.filter(
|
||||
(rate) => rate.cancellationRule === "CancellableBefore6PM"
|
||||
),
|
||||
save: rateDefinitions.filter(
|
||||
(rate) => rate.cancellationRule === "NotCancellable"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export function getIconForFeatureCode(featureCode: RoomPackageCodes) {
|
||||
case RoomPackageCodeEnum.ALLERGY_ROOM:
|
||||
return AllergyIcon
|
||||
case RoomPackageCodeEnum.PET_ROOM:
|
||||
return PetsIcon
|
||||
default:
|
||||
return PetsIcon
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function CountrySelect({
|
||||
rules: registerOptions,
|
||||
})
|
||||
|
||||
function handleChange(country: Key) {
|
||||
function handleChange(country: Key | null) {
|
||||
setValue(name, country ?? "")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user