Merged in monorepo-step-1 (pull request #1080)
Migrate to a monorepo setup - step 1 * Move web to subfolder /apps/scandic-web * Yarn + transitive deps - Move to yarn - design-system package removed for now since yarn doesn't support the parameter for token (ie project currently broken) - Add missing transitive dependencies as Yarn otherwise prevents these imports - VS Code doesn't pick up TS path aliases unless you open /apps/scandic-web instead of root (will be fixed with monorepo) * Pin framer-motion to temporarily fix typing issue https://github.com/adobe/react-spectrum/issues/7494 * Pin zod to avoid typ error There seems to have been a breaking change in the types returned by zod where error is now returned as undefined instead of missing in the type. We should just handle this but to avoid merge conflicts just pin the dependency for now. * Pin react-intl version Pin version of react-intl to avoid tiny type issue where formatMessage does not accept a generic any more. This will be fixed in a future commit, but to avoid merge conflicts just pin for now. * Pin typescript version Temporarily pin version as newer versions as stricter and results in a type error. Will be fixed in future commit after merge. * Setup workspaces * Add design-system as a monorepo package * Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN * Fix husky for monorepo setup * Update netlify.toml * Add lint script to root package.json * Add stub readme * Fix react-intl formatMessage types * Test netlify.toml in root * Remove root toml * Update netlify.toml publish path * Remove package-lock.json * Update build for branch/preview builds Approved-by: Linus Flood
This commit is contained in:
committed by
Linus Flood
parent
667cab6fb6
commit
80100e7631
@@ -0,0 +1,112 @@
|
||||
.container {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width-page);
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.imageWrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 200px;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imageWrapper img {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.hotelContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hotelInformation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
width: min(607px, 100%);
|
||||
}
|
||||
|
||||
.hotelAddressDescription {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.facilities {
|
||||
padding: var(--Spacing-x3) 0 var(--Spacing-x-quarter);
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.facilityList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.facilitiesItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelAlert {
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
padding-top: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
padding: var(--Spacing-x4) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
padding: var(--Spacing-x4) var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.hotelContent {
|
||||
gap: var(--Spacing-x6);
|
||||
}
|
||||
|
||||
.hotelInformation {
|
||||
padding-right: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
gap: var(--Spacing-x3);
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.facilities {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.facilityList {
|
||||
gap: var(--Spacing-x1);
|
||||
padding-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
.facilityTitle {
|
||||
display: none;
|
||||
}
|
||||
.hotelContent {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.imageWrapper {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
import { getSingleDecimal } from "@/utils/numberFormatting"
|
||||
|
||||
import ReadMore from "../../ReadMore"
|
||||
import TripAdvisorChip from "../../TripAdvisorChip"
|
||||
|
||||
import styles from "./hotelInfoCard.module.css"
|
||||
|
||||
import type { HotelInfoCardProps } from "@/types/components/hotelReservation/selectRate/hotelInfoCard"
|
||||
|
||||
export default async function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
|
||||
const hotel = hotelData?.hotel
|
||||
const intl = await getIntl()
|
||||
|
||||
const sortedFacilities = hotel?.detailedFacilities
|
||||
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||
.slice(0, 5)
|
||||
|
||||
const galleryImages = mapApiImagesToGalleryImages(hotel?.galleryImages || [])
|
||||
|
||||
return (
|
||||
<article className={styles.container}>
|
||||
{hotel && (
|
||||
<section className={styles.wrapper}>
|
||||
<div className={styles.imageWrapper}>
|
||||
<ImageGallery title={hotel.name} images={galleryImages} fill />
|
||||
{hotel.ratings?.tripAdvisor && (
|
||||
<TripAdvisorChip rating={hotel.ratings.tripAdvisor.rating} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.hotelContent}>
|
||||
<div className={styles.hotelInformation}>
|
||||
<Title as="h2" textTransform="uppercase">
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<div className={styles.hotelAddressDescription}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "{address}, {city} ∙ {distanceToCityCenterInKm} km to city center",
|
||||
},
|
||||
{
|
||||
address: hotel.address.streetAddress,
|
||||
city: hotel.address.city,
|
||||
distanceToCityCenterInKm: getSingleDecimal(
|
||||
hotel.location.distanceToCentre / 1000
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotel.hotelContent.texts.descriptions?.medium}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<div className={styles.facilities}>
|
||||
<div className={styles.facilityList}>
|
||||
<Body textTransform="bold" className={styles.facilityTitle}>
|
||||
{intl.formatMessage({ id: "At the hotel" })}
|
||||
</Body>
|
||||
{sortedFacilities?.map((facility) => {
|
||||
const IconComponent = mapFacilityToIcon(facility.id)
|
||||
return (
|
||||
<div className={styles.facilitiesItem} key={facility.id}>
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
className={styles.facilitiesIcon}
|
||||
color="grey80"
|
||||
/>
|
||||
)}
|
||||
<Body color="uiTextHighContrast">{facility.name}</Body>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ReadMore
|
||||
label={intl.formatMessage({ id: "Show all amenities" })}
|
||||
hotelId={hotel.operaId}
|
||||
hotel={hotel}
|
||||
showCTA={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{hotel?.specialAlerts.map((alert) => {
|
||||
return (
|
||||
<div className={styles.hotelAlert} key={`wrapper_${alert.id}`}>
|
||||
<Alert
|
||||
key={alert.id}
|
||||
type={alert.type}
|
||||
heading={alert.heading}
|
||||
text={alert.text}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function HotelInfoCardSkeleton() {
|
||||
return (
|
||||
<article className={styles.container}>
|
||||
<section className={styles.wrapper}>
|
||||
<div className={styles.imageWrapper}>
|
||||
<SkeletonShimmer height={"100%"} width={"100%"} />
|
||||
</div>
|
||||
<div className={styles.hotelContent}>
|
||||
<div className={styles.hotelInformation}>
|
||||
<SkeletonShimmer width={"60ch"} height={"40px"} />
|
||||
<div className={styles.hotelAddressDescription}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
<SkeletonShimmer width={"40ch"} />
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast">
|
||||
<SkeletonShimmer width={"60ch"} />
|
||||
<SkeletonShimmer width={"58ch"} />
|
||||
<SkeletonShimmer width={"45ch"} />
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<div className={styles.facilities}>
|
||||
<div className={styles.facilityList}>
|
||||
<Body textTransform="bold" className={styles.facilityTitle}>
|
||||
<SkeletonShimmer width={"20ch"} />
|
||||
</Body>
|
||||
{[1, 2, 3, 4, 5]?.map((id) => {
|
||||
return (
|
||||
<div className={styles.facilitiesItem} key={id}>
|
||||
<SkeletonShimmer width={"10ch"} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.hotelAlert}>
|
||||
<SkeletonShimmer width={"18ch"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client"
|
||||
import { Fragment } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
ChevronDownSmallIcon,
|
||||
} from "@/components/Icons"
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default function Summary({
|
||||
booking,
|
||||
rooms,
|
||||
totalPrice,
|
||||
isMember,
|
||||
vat,
|
||||
toggleSummaryOpen,
|
||||
}: SelectRateSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
return roomRate?.memberRate
|
||||
? {
|
||||
currency: roomRate.memberRate.localPrice.currency,
|
||||
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
|
||||
amount: roomRate.memberRate.localPrice.pricePerStay,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
const memberPrice = getMemberPrice(rooms[0].roomRate)
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header className={styles.header}>
|
||||
<Subtitle className={styles.title} type="two">
|
||||
{intl.formatMessage({ id: "Booking summary" })}
|
||||
</Subtitle>
|
||||
<Body className={styles.date} color="baseTextMediumContrast">
|
||||
{dt(booking.fromDate).locale(lang).format("ddd, D MMM")}
|
||||
<ArrowRightIcon color="peach80" height={15} width={15} />
|
||||
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||
</Body>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
className={styles.chevronButton}
|
||||
onClick={toggleSummaryOpen}
|
||||
>
|
||||
<ChevronDownSmallIcon height="20" width="20" />
|
||||
</Button>
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
{rooms.map((room, idx) => {
|
||||
const roomNumber = idx + 1
|
||||
const adults = room.adults
|
||||
const childrenInRoom = room.childrenInRoom
|
||||
|
||||
const childrenBeds = childrenInRoom?.reduce(
|
||||
(acc, value) => {
|
||||
const bedType = Number(value.bed)
|
||||
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
return acc
|
||||
}
|
||||
const count = acc.get(bedType) ?? 0
|
||||
acc.set(bedType, count + 1)
|
||||
return acc
|
||||
},
|
||||
new Map<ChildBedMapEnum, number>([
|
||||
[ChildBedMapEnum.IN_CRIB, 0],
|
||||
[ChildBedMapEnum.IN_EXTRA_BED, 0],
|
||||
])
|
||||
)
|
||||
|
||||
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
|
||||
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
|
||||
|
||||
const memberPrice = getMemberPrice(room.roomRate)
|
||||
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
||||
{ totalAdults: adults }
|
||||
)
|
||||
|
||||
const guestsParts = [adultsMsg]
|
||||
if (childrenInRoom?.length) {
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
id: "{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{ totalChildren: childrenInRoom.length }
|
||||
)
|
||||
guestsParts.push(childrenMsg)
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={idx}>
|
||||
<div
|
||||
className={styles.addOns}
|
||||
data-testid={`summary-room-${roomNumber}`}
|
||||
>
|
||||
<div>
|
||||
{rooms.length > 1 ? (
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Room" })} {roomNumber}
|
||||
</Body>
|
||||
) : null}
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{guestsParts.join(", ")}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<Caption color="burgundy" type="underline">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Caption>
|
||||
</Button>
|
||||
}
|
||||
title={room.cancellationText}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{room.rateDetails?.map((info) => (
|
||||
<Body
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.termsText}
|
||||
>
|
||||
<CheckIcon
|
||||
color="uiSemanticSuccess"
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.termsIcon}
|
||||
></CheckIcon>
|
||||
{info}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{childBedCrib ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Crib (child) × {count}" },
|
||||
{ count: childBedCrib }
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
0,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{childBedExtraBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Extra bed (child) × {count}" },
|
||||
{
|
||||
count: childBedExtraBed,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
0,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
<div className={styles.total}>
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body>
|
||||
{intl.formatMessage(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<PriceDetailsModal
|
||||
fromDate={booking.fromDate}
|
||||
toDate={booking.toDate}
|
||||
rooms={rooms.map((r) => ({
|
||||
adults: r.adults,
|
||||
childrenInRoom: r.childrenInRoom,
|
||||
roomPrice: r.roomPrice,
|
||||
roomType: r.roomType,
|
||||
}))}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Body textTransform="bold" data-testid="total-price">
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency
|
||||
)}
|
||||
</Body>
|
||||
{totalPrice.requested && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Approx. {value}" },
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
totalPrice.requested.price,
|
||||
totalPrice.requested.currency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||
</div>
|
||||
{!isMember && memberPrice ? (
|
||||
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import Summary from "./Summary"
|
||||
|
||||
import styles from "./mobileSummary.module.css"
|
||||
|
||||
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
|
||||
export default function MobileSummary({
|
||||
isAllRoomsSelected,
|
||||
isUserLoggedIn,
|
||||
totalPriceToShow,
|
||||
}: MobileSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const scrollY = useRef(0)
|
||||
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
function toggleSummaryOpen() {
|
||||
setIsSummaryOpen(!isSummaryOpen)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSummaryOpen) {
|
||||
scrollY.current = window.scrollY
|
||||
document.body.style.position = "fixed"
|
||||
document.body.style.top = `-${scrollY.current}px`
|
||||
document.body.style.width = "100%"
|
||||
} else {
|
||||
document.body.style.position = ""
|
||||
document.body.style.top = ""
|
||||
document.body.style.width = ""
|
||||
window.scrollTo({
|
||||
top: scrollY.current,
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.position = ""
|
||||
document.body.style.top = ""
|
||||
}
|
||||
}, [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}>
|
||||
<div className={styles.summaryAccordion}>
|
||||
<Summary
|
||||
booking={booking}
|
||||
rooms={rooms}
|
||||
isMember={isUserLoggedIn}
|
||||
totalPrice={totalPriceToShow}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.bottomSheet}>
|
||||
<button
|
||||
data-open={isSummaryOpen}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
toggleSummaryOpen()
|
||||
}}
|
||||
className={styles.priceDetailsButton}
|
||||
>
|
||||
<Caption>{intl.formatMessage({ id: "Total price" })}</Caption>
|
||||
<Subtitle>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.price,
|
||||
totalPriceToShow.local.currency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Caption color="baseTextHighContrast" type="underline">
|
||||
{intl.formatMessage({ id: "See details" })}
|
||||
</Caption>
|
||||
</button>
|
||||
<Button
|
||||
intent="primary"
|
||||
theme="base"
|
||||
size="large"
|
||||
type="submit"
|
||||
fullWidth
|
||||
disabled={!isAllRoomsSelected}
|
||||
>
|
||||
{intl.formatMessage({ id: "Continue" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr 7.5em;
|
||||
|
||||
transition: 0.5s ease-in-out;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
align-content: end;
|
||||
}
|
||||
|
||||
.bottomSheet {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x5);
|
||||
align-items: flex-start;
|
||||
transition: 0.5s ease-in-out;
|
||||
max-width: var(--max-width-page);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
display: block;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: start;
|
||||
transition: padding 0.5s ease-in-out;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] {
|
||||
grid-template-rows: 1fr 7.5em;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .bottomSheet {
|
||||
grid-template-columns: 0fr auto;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .priceDetailsButton {
|
||||
animation: fadeOut 0.3s ease-out;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper[data-open="false"] .priceDetailsButton {
|
||||
animation: fadeIn 0.8s ease-in;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-height: 50dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.summaryAccordion {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.bottomSheet {
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x7);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
.summary {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "date button";
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
grid-area: button;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
margin-right: calc(0px - var(--Spacing-x2));
|
||||
}
|
||||
|
||||
.date {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: flex-start;
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.addOns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rateDetailsPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry > :last-child {
|
||||
justify-items: flex-end;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bottomDivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
width: 560px;
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin-top: var(--Spacing-x3);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
.terms .termsIcon {
|
||||
margin-right: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.bottomDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary .header .chevronButton {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
"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) {
|
||||
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) => {
|
||||
const isMainRoom = index + 1 === 1
|
||||
return (
|
||||
<div key={index} className={styles.roomSummary}>
|
||||
{rateSummary.length > 1 ? (
|
||||
<>
|
||||
<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 && isMainRoom
|
||||
? room.member?.rateCode
|
||||
: room.public.rateCode
|
||||
)}
|
||||
</Caption>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{room.roomType}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{getRateDetails(
|
||||
isUserLoggedIn && room.member && isMainRoom
|
||||
? room.member?.rateCode
|
||||
: room.public.rateCode
|
||||
)}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</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(
|
||||
{ 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
bottom: -100%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
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;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summaryPriceContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x4);
|
||||
padding-top: var(--Spacing-x2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.promoContainer {
|
||||
display: none;
|
||||
max-width: 264px;
|
||||
}
|
||||
|
||||
.summaryPrice {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.petInfo {
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding-left: var(--Spacing-x2);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summaryPriceTextDesktop {
|
||||
align-self: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.continueButton {
|
||||
margin-left: auto;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.summaryPriceTextMobile {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobileSummary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.summary {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import {
|
||||
type RoomPackage,
|
||||
RoomPackageCodeEnum,
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
|
||||
export const calculateTotalPrice = (
|
||||
selectedRateSummary: Rate[],
|
||||
isUserLoggedIn: boolean,
|
||||
petRoomPackage: RoomPackage | undefined
|
||||
) => {
|
||||
return selectedRateSummary.reduce<Price>(
|
||||
(total, room, idx) => {
|
||||
const priceToUse =
|
||||
isUserLoggedIn && room.member && idx + 1 === 1
|
||||
? room.member
|
||||
: room.public
|
||||
const isPetRoom = room.features.find(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
|
||||
let petRoomPrice = 0
|
||||
if (
|
||||
petRoomPackage &&
|
||||
isPetRoom &&
|
||||
room.package === RoomPackageCodeEnum.PET_ROOM
|
||||
) {
|
||||
petRoomPrice = Number(petRoomPackage.localPrice.totalPrice)
|
||||
}
|
||||
|
||||
return {
|
||||
local: {
|
||||
currency: priceToUse.localPrice.currency,
|
||||
price:
|
||||
total.local.price +
|
||||
priceToUse.localPrice.pricePerStay +
|
||||
petRoomPrice,
|
||||
},
|
||||
requested: priceToUse.requestedPrice
|
||||
? {
|
||||
currency: priceToUse.requestedPrice.currency,
|
||||
price:
|
||||
(total.requested?.price ?? 0) +
|
||||
priceToUse.requestedPrice.pricePerStay +
|
||||
petRoomPrice,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: selectedRateSummary[0].public.localPrice.currency,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"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 Chip from "@/components/TempDesignSystem/Chip"
|
||||
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"
|
||||
|
||||
export default function SelectedRoomPanel() {
|
||||
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 },
|
||||
isMainRoom,
|
||||
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 rate =
|
||||
isUserLoggedIn && isMainRoom && selectedRate?.product.productType.member
|
||||
? selectedRate?.product.productType.member
|
||||
: selectedRate?.product.productType.public
|
||||
|
||||
return (
|
||||
<div className={styles.selectedRoomPanel}>
|
||||
<div className={styles.content}>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: roomNr }
|
||||
)}
|
||||
</Caption>
|
||||
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
|
||||
{selectedRate?.roomType}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{rate?.rateCode ? getRateDetails(rate.rateCode) : null}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{rate?.localPrice.pricePerNight} {rate?.localPrice.currency}/
|
||||
{intl.formatMessage({ id: "night" })}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.imageContainer}>
|
||||
{images?.[0]?.imageSizes?.tiny ? (
|
||||
<Image
|
||||
alt={selectedRate?.roomType ?? images[0].metaData?.altText ?? ""}
|
||||
className={styles.img}
|
||||
height={300}
|
||||
src={images[0].imageSizes.tiny}
|
||||
width={600}
|
||||
/>
|
||||
) : null}
|
||||
<div className={styles.modifyButtonContainer}>
|
||||
<Button clean onClick={modifyRate}>
|
||||
<Chip size="small" variant="uiTextHighContrast">
|
||||
<EditIcon />
|
||||
{intl.formatMessage({ id: "Modify" })}
|
||||
</Chip>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.selectedRoomPanel {
|
||||
display: grid;
|
||||
grid-template-areas: "content image";
|
||||
grid-template-columns: 1fr 190px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
}
|
||||
|
||||
.img {
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
height: auto;
|
||||
max-height: 105px;
|
||||
object-fit: fill;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modifyButtonContainer {
|
||||
bottom: var(--Spacing-x1);
|
||||
position: absolute;
|
||||
right: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
div.selectedRoomPanel p.subtitle {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.selectedRoomPanel {
|
||||
gap: var(--Spacing-x1);
|
||||
grid-template-areas: "image" "content";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
.img {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.img {
|
||||
max-height: 190px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { ChevronUpIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
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 {
|
||||
actions: { closeSection },
|
||||
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} data-multiroom="true">
|
||||
<div className={styles.header}>
|
||||
{selectedRate && !isActiveRoom ? null : (
|
||||
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: roomNr }
|
||||
)}
|
||||
,{" "}
|
||||
{bookingRoom.childrenInRoom?.length
|
||||
? adultsAndChildrenMsg
|
||||
: onlyAdultsMsg}
|
||||
</Subtitle>
|
||||
)}
|
||||
{selectedRate && isActiveRoom ? (
|
||||
<Button
|
||||
intent="text"
|
||||
onClick={closeSection}
|
||||
size="medium"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
>
|
||||
{intl.formatMessage({ id: "Close" })}
|
||||
<ChevronUpIcon height={20} width={20} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={classNames}>
|
||||
<div className={styles.roomPanel}>
|
||||
<SelectedRoomPanel />
|
||||
</div>
|
||||
<div className={styles.roomSelectionPanel}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.roomContainer {
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.roomPanel,
|
||||
.roomSelectionPanel {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
grid-template-rows 0.3s ease;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.roomPanel > * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roomSelectionPanel {
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.roomSelectionPanelContainer.active .roomSelectionPanel,
|
||||
.roomSelectionPanelContainer.selected .roomPanel {
|
||||
grid-template-rows: 1fr;
|
||||
height: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.roomSelectionPanelContainer.active .roomPanel {
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.roomSelectionPanelContainer.selected .roomSelectionPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.roomContainer {
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./multiRoomWrapper.module.css"
|
||||
|
||||
export const roomSelectionPanelVariants = cva(
|
||||
styles.roomSelectionPanelContainer,
|
||||
{
|
||||
variants: {
|
||||
active: {
|
||||
true: styles.active,
|
||||
},
|
||||
selected: {
|
||||
true: styles.selected,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
active: false,
|
||||
selected: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
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 { calculatePricesPerNight } from "./utils"
|
||||
|
||||
import styles from "./priceList.module.css"
|
||||
|
||||
import type { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
|
||||
export default function PriceList({
|
||||
publicPrice = {},
|
||||
memberPrice = {},
|
||||
petRoomPackage,
|
||||
}: PriceListProps) {
|
||||
const intl = useIntl()
|
||||
const { isMainRoom } = useRoomContext()
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
|
||||
const { localPrice: publicLocalPrice, requestedPrice: publicRequestedPrice } =
|
||||
publicPrice
|
||||
const { localPrice: memberLocalPrice, requestedPrice: memberRequestedPrice } =
|
||||
memberPrice
|
||||
|
||||
const petRoomLocalPrice = petRoomPackage?.localPrice
|
||||
const petRoomRequestedPrice = petRoomPackage?.requestedPrice
|
||||
|
||||
const showRequestedPrice =
|
||||
publicRequestedPrice &&
|
||||
memberRequestedPrice &&
|
||||
publicRequestedPrice.currency !== publicLocalPrice.currency
|
||||
const searchParams = useSearchParams()
|
||||
const fromDate = searchParams.get("fromDate")
|
||||
const toDate = searchParams.get("toDate")
|
||||
|
||||
let nights = 1
|
||||
|
||||
if (fromDate && toDate) {
|
||||
nights = dt(toDate).diff(dt(fromDate), "days")
|
||||
}
|
||||
|
||||
const {
|
||||
totalPublicLocalPricePerNight,
|
||||
totalMemberLocalPricePerNight,
|
||||
totalPublicRequestedPricePerNight,
|
||||
totalMemberRequestedPricePerNight,
|
||||
} = calculatePricesPerNight({
|
||||
publicLocalPrice,
|
||||
memberLocalPrice,
|
||||
publicRequestedPrice,
|
||||
memberRequestedPrice,
|
||||
petRoomLocalPrice,
|
||||
petRoomRequestedPrice,
|
||||
nights,
|
||||
})
|
||||
|
||||
return (
|
||||
<dl className={styles.priceList}>
|
||||
{isUserLoggedIn && isMainRoom ? null : (
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption
|
||||
type="bold"
|
||||
color={
|
||||
totalPublicLocalPricePerNight
|
||||
? "uiTextHighContrast"
|
||||
: "disabled"
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Standard price" })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
{publicLocalPrice ? (
|
||||
<div className={styles.price}>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{totalPublicLocalPricePerNight}
|
||||
</Subtitle>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{publicLocalPrice.currency}
|
||||
<span className={styles.perNight}>
|
||||
/{intl.formatMessage({ id: "night" })}
|
||||
</span>
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
<Subtitle type="two" color="baseTextDisabled">
|
||||
{intl.formatMessage({ id: "N/A" })}
|
||||
</Subtitle>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption type="bold" color={memberLocalPrice ? "red" : "disabled"}>
|
||||
{intl.formatMessage({ id: "Member price" })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
{memberLocalPrice ? (
|
||||
<div className={styles.price}>
|
||||
<Subtitle type="two" color="red">
|
||||
{totalMemberLocalPricePerNight}
|
||||
</Subtitle>
|
||||
<Body color="red" textTransform="bold">
|
||||
{memberLocalPrice.currency}
|
||||
<span className={styles.perNight}>
|
||||
/{intl.formatMessage({ id: "night" })}
|
||||
</span>
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
<Body textTransform="bold" color="disabled">
|
||||
-
|
||||
</Body>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{showRequestedPrice && (
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{isUserLoggedIn
|
||||
? intl.formatMessage(
|
||||
{ id: "{memberPrice} {currency}" },
|
||||
{
|
||||
memberPrice: totalMemberRequestedPricePerNight,
|
||||
currency: publicRequestedPrice.currency,
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{ id: "{publicPrice}/{memberPrice} {currency}" },
|
||||
{
|
||||
publicPrice: totalPublicRequestedPricePerNight,
|
||||
memberPrice: totalMemberRequestedPricePerNight,
|
||||
currency: publicRequestedPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.priceList {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.priceTable {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.perNight {
|
||||
font-weight: 400;
|
||||
font-size: var(--typography-Caption-Regular-fontSize);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CalculatePricesPerNightProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
|
||||
export function calculatePricesPerNight({
|
||||
publicLocalPrice,
|
||||
memberLocalPrice,
|
||||
publicRequestedPrice,
|
||||
memberRequestedPrice,
|
||||
petRoomLocalPrice,
|
||||
petRoomRequestedPrice,
|
||||
nights,
|
||||
}: CalculatePricesPerNightProps) {
|
||||
const totalPublicLocalPricePerNight = publicLocalPrice
|
||||
? petRoomLocalPrice
|
||||
? Math.floor(
|
||||
Number(publicLocalPrice.pricePerNight) +
|
||||
Number(petRoomLocalPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(publicLocalPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
const totalMemberLocalPricePerNight = memberLocalPrice
|
||||
? petRoomLocalPrice
|
||||
? Math.floor(
|
||||
Number(memberLocalPrice.pricePerNight) +
|
||||
Number(petRoomLocalPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(memberLocalPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
const totalPublicRequestedPricePerNight = publicRequestedPrice
|
||||
? petRoomRequestedPrice
|
||||
? Math.floor(
|
||||
Number(publicRequestedPrice.pricePerNight) +
|
||||
Number(petRoomRequestedPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(publicRequestedPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
const totalMemberRequestedPricePerNight = memberRequestedPrice
|
||||
? petRoomRequestedPrice
|
||||
? Math.floor(
|
||||
Number(memberRequestedPrice.pricePerNight) +
|
||||
Number(petRoomRequestedPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(memberRequestedPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
return {
|
||||
totalPublicLocalPricePerNight,
|
||||
totalMemberLocalPricePerNight,
|
||||
totalPublicRequestedPricePerNight,
|
||||
totalMemberRequestedPricePerNight,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
.card,
|
||||
.noPricesCard {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border: 1px solid var(--Base-Surface-Secondary-light-Normal);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noPricesCard {
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.noPricesCard:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 100px;
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
border: 2px solid var(--Base-Border-Inverted);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="radio"].radio {
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + .card {
|
||||
border: 1px solid var(--Primary-Dark-On-Surface-Divider);
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + .card .checkIcon {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.priceType {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
grid-area: chevron;
|
||||
height: 100%;
|
||||
justify-self: flex-end;
|
||||
padding: 1px 0 0 0;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.noPricesLabel {
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
text-align: center;
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
margin: 0 auto var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.terms {
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.termsIcon {
|
||||
padding-right: var(--Spacing-x1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
|
||||
import Modal from "@/components/Modal"
|
||||
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 { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import PriceTable from "./PriceList"
|
||||
|
||||
import styles from "./flexibilityOption.module.css"
|
||||
|
||||
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
|
||||
export default function FlexibilityOption({
|
||||
features,
|
||||
isSelected,
|
||||
paymentTerm,
|
||||
priceInformation,
|
||||
petRoomPackage,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
title,
|
||||
}: FlexibilityOptionProps) {
|
||||
const intl = useIntl()
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const {
|
||||
actions: { selectRate },
|
||||
isMainRoom,
|
||||
roomNr,
|
||||
} = useRoomContext()
|
||||
|
||||
function handleSelect() {
|
||||
if (product) {
|
||||
selectRate({
|
||||
features,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className={styles.noPricesCard}>
|
||||
<div className={styles.header}>
|
||||
<InfoCircleIcon width={16} height={16} color="uiTextMediumContrast" />
|
||||
<div className={styles.priceType}>
|
||||
<Caption>{title}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<Label size="regular" className={styles.noPricesLabel}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({ id: "No prices available" })}
|
||||
</Caption>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { public: publicPrice, member: memberPrice } = product.productType
|
||||
const rate =
|
||||
isUserLoggedIn && isMainRoom && memberPrice ? memberPrice : publicPrice
|
||||
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
checked={isSelected}
|
||||
className={styles.radio}
|
||||
name={`rateCode-${roomNr}-${rate.rateCode}`}
|
||||
onChange={handleSelect}
|
||||
type="radio"
|
||||
value={rate.rateCode}
|
||||
/>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<InfoCircleIcon
|
||||
width={16}
|
||||
height={16}
|
||||
color="uiTextMediumContrast"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={title}
|
||||
subtitle={paymentTerm}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{priceInformation?.map((info) => (
|
||||
<Body
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.termsText}
|
||||
>
|
||||
<CheckIcon
|
||||
color="uiSemanticSuccess"
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.termsIcon}
|
||||
></CheckIcon>
|
||||
{info}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<div className={styles.priceType}>
|
||||
<Caption color="uiTextHighContrast">{title}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<PriceTable
|
||||
memberPrice={memberPrice}
|
||||
petRoomPackage={petRoomPackage}
|
||||
publicPrice={publicPrice}
|
||||
/>
|
||||
|
||||
<div className={styles.checkIcon}>
|
||||
<CheckIcon color="white" height="16" width="16" />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"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">∙</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{roomSize} m²" },
|
||||
{ roomSize: roomSize.min }
|
||||
)}
|
||||
</Caption>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Caption color="uiTextMediumContrast">∙</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{roomSizeMin} - {roomSizeMax} m²" },
|
||||
{
|
||||
roomSizeMin: roomSize.min,
|
||||
roomSizeMax: roomSize.max,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
export const cardVariants = cva(styles.card, {
|
||||
variants: {
|
||||
availability: {
|
||||
noAvailability: styles.noAvailability,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,322 @@
|
||||
"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
|
||||
>,
|
||||
roomNr: number
|
||||
) {
|
||||
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||
return msgs.scandicgo
|
||||
}
|
||||
|
||||
if (userIsLoggedIn && memberBreakfastIncluded && roomNr === 1) {
|
||||
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 { isMainRoom, roomNr, 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,
|
||||
roomNr
|
||||
)
|
||||
|
||||
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 { images, name, occupancy, roomSize } = 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 && isMainRoom ? memberRate : publicRate
|
||||
return getRate(key)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<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}>
|
||||
{occupancy && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{occupancy.max === occupancy.min
|
||||
? intl.formatMessage(
|
||||
{ id: "guests.plural" },
|
||||
{ guests: occupancy.max }
|
||||
)
|
||||
: intl.formatMessage({ id: "guests.span" }, occupancy)}
|
||||
</Caption>
|
||||
)}
|
||||
<RoomSize roomSize={roomSize} />
|
||||
<div className={styles.toggleSidePeek}>
|
||||
{roomConfiguration.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId.toString()}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
title={intl.formatMessage({ id: "Room details" })}
|
||||
/>
|
||||
)}
|
||||
</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 className={styles.container}>
|
||||
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
|
||||
<>
|
||||
{/** The empty div is used to allow for subgrid to align rows */}
|
||||
<div></div>
|
||||
<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">{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
|
||||
}
|
||||
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,100 @@
|
||||
.card {
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: grid;
|
||||
font-size: 14px;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
grid-row: span 7;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: subgrid;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--Spacing-x2) var(--Spacing-x2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div[data-multiroom="true"] .card {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card.noAvailability {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
margin: 0 calc(-1 * var(--Spacing-x2));
|
||||
min-height: 190px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div[data-multiroom="true"] .imageContainer {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chipContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
left: 12px;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.card .imageContainer img {
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
|
||||
max-width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.specification {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggleSidePeek {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.specification .toggleSidePeek button {
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.roomDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.name {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-row: span 4;
|
||||
grid-template-rows: subgrid;
|
||||
}
|
||||
|
||||
.noRooms {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
margin: 0;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
@@ -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,54 @@
|
||||
"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 />
|
||||
<ul className={styles.roomList}>
|
||||
{rooms.map((roomConfiguration) => (
|
||||
<RoomCard
|
||||
key={roomConfiguration.roomTypeCode}
|
||||
roomConfiguration={roomConfiguration}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.roomList {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.roomList > li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.container {
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width-page);
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
/* used to hide overflowing rows */
|
||||
grid-template-rows: auto;
|
||||
grid-auto-rows: 0;
|
||||
overflow: hidden;
|
||||
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
|
||||
|
||||
import styles from "./RoomsContainerSkeleton.module.css"
|
||||
|
||||
type Props = {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function RoomsContainerSkeleton({ count = 4 }: Props) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}></div>
|
||||
<div className={styles.skeletonContainer}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
import { useSession } from "next-auth/react"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
import RatesProvider from "@/providers/RatesProvider"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import { useHotelPackages, useRoomsAvailability } from "../utils"
|
||||
import RateSummary from "./RateSummary"
|
||||
import Rooms from "./Rooms"
|
||||
import { RoomsContainerSkeleton } from "./RoomsContainerSkeleton"
|
||||
|
||||
import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer"
|
||||
|
||||
export function RoomsContainer({
|
||||
adultArray,
|
||||
booking,
|
||||
childArray,
|
||||
fromDate,
|
||||
hotelId,
|
||||
hotelData,
|
||||
toDate,
|
||||
}: RoomsContainerProps) {
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const lang = useLang()
|
||||
|
||||
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
|
||||
const toDateString = dt(toDate).format("YYYY-MM-DD")
|
||||
|
||||
const uniqueAdultsCount = Array.from(new Set(adultArray))
|
||||
|
||||
const { isPending: isLoadingAvailability, data: roomsAvailability } =
|
||||
useRoomsAvailability(
|
||||
uniqueAdultsCount,
|
||||
hotelId,
|
||||
fromDateString,
|
||||
toDateString,
|
||||
lang,
|
||||
childArray
|
||||
)
|
||||
|
||||
const { data: packages, isPending: isLoadingPackages } = useHotelPackages(
|
||||
adultArray,
|
||||
childArray,
|
||||
fromDateString,
|
||||
toDateString,
|
||||
hotelId,
|
||||
lang
|
||||
)
|
||||
|
||||
if (isLoadingAvailability || isLoadingPackages) {
|
||||
return <RoomsContainerSkeleton />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
import stringify from "json-stable-stringify-without-jsonify"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { getHotel } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { getValidDates } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/getValidDates"
|
||||
import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils"
|
||||
import HotelInfoCard, {
|
||||
HotelInfoCardSkeleton,
|
||||
} from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
||||
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/RoomsContainer"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import { convertSearchParamsToObj } from "@/utils/url"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function SelectRatePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
|
||||
setLang(params.lang)
|
||||
const searchDetails = await getHotelSearchDetails({ searchParams })
|
||||
if (!searchDetails?.hotel) {
|
||||
return notFound()
|
||||
}
|
||||
const { hotel, adultsInRoom, childrenInRoom, selectHotelParams } =
|
||||
searchDetails
|
||||
|
||||
const { fromDate, toDate } = getValidDates(
|
||||
selectHotelParams.fromDate,
|
||||
selectHotelParams.toDate
|
||||
)
|
||||
|
||||
const hotelData = await getHotel({
|
||||
hotelId: hotel.id,
|
||||
isCardOnlyPayment: false,
|
||||
language: params.lang,
|
||||
})
|
||||
|
||||
const arrivalDate = fromDate.toDate()
|
||||
const departureDate = toDate.toDate()
|
||||
|
||||
const pageTrackingData: TrackingSDKPageData = {
|
||||
pageId: "select-rate",
|
||||
domainLanguage: params.lang,
|
||||
channel: TrackingChannelEnum["hotelreservation"],
|
||||
pageName: "hotelreservation|select-rate",
|
||||
siteSections: "hotelreservation|select-rate",
|
||||
pageType: "bookingroomsandratespage",
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
const hotelsTrackingData: TrackingSDKHotelInfo = {
|
||||
searchTerm: selectHotelParams.city ?? hotel?.name,
|
||||
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
|
||||
departureDate: format(departureDate, "yyyy-MM-dd"),
|
||||
noOfAdults: adultsInRoom[0], // TODO: Handle multiple rooms
|
||||
noOfChildren: childrenInRoom?.length,
|
||||
ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
|
||||
childBedPreference: childrenInRoom
|
||||
?.map((c) => ChildBedMapEnum[c.bed])
|
||||
.join("|"),
|
||||
noOfRooms: 1, // // TODO: Handle multiple rooms
|
||||
duration: differenceInCalendarDays(departureDate, arrivalDate),
|
||||
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
||||
searchType: "hotel",
|
||||
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
|
||||
country: hotelData?.hotel.address.country,
|
||||
hotelID: hotel?.id,
|
||||
region: hotelData?.hotel.address.city,
|
||||
}
|
||||
|
||||
const hotelId = +hotel.id
|
||||
|
||||
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
|
||||
const suspenseKey = stringify(searchParams)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<HotelInfoCardSkeleton />}>
|
||||
<HotelInfoCard hotelData={hotelData} />
|
||||
</Suspense>
|
||||
|
||||
<RoomsContainer
|
||||
hotelData={hotelData}
|
||||
adultArray={adultsInRoom}
|
||||
booking={booking}
|
||||
childArray={childrenInRoom}
|
||||
fromDate={arrivalDate}
|
||||
hotelId={hotelId}
|
||||
toDate={departureDate}
|
||||
/>
|
||||
|
||||
<Suspense key={`${suspenseKey}-tracking`} fallback={null}>
|
||||
<TrackingSDK
|
||||
pageData={pageTrackingData}
|
||||
hotelInfo={hotelsTrackingData}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function useRoomsAvailability(
|
||||
uniqueAdultsCount: number[],
|
||||
hotelId: number,
|
||||
fromDateString: string,
|
||||
toDateString: string,
|
||||
lang: Lang,
|
||||
childArray?: Child[]
|
||||
) {
|
||||
const returnValue =
|
||||
trpc.hotel.availability.roomsCombinedAvailability.useQuery({
|
||||
hotelId,
|
||||
roomStayStartDate: fromDateString,
|
||||
roomStayEndDate: toDateString,
|
||||
uniqueAdultsCount,
|
||||
childArray,
|
||||
lang,
|
||||
})
|
||||
|
||||
const combinedAvailability = returnValue.data?.length
|
||||
? combineRoomAvailabilities(
|
||||
returnValue.data as PromiseSettledResult<RoomsAvailability | null>[]
|
||||
)
|
||||
: null
|
||||
|
||||
return {
|
||||
...returnValue,
|
||||
data: combinedAvailability,
|
||||
}
|
||||
}
|
||||
|
||||
export function useHotelPackages(
|
||||
adultArray: number[],
|
||||
childArray: Child[] | undefined,
|
||||
fromDateString: string,
|
||||
toDateString: string,
|
||||
hotelId: number,
|
||||
lang: Lang
|
||||
) {
|
||||
return trpc.hotel.packages.get.useQuery({
|
||||
adults: adultArray[0], // Using the first adult count
|
||||
children: childArray ? childArray.length : undefined,
|
||||
endDate: toDateString,
|
||||
hotelId: hotelId.toString(),
|
||||
packageCodes: [
|
||||
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
RoomPackageCodeEnum.PET_ROOM,
|
||||
RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
],
|
||||
startDate: fromDateString,
|
||||
lang: lang,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user