chore: cleaning up select-rate

This commit is contained in:
Simon Emanuelsson
2025-02-05 20:18:03 +01:00
parent 3044bc87d1
commit 051bc54e6c
95 changed files with 3269 additions and 3527 deletions

View File

@@ -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)
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<React.ReactNode>(
{ 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>
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,269 @@
"use client"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useRatesStore } from "@/stores/select-rate"
import { getRates } from "@/components/HotelReservation/SelectRate/utils"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formatPrice } from "@/utils/numberFormatting"
import MobileSummary from "./MobileSummary"
import { calculateTotalPrice } from "./utils"
import styles from "./rateSummary.module.css"
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
const {
bookingRooms,
petRoomPackage,
rateSummary,
roomsAvailability,
searchParams,
} = useRatesStore((state) => ({
bookingRooms: state.booking.rooms,
petRoomPackage: state.petRoomPackage,
rateSummary: state.rateSummary,
roomsAvailability: state.roomsAvailability,
searchParams: state.searchParams,
}))
const intl = useIntl()
const router = useRouter()
const params = new URLSearchParams(searchParams)
const [_, startTransition] = useTransition()
if (!roomsAvailability) {
return null
}
const checkInDate = new Date(roomsAvailability.checkInDate)
const checkOutDate = new Date(roomsAvailability.checkOutDate)
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
const totalNights = intl.formatMessage(
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
{ totalNights: nights }
)
const totalAdults = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{ totalAdults: bookingRooms.reduce((acc, room) => acc + room.adults, 0) }
)
const childrenInOneOrMoreRooms = bookingRooms.some(
(room) => room.childrenInRoom?.length
)
const childrenInroom = intl.formatMessage(
{ id: "{totalChildren, plural, one {# child} other {# children}}" },
{
totalChildren: bookingRooms.reduce(
(acc, room) => acc + (room.childrenInRoom?.length ?? 0),
0
),
}
)
const totalChildren = childrenInOneOrMoreRooms ? `, ${childrenInroom}` : ""
const totalRooms = intl.formatMessage(
{ id: "{totalRooms, plural, one {# room} other {# rooms}}" },
{ totalRooms: bookingRooms.length }
)
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
const totalRoomsRequired = bookingRooms.length
const isAllRoomsSelected = rateSummary.length === totalRoomsRequired
const hasMemberRates = rateSummary.some((room) => room.member)
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
const rates = getRates(roomsAvailability.rateDefinitions)
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
const payLater = intl.formatMessage({ id: "Pay later" })
const payNow = intl.formatMessage({ id: "Pay now" })
function getRateDetails(rateCode: string) {
const rate = Object.keys(rates).find((k) =>
rates[k as keyof typeof rates].find((a) => a.rateCode === rateCode)
)
switch (rate) {
case "change":
return `${freeBooking}, ${payNow}`
case "flex":
return `${freeCancelation}, ${payLater}`
case "save":
default:
return `${nonRefundable}, ${payNow}`
}
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
startTransition(() => {
router.push(`details?${params}`)
})
}
if (!rateSummary.length) {
return null
}
const totalPriceToShow = calculateTotalPrice(
rateSummary,
isUserLoggedIn,
petRoomPackage
)
return (
<form action={`details?${params}`} method="GET" onSubmit={handleSubmit}>
<div className={styles.summary}>
<div className={styles.content}>
<div className={styles.summaryText}>
{rateSummary.map((room, index) => (
<div key={index} className={styles.roomSummary}>
<Subtitle color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{ roomIndex: index + 1 }
)}
</Subtitle>
<Body color="uiTextMediumContrast">{room.roomType}</Body>
<Caption color="uiTextMediumContrast">
{getRateDetails(
isUserLoggedIn && room.member
? room.member?.rateCode
: room.public.rateCode
)}
</Caption>
</div>
))}
{/* Render unselected rooms */}
{Array.from({
length: totalRoomsRequired - rateSummary.length,
}).map((_, index) => (
<div key={`unselected-${index}`} className={styles.roomSummary}>
<Subtitle color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{ roomIndex: rateSummary.length + index + 1 }
)}
</Subtitle>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Select room" })}
</Body>
</div>
))}
</div>
<div className={styles.summaryPriceContainer}>
{showMemberDiscountBanner && (
<div className={styles.promoContainer}>
<SignupPromoDesktop
memberPrice={{
amount: rateSummary.reduce((total, room) => {
const memberPrice =
room.member?.localPrice.pricePerStay ?? 0
const isPetRoom = room.features.find(
(feature) =>
feature.code === RoomPackageCodeEnum.PET_ROOM
)
const petRoomPrice =
isPetRoom && petRoomPackage
? Number(petRoomPackage.localPrice.totalPrice)
: 0
return total + memberPrice + petRoomPrice
}, 0),
currency:
rateSummary[0].member?.localPrice.currency ??
rateSummary[0].public.localPrice.currency,
}}
/>
</div>
)}
<div className={styles.summaryPriceTextDesktop}>
<Body>
{intl.formatMessage<React.ReactNode>(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</Body>
<Caption color="uiTextMediumContrast">{summaryPriceText}</Caption>
</div>
<div className={styles.summaryPrice}>
<div className={styles.summaryPriceTextDesktop}>
<Subtitle
color={isUserLoggedIn ? "red" : "uiTextHighContrast"}
textAlign="right"
>
{formatPrice(
intl,
totalPriceToShow.local.price,
totalPriceToShow.local.currency
)}
</Subtitle>
{totalPriceToShow.requested ? (
<Body color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "Approx. {value}" },
{
value: formatPrice(
intl,
totalPriceToShow.requested.price,
totalPriceToShow.requested.currency
),
}
)}
</Body>
) : null}
</div>
<div className={styles.summaryPriceTextMobile}>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Total price" })}
</Caption>
<Subtitle color={isUserLoggedIn ? "red" : "uiTextHighContrast"}>
{formatPrice(
intl,
totalPriceToShow.local.price,
totalPriceToShow.local.currency
)}
</Subtitle>
<Footnote
color="uiTextMediumContrast"
className={styles.summaryPriceTextMobile}
>
{summaryPriceText}
</Footnote>
</div>
<Button
className={styles.continueButton}
disabled={!isAllRoomsSelected}
theme="base"
type="submit"
>
{intl.formatMessage({ id: "Continue" })}
</Button>
</div>
</div>
</div>
<div className={styles.mobileSummary}>
{showMemberDiscountBanner ? <SignupPromoMobile /> : null}
<MobileSummary
isAllRoomsSelected={isAllRoomsSelected}
isUserLoggedIn={isUserLoggedIn}
totalPriceToShow={totalPriceToShow}
/>
</div>
</div>
</form>
)
}

View File

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

View File

@@ -0,0 +1,57 @@
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) => {
const priceToUse =
isUserLoggedIn && room.member ? 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,
}
)
}

View File

@@ -0,0 +1,118 @@
"use client"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { useRatesStore } from "@/stores/select-rate"
import { getRates } from "@/components/HotelReservation/SelectRate/utils"
import { EditIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Room"
import { isValidClientSession } from "@/utils/clientSession"
import styles from "./selectedRoomPanel.module.css"
import type { Room as SelectedRateRoom } from "@/types/components/hotelReservation/selectRate/selectRate"
interface SelectedRoomPanelProps {
room: SelectedRateRoom
}
export default function SelectedRoomPanel({ room }: SelectedRoomPanelProps) {
const intl = useIntl()
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const { rateDefinitions, roomCategories } = useRatesStore((state) => ({
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories,
}))
const {
actions: { modifyRate },
roomNr,
selectedRate,
} = useRoomContext()
const images = roomCategories.find((roomCategory) =>
roomCategory.roomTypes.some(
(roomType) => roomType.code === selectedRate?.roomTypeCode
)
)?.images
if (!rateDefinitions) {
return null
}
const rates = getRates(rateDefinitions)
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
const payLater = intl.formatMessage({ id: "Pay later" })
const payNow = intl.formatMessage({ id: "Pay now" })
function getRateDetails(rateCode: string) {
const rate = Object.keys(rates).find((k) =>
rates[k as keyof typeof rates].find((a) => a.rateCode === rateCode)
)
switch (rate) {
case "change":
return `${freeBooking}, ${payNow}`
case "flex":
return `${freeCancelation}, ${payLater}`
case "save":
default:
return `${nonRefundable}, ${payNow}`
}
}
const rateCode =
isUserLoggedIn && selectedRate?.product.productType.member
? selectedRate?.product.productType.member?.rateCode
: selectedRate?.product.productType.public.rateCode
return (
<div className={styles.selectedRoomPanel}>
<div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{ roomIndex: roomNr }
)}
</Caption>
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
{selectedRate?.roomType}
</Subtitle>
<Body color="uiTextMediumContrast">
{rateCode ? getRateDetails(rateCode) : null}
</Body>
<Body color="uiTextHighContrast">
{selectedRate?.product.productType.public.localPrice.pricePerNight}{" "}
{selectedRate?.product.productType.public.localPrice.currency}/
{intl.formatMessage({ id: "night" })}
</Body>
</div>
<div className={styles.imageAndModifyButtonContainer}>
{images?.[0]?.imageSizes?.tiny && (
<div className={styles.imageContainer}>
<Image
alt={selectedRate?.roomType ?? images[0].metaData?.altText ?? ""}
fill
src={images[0].imageSizes.tiny}
/>
</div>
)}
<div className={styles.modifyButtonContainer}>
<Button variant="icon" size="small" onClick={modifyRate}>
<EditIcon />
{intl.formatMessage({ id: "Modify" })}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
.selectedRoomPanel {
display: flex;
flex-direction: row;
justify-content: space-between;
position: relative;
}
.modifyButtonContainer {
position: absolute;
right: var(--Spacing-x2);
bottom: var(--Spacing-x2);
}
.imageContainer {
width: 187px;
height: 105px;
position: relative;
border-radius: var(--Corner-radius-Small);
overflow: hidden;
}
.titleContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
div.selectedRoomPanel p.subtitle {
padding-bottom: var(--Spacing-x1);
}
@media (max-width: 768px) {
.imageContainer {
width: 120px;
height: 80px;
}
.imageAndModifyButtonContainer {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--Spacing-x1);
}
.modifyButtonContainer {
position: relative;
bottom: 0;
right: 0;
}
}

View File

@@ -0,0 +1,83 @@
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { useRatesStore } from "@/stores/select-rate"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Room"
import SelectedRoomPanel from "./SelectedRoomPanel"
import { roomSelectionPanelVariants } from "./variants"
import styles from "./multiRoomWrapper.module.css"
export default function MultiRoomWrapper({
children,
isMultiRoom,
}: React.PropsWithChildren<{ isMultiRoom: boolean }>) {
const intl = useIntl()
const activeRoom = useRatesStore((state) => state.activeRoom)
const { bookingRoom, isActiveRoom, roomNr, selectedRate } = useRoomContext()
const onlyAdultsMsg = intl.formatMessage(
{ id: "{adults} adults" },
{ adults: bookingRoom.adults }
)
const adultsAndChildrenMsg = intl.formatMessage(
{ id: "{adults} adults, {children} children" },
{
adults: bookingRoom.adults,
children: bookingRoom.childrenInRoom?.length,
}
)
useEffect(() => {
requestAnimationFrame(() => {
const SCROLL_OFFSET = 100
const roomElements = document.querySelectorAll(`.${styles.roomContainer}`)
const selectedRoom = roomElements[activeRoom]
if (selectedRoom) {
const elementPosition = selectedRoom.getBoundingClientRect().top
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET
window.scrollTo({
top: offsetPosition,
behavior: "smooth",
})
}
})
}, [activeRoom])
if (isMultiRoom) {
const classNames = roomSelectionPanelVariants({
active: isActiveRoom,
selected: !!selectedRate && !isActiveRoom,
})
return (
<div className={styles.roomContainer}>
{selectedRate && !isActiveRoom ? null : (
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{ roomIndex: roomNr }
)}
,{" "}
{bookingRoom.childrenInRoom?.length
? adultsAndChildrenMsg
: onlyAdultsMsg}
</Subtitle>
)}
<div className={classNames}>
<div className={styles.roomPanel}>
<SelectedRoomPanel room={bookingRoom} />
</div>
<div className={styles.roomSelectionPanel}>{children}</div>
</div>
</div>
)
}
return children
}

View File

@@ -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-x2);
}
.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.selected .roomPanel {
grid-template-rows: 1fr;
opacity: 1;
height: auto;
}
.roomSelectionPanelContainer.selected .roomSelectionPanel {
display: none;
}
.roomSelectionPanelContainer.active .roomSelectionPanel {
grid-template-rows: 1fr;
opacity: 1;
height: auto;
padding-top: var(--Spacing-x1);
}
div.roomContainer p.subtitle {
padding-bottom: var(--Spacing-x1);
}
@media (max-width: 768px) {
.roomContainer {
padding: var(--Spacing-x2);
}
}

View File

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

View File

@@ -0,0 +1,156 @@
import { useSearchParams } from "next/navigation"
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 { calculatePricesPerNight } from "./utils"
import styles from "./priceList.module.css"
import type { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
export default function PriceList({
isUserLoggedIn,
publicPrice = {},
memberPrice = {},
petRoomPackage,
}: PriceListProps) {
const intl = useIntl()
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 ? 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>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
.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"]: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;
}

View File

@@ -0,0 +1,127 @@
"use client"
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 PriceTable from "./PriceList"
import styles from "./flexibilityOption.module.css"
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
export default function FlexibilityOption({
features,
isSelected,
isUserLoggedIn,
paymentTerm,
priceInformation,
petRoomPackage,
product,
roomType,
roomTypeCode,
title,
}: FlexibilityOptionProps) {
const intl = useIntl()
const {
actions: { selectRate },
} = useRoomContext()
function handleSelect() {
if (product) {
selectRate({
features,
product,
roomType,
roomTypeCode,
})
}
}
if (!product) {
return (
<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
return (
<label>
<input
checked={isSelected}
name={`rateCode-${product.productType.public.rateCode}`}
onChange={handleSelect}
type="radio"
value={publicPrice?.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
isUserLoggedIn={isUserLoggedIn}
publicPrice={publicPrice}
memberPrice={memberPrice}
petRoomPackage={petRoomPackage}
/>
<div className={styles.checkIcon}>
<CheckIcon color="white" height="16" width="16" />
</div>
</div>
</label>
)
}

View File

@@ -0,0 +1,36 @@
"use client"
import { useIntl } from "react-intl"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import type { RoomSizeProps } from "@/types/components/hotelReservation/selectRate/roomCard"
export default function RoomSize({ roomSize }: RoomSizeProps) {
const intl = useIntl()
if (!roomSize) {
return null
}
if (roomSize.min === roomSize.max) {
return (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{roomSize} m²" },
{ roomSize: roomSize.min }
)}
</Caption>
)
}
return (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{roomSizeMin} - {roomSizeMax} m²" },
{
roomSizeMin: roomSize.min,
roomSizeMax: roomSize.max,
}
)}
</Caption>
)
}

View File

@@ -0,0 +1,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: "",
},
},
})

View File

@@ -0,0 +1,317 @@
"use client"
import { useSession } from "next-auth/react"
import { createElement } from "react"
import { useIntl } from "react-intl"
import { useRatesStore } from "@/stores/select-rate"
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
import { getRates } from "@/components/HotelReservation/SelectRate/utils"
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
import { ErrorCircleIcon } from "@/components/Icons"
import ImageGallery from "@/components/ImageGallery"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Room"
import { isValidClientSession } from "@/utils/clientSession"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { cardVariants } from "./cardVariants"
import FlexibilityOption from "./FlexibilityOption"
import RoomSize from "./RoomSize"
import styles from "./roomCard.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
function getBreakfastMessage(
publicBreakfastIncluded: boolean,
memberBreakfastIncluded: boolean,
hotelType: string | undefined,
userIsLoggedIn: boolean,
msgs: Record<"included" | "noSelection" | "scandicgo" | "notIncluded", string>
) {
if (hotelType === HotelTypeEnum.ScandicGo) {
return msgs.scandicgo
}
if (userIsLoggedIn && memberBreakfastIncluded) {
return msgs.included
}
if (publicBreakfastIncluded && memberBreakfastIncluded) {
return msgs.included
}
/** selected and rate does not include breakfast */
if (false) {
return msgs.notIncluded
}
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
return msgs.notIncluded
}
return msgs.noSelection
}
export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const intl = useIntl()
const lessThanFiveRoomsLeft =
roomConfiguration.roomsLeft > 0 && roomConfiguration.roomsLeft < 5
const {
hotelId,
hotelType,
petRoomPackage,
rateDefinitions,
roomCategories,
} = useRatesStore((state) => ({
hotelId: state.booking.hotelId,
hotelType: state.hotelType,
petRoomPackage: state.petRoomPackage,
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories,
}))
const { selectedPackage, selectedRate } = useRoomContext()
const classNames = cardVariants({
availability:
roomConfiguration.status === AvailabilityEnum.NotAvailable
? "noAvailability"
: "default",
})
const breakfastMessages = {
included: intl.formatMessage({ id: "Breakfast is included." }),
notIncluded: intl.formatMessage({
id: "Breakfast selection in next step.",
}),
noSelection: intl.formatMessage({ id: "Select a rate" }),
scandicgo: intl.formatMessage({
id: "Breakfast deal can be purchased at the hotel.",
}),
}
const breakfastMessage = getBreakfastMessage(
roomConfiguration.breakfastIncludedInAllRatesPublic,
roomConfiguration.breakfastIncludedInAllRatesMember,
hotelType,
isUserLoggedIn,
breakfastMessages
)
if (!rateDefinitions) {
return null
}
const rates = getRates(rateDefinitions)
const petRoomPackageSelected =
(selectedPackage === RoomPackageCodeEnum.PET_ROOM && petRoomPackage) ||
undefined
const selectedRoom = roomCategories.find((roomCategory) =>
roomCategory.roomTypes.find(
(roomType) => roomType.code === roomConfiguration.roomTypeCode
)
)
const { name, roomSize, totalOccupancy, images } = selectedRoom || {}
const galleryImages = mapApiImagesToGalleryImages(images || [])
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
const payLater = intl.formatMessage({ id: "Pay later" })
const payNow = intl.formatMessage({ id: "Pay now" })
function getRate(rateCode: string) {
switch (rateCode) {
case "change":
return {
isFlex: false,
notAvailable: false,
title: freeBooking,
}
case "flex":
return {
isFlex: true,
notAvailable: false,
title: freeCancelation,
}
case "save":
return {
isFlex: false,
notAvailable: false,
title: nonRefundable,
}
default:
throw new Error(
`Unknown key for rate, should be "change", "flex" or "save", but got ${rateCode}`
)
}
}
function getRateInfo(product: Product) {
if (
!product.productType.public.rateCode &&
!product.productType.member?.rateCode
) {
const possibleRate = getRate(product.productType.public.rate)
if (possibleRate) {
return {
...possibleRate,
notAvailable: true,
}
}
return {
isFlex: false,
notAvailable: true,
title: "",
}
}
const publicRate = Object.keys(rates).find((k) =>
rates[k as keyof typeof rates].find(
(a) => a.rateCode === product.productType.public.rateCode
)
)
let memberRate
if (product.productType.member) {
memberRate = Object.keys(rates).find((k) =>
rates[k as keyof typeof rates].find(
(a) => a.rateCode === product.productType.member!.rateCode
)
)
}
if (!publicRate || !memberRate) {
throw new Error("We should never make it where without rateCodes")
}
const key = isUserLoggedIn ? memberRate : publicRate
return getRate(key)
}
return (
<li className={classNames}>
<div>
<div className={styles.imageContainer}>
<div className={styles.chipContainer}>
{lessThanFiveRoomsLeft ? (
<span className={styles.chip}>
<Footnote color="burgundy" textTransform="uppercase">
{intl.formatMessage(
{ id: "{amount, number} left" },
{ amount: roomConfiguration.roomsLeft }
)}
</Footnote>
</span>
) : null}
{roomConfiguration.features
.filter((feature) => selectedPackage === feature.code)
.map((feature) => (
<span className={styles.chip} key={feature.code}>
{createElement(getIconForFeatureCode(feature.code), {
color: "burgundy",
height: 16,
width: 16,
})}
</span>
))}
</div>
<ImageGallery
images={galleryImages}
title={roomConfiguration.roomType}
fill
/>
</div>
<div className={styles.specification}>
{totalOccupancy && (
<Caption color="uiTextMediumContrast" className={styles.guests}>
{intl.formatMessage(
{
id: "Max {max, plural, one {{range} guest} other {{range} guests}}",
},
{ max: totalOccupancy.max, range: totalOccupancy.range }
)}
</Caption>
)}
<RoomSize roomSize={roomSize} />
<div className={styles.toggleSidePeek}>
{roomConfiguration.roomTypeCode && (
<ToggleSidePeek
hotelId={hotelId.toString()}
roomTypeCode={roomConfiguration.roomTypeCode}
/>
)}
</div>
</div>
<div className={styles.roomDetails}>
<Subtitle className={styles.name} type="two">
{name}
</Subtitle>
{/* Out of scope for now
<Body>{descriptions?.short}</Body>
*/}
</div>
</div>
<div className={styles.container}>
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
<div className={styles.noRoomsContainer}>
<div className={styles.noRooms}>
<ErrorCircleIcon color="red" width={16} />
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({
id: "This room is not available",
})}
</Caption>
</div>
</div>
) : (
<>
<Caption color="uiTextHighContrast" type="bold">
{breakfastMessage}
</Caption>
{roomConfiguration.products.map((product) => {
const rate = getRateInfo(product)
const isSelectedRateCode =
selectedRate?.product.productType.public.rateCode ===
product.productType.public.rateCode ||
selectedRate?.product.productType.member?.rateCode ===
product.productType.member?.rateCode
return (
<FlexibilityOption
key={product.productType.public.rateCode}
features={roomConfiguration.features}
isSelected={
isSelectedRateCode &&
selectedRate?.roomTypeCode ===
roomConfiguration.roomTypeCode
}
isUserLoggedIn={isUserLoggedIn}
paymentTerm={rate.isFlex ? payLater : payNow}
petRoomPackage={petRoomPackageSelected}
product={rate.notAvailable ? undefined : product}
roomType={roomConfiguration.roomType}
roomTypeCode={roomConfiguration.roomTypeCode}
title={rate.title}
/>
)
})}
</>
)}
</div>
</li>
)
}

View File

@@ -0,0 +1,112 @@
.card {
font-size: 14px;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: subgrid;
background-color: #fff;
border-radius: var(--Corner-radius-Large);
border: 1px solid var(--Base-Border-Subtle);
position: relative;
justify-content: space-between;
grid-row: span 5;
}
.card.noAvailability {
opacity: 0.6;
}
.specification {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--Spacing-x1);
padding: 0 var(--Spacing-x1) 0 var(--Spacing-x-one-and-half);
}
.specification .guests {
border-right: 1px solid var(--Base-Border-Subtle);
padding-right: var(--Spacing-x1);
}
.toggleSidePeek {
margin-left: auto;
}
.toggleSidePeek button {
text-align: start;
}
.container {
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x2);
display: grid;
grid-template-rows: subgrid;
gap: var(--Spacing-x-one-and-half);
grid-row: span 4;
}
/* Make sure rows with only unavailable rooms still has a min-height */
.container:has(.noRoomsContainer) {
min-height: 400px;
grid-template-rows: auto repeat(3, 1fr);
}
.roomDetails {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x2);
}
.name {
display: inline-block;
}
.card img {
max-width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
}
.flexibilityOptions {
display: grid;
gap: var(--Spacing-x2);
grid-template-rows: repeat(3, 1fr);
}
.chipContainer {
position: absolute;
z-index: 1;
top: 12px;
left: 12px;
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
}
.chip {
background-color: var(--Main-Grey-White);
padding: var(--Spacing-x-half) var(--Spacing-x1);
border-radius: var(--Corner-radius-Small);
}
.imageContainer {
min-height: 190px;
position: relative;
}
.noRoomsContainer {
height: 100%;
width: 100%;
}
.noRooms {
padding: var(--Spacing-x2);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Medium);
margin: 0;
display: flex;
gap: var(--Spacing-x1);
}

View File

@@ -0,0 +1,71 @@
"use client"
import {
type Key,
ToggleButton,
ToggleButtonGroup,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { useRatesStore } from "@/stores/select-rate"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { useRoomContext } from "@/contexts/Room"
import styles from "./roomFilter.module.css"
import type { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
export default function RoomTypeFilter() {
const filterOptions = useRatesStore((state) => state.filterOptions)
const {
actions: { selectFilter },
rooms,
selectedPackage,
} = useRoomContext()
const intl = useIntl()
// const tooltipText = intl.formatMessage({
// id: "Pet-friendly rooms have an additional fee of 20 EUR per stay",
// })
function handleChange(selectedFilter: Set<Key>) {
if (selectedFilter.size) {
const selected = selectedFilter.values().next()
selectFilter(selected.value as RoomPackageCodeEnum)
} else {
selectFilter(undefined)
}
}
return (
<div className={styles.container}>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{
id: "{numberOfRooms, plural, one {# room type} other {# room types}} available",
},
{ numberOfRooms: rooms.length }
)}
</Caption>
<ToggleButtonGroup
aria-label="Filter"
className={styles.roomsFilter}
defaultSelectedKeys={selectedPackage ? [selectedPackage] : undefined}
onSelectionChange={handleChange}
orientation="horizontal"
>
{filterOptions.map((option) => (
<ToggleButton
aria-label={option.description}
className={styles.radio}
id={option.code}
key={option.itemCode}
>
<div className={styles.circle} />
<Caption color="uiTextHighContrast">{option.description}</Caption>
</ToggleButton>
))}
</ToggleButtonGroup>
</div>
)
}

View File

@@ -0,0 +1,70 @@
.container {
align-items: center;
display: grid;
gap: var(--Spacing-x3);
}
.roomsFilter {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x2);
justify-content: flex-start;
}
.radio {
align-items: center;
background: none;
border: none;
cursor: pointer;
display: flex;
gap: var(--Spacing-x-one-and-half);
outline: none;
padding: 0;
}
.circle {
border: 1px solid var(--UI-Input-Controls-Border-Normal);
border-radius: 50%;
grid-area: input;
height: 24px;
position: relative;
transition: background-color 200ms ease;
width: 24px;
}
.radio:hover .circle {
background-color: var(--UI-Input-Controls-Fill-Selected-hover);
}
.radio[data-selected="true"] .circle {
background-color: var(--UI-Input-Controls-Fill-Selected);
}
.radio[data-selected="true"]:hover .circle {
background-color: var(--UI-Input-Controls-Fill-Selected-hover);
border-color: var(--UI-Input-Controls-Border-Hover);
}
.radio[data-selected="true"] .circle::after {
background-color: var(--Main-Grey-White);
border-radius: 50%;
content: "";
height: 8px;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 8px;
}
@media screen and (min-width: 768px) {
.container {
grid-template-columns: auto 1fr;
}
.roomsFilter {
flex-wrap: nowrap;
justify-content: flex-end;
}
}

View File

@@ -0,0 +1,56 @@
"use client"
import { useIntl } from "react-intl"
import { alternativeHotels } from "@/constants/routes/hotelReservation"
import Alert from "@/components/TempDesignSystem/Alert"
import { useRoomContext } from "@/contexts/Room"
import useLang from "@/hooks/useLang"
import RoomCard from "./RoomCard"
import RoomTypeFilter from "./RoomTypeFilter"
import styles from "./roomSelectionPanel.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function RoomSelectionPanel() {
const { rooms } = useRoomContext()
const intl = useIntl()
const lang = useLang()
const noAvailableRooms = rooms.every(
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
)
return (
<>
{noAvailableRooms ? (
<div className={styles.hotelAlert}>
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}
text={intl.formatMessage({
id: "There are no rooms available that match your request.",
})}
link={{
title: intl.formatMessage({ id: "See alternative hotels" }),
url: `${alternativeHotels(lang)}`,
keepSearchParams: true,
}}
/>
</div>
) : null}
<RoomTypeFilter />
<div className={styles.wrapper}>
<ul className={styles.roomList}>
{rooms.map((roomConfiguration) => (
<RoomCard
key={roomConfiguration.roomTypeCode}
roomConfiguration={roomConfiguration}
/>
))}
</ul>
</div>
</>
)
}

View File

@@ -0,0 +1,22 @@
.roomList {
list-style: none;
display: grid;
gap: var(--Spacing-x3);
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
}
.roomList > li {
width: 100%;
}
.roomList input[type="radio"] {
opacity: 0;
position: fixed;
width: 0;
}
.hotelAlert {
width: 100%;
margin: 0 auto;
padding: var(--Spacing-x-one-and-half);
}

View File

@@ -0,0 +1,69 @@
"use client"
import { useEffect } from "react"
import { useRatesStore } from "@/stores/select-rate"
import RoomProvider from "@/providers/RoomProvider"
import { trackLowestRoomPrice } from "@/utils/tracking"
import MultiRoomWrapper from "./MultiRoomWrapper"
import RoomSelectionPanel from "./RoomSelectionPanel"
import styles from "./rooms.module.css"
export default function Rooms() {
const {
arrivalDate,
bookingRooms,
departureDate,
hotelId,
rooms,
visibleRooms,
} = useRatesStore((state) => ({
arrivalDate: state.booking.fromDate,
bookingRooms: state.booking.rooms,
departureDate: state.booking.toDate,
hotelId: state.booking.hotelId,
rooms: state.rooms,
visibleRooms: state.allRooms,
}))
useEffect(() => {
const pricesWithCurrencies = visibleRooms.flatMap((room) =>
room.products.map((product) => ({
price: product.productType.public.localPrice.pricePerNight,
currency: product.productType.public.localPrice.currency,
}))
)
const lowestPrice = pricesWithCurrencies.reduce(
(minPrice, { price }) => Math.min(minPrice, price),
Infinity
)
const currency = pricesWithCurrencies[0]?.currency
trackLowestRoomPrice({
hotelId,
arrivalDate,
departureDate,
lowestPrice: lowestPrice,
currency: currency,
})
}, [arrivalDate, departureDate, hotelId, visibleRooms])
return (
<div className={styles.content}>
{bookingRooms.map((room, idx) => (
<RoomProvider
key={`${room.rateCode}-${room.roomTypeCode}-${idx}`}
idx={idx}
room={rooms[idx]}
>
<MultiRoomWrapper isMultiRoom={bookingRooms.length > 1}>
<RoomSelectionPanel />
</MultiRoomWrapper>
</RoomProvider>
))}
</div>
)
}

View File

@@ -0,0 +1,8 @@
.content {
max-width: var(--max-width-page);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x5) 0;
}

View File

@@ -0,0 +1,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);
}

View File

@@ -0,0 +1,20 @@
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
import styles from "./RoomsContainerSkeleton.module.css"
type Props = {
count?: number
}
export async 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>
)
}

View File

@@ -0,0 +1,131 @@
import { dt } from "@/lib/dt"
import {
getHotel,
getPackages,
getRoomsAvailability,
} from "@/lib/trpc/memoizedRequests"
import { auth } from "@/auth"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import RatesProvider from "@/providers/RatesProvider"
import { isValidSession } from "@/utils/session"
import { combineRoomAvailabilities } from "../utils"
import RateSummary from "./RateSummary"
import Rooms from "./Rooms"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Lang } from "@/constants/languages"
export function preload(
hotelId: string,
lang: Lang,
fromDate: string,
toDate: string,
adults: number[],
children?: Child[]
) {
void getHotel({ hotelId, isCardOnlyPayment: false, language: lang })
void getPackages({
adults: adults[0],
children: children ? children?.length : undefined,
endDate: toDate,
hotelId,
packageCodes: [
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
],
startDate: fromDate,
})
const uniqueAdultsCount = Array.from(new Set(adults))
uniqueAdultsCount.forEach((adultsInRoom) => {
void getRoomsAvailability({
adults: adultsInRoom,
children: children ? generateChildrenString(children) : undefined,
hotelId: +hotelId,
roomStayEndDate: toDate,
roomStayStartDate: fromDate,
})
})
}
export async function RoomsContainer({
adultArray,
booking,
childArray,
fromDate,
hotelId,
lang,
toDate,
}: RoomsContainerProps) {
const session = await auth()
const isUserLoggedIn = isValidSession(session)
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
const toDateString = dt(toDate).format("YYYY-MM-DD")
const hotelData = await getHotel({
hotelId: hotelId.toString(),
isCardOnlyPayment: false,
language: lang,
})
const uniqueAdultsCount = Array.from(new Set(adultArray))
const roomsAvailabilityResults = await Promise.allSettled(
uniqueAdultsCount.map((adultCount) =>
getRoomsAvailability({
adults: adultCount,
hotelId: hotelId,
roomStayEndDate: toDateString,
roomStayStartDate: fromDateString,
children:
childArray && childArray.length > 0
? generateChildrenString(childArray)
: undefined,
})
)
)
const roomsAvailability = combineRoomAvailabilities(roomsAvailabilityResults)
const packages = await getPackages({
adults: adultArray[0],
children: childArray ? childArray.length : undefined,
endDate: toDateString,
hotelId: hotelId.toString(),
packageCodes: [
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
],
startDate: fromDateString,
})
if (!hotelData?.hotel) {
return null
}
if (packages === null) {
// TODO: Log packages error
console.error("[RoomsContainer] unable to fetch packages")
}
return (
<RatesProvider
booking={booking}
hotelType={hotelData.hotel.hotelType}
isUserLoggedIn={isUserLoggedIn}
packages={packages}
roomCategories={hotelData.roomCategories}
roomsAvailability={roomsAvailability}
vat={hotelData.hotel.vat}
>
<Rooms />
<RateSummary isUserLoggedIn={isUserLoggedIn} />
</RatesProvider>
)
}