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