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:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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