feat(SW-718) Refactor select rate to support multiroom
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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({
|
||||
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}>
|
||||
<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">
|
||||
{intl.formatMessage(
|
||||
{ id: "{publicPrice}/{memberPrice} {currency}" },
|
||||
{
|
||||
publicPrice: totalPublicRequestedPricePerNight,
|
||||
memberPrice: totalMemberRequestedPricePerNight,
|
||||
currency: publicRequestedPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.priceList {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.priceTable {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.perNight {
|
||||
font-weight: 400;
|
||||
font-size: var(--typography-Caption-Regular-fontSize);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CalculatePricesPerNightProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
|
||||
export function calculatePricesPerNight({
|
||||
publicLocalPrice,
|
||||
memberLocalPrice,
|
||||
publicRequestedPrice,
|
||||
memberRequestedPrice,
|
||||
petRoomLocalPrice,
|
||||
petRoomRequestedPrice,
|
||||
nights,
|
||||
}: CalculatePricesPerNightProps) {
|
||||
const totalPublicLocalPricePerNight = publicLocalPrice
|
||||
? petRoomLocalPrice
|
||||
? Math.floor(
|
||||
Number(publicLocalPrice.pricePerNight) +
|
||||
Number(petRoomLocalPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(publicLocalPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
const totalMemberLocalPricePerNight = memberLocalPrice
|
||||
? petRoomLocalPrice
|
||||
? Math.floor(
|
||||
Number(memberLocalPrice.pricePerNight) +
|
||||
Number(petRoomLocalPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(memberLocalPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
const totalPublicRequestedPricePerNight = publicRequestedPrice
|
||||
? petRoomRequestedPrice
|
||||
? Math.floor(
|
||||
Number(publicRequestedPrice.pricePerNight) +
|
||||
Number(petRoomRequestedPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(publicRequestedPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
const totalMemberRequestedPricePerNight = memberRequestedPrice
|
||||
? petRoomRequestedPrice
|
||||
? Math.floor(
|
||||
Number(memberRequestedPrice.pricePerNight) +
|
||||
Number(petRoomRequestedPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(memberRequestedPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
return {
|
||||
totalPublicLocalPricePerNight,
|
||||
totalMemberLocalPricePerNight,
|
||||
totalPublicRequestedPricePerNight,
|
||||
totalMemberRequestedPricePerNight,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CheckCircleIcon, 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 PriceTable from "./PriceList"
|
||||
|
||||
import styles from "./flexibilityOption.module.css"
|
||||
|
||||
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
|
||||
export default function FlexibilityOption({
|
||||
product,
|
||||
name,
|
||||
paymentTerm,
|
||||
priceInformation,
|
||||
roomTypeCode,
|
||||
petRoomPackage,
|
||||
handleSelectRate,
|
||||
}: FlexibilityOptionProps) {
|
||||
const intl = useIntl()
|
||||
const inputElementRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// When entering the page with a room and rate selection already in the URL we
|
||||
// want to preselect the selection. This happens e.g. when you do a selection,
|
||||
// go to the enter details page and then want to change the room.
|
||||
useEffect(() => {
|
||||
const ratecodeSearchParam = searchParams.get("room[0].ratecode")
|
||||
const roomtypeSearchParam = searchParams.get("room[0].roomtype")
|
||||
|
||||
// If this is not the room and rate we want to preselect, abort
|
||||
if (
|
||||
!product ||
|
||||
ratecodeSearchParam !== product.productType.public.rateCode ||
|
||||
roomtypeSearchParam !== roomTypeCode
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
handleSelectRate((prev) => {
|
||||
// If the user already has made a new selection we respect that and don't do anything else
|
||||
if (prev) {
|
||||
return prev
|
||||
}
|
||||
|
||||
if (inputElementRef.current) {
|
||||
inputElementRef.current.checked = true
|
||||
}
|
||||
|
||||
return {
|
||||
publicRateCode: product.productType.public.rateCode,
|
||||
roomTypeCode: roomTypeCode,
|
||||
name: name,
|
||||
paymentTerm: paymentTerm,
|
||||
}
|
||||
})
|
||||
}, [handleSelectRate, name, paymentTerm, product, roomTypeCode, searchParams])
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className={styles.noPricesCard}>
|
||||
<div className={styles.header}>
|
||||
<InfoCircleIcon width={16} height={16} color="uiTextMediumContrast" />
|
||||
<div className={styles.priceType}>
|
||||
<Caption>{name}</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 onClick: React.MouseEventHandler<HTMLInputElement> = (e) => {
|
||||
handleSelectRate((prev) => {
|
||||
if (
|
||||
prev &&
|
||||
prev.publicRateCode === publicPrice.rateCode &&
|
||||
prev.roomTypeCode === roomTypeCode
|
||||
) {
|
||||
if (e.currentTarget?.checked) e.currentTarget.checked = false
|
||||
return undefined
|
||||
} else
|
||||
return {
|
||||
publicRateCode: publicPrice.rateCode,
|
||||
roomTypeCode: roomTypeCode,
|
||||
name: name,
|
||||
paymentTerm: paymentTerm,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="rateCode"
|
||||
value={publicPrice?.rateCode}
|
||||
onClick={onClick}
|
||||
ref={inputElementRef}
|
||||
/>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<InfoCircleIcon
|
||||
width={16}
|
||||
height={16}
|
||||
color="uiTextMediumContrast"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={name}
|
||||
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">{name}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<PriceTable
|
||||
publicPrice={publicPrice}
|
||||
memberPrice={memberPrice}
|
||||
petRoomPackage={petRoomPackage}
|
||||
/>
|
||||
|
||||
<div className={styles.checkIcon}>
|
||||
<CheckIcon color="white" height="16" width="16" />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.card {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
min-height: 200px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
aspect-ratio: 16/9;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.priceVariants {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./RoomCardSkeleton.module.css"
|
||||
|
||||
export function RoomCardSkeleton() {
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
{/* image container */}
|
||||
<div className={styles.imageContainer}>
|
||||
<SkeletonShimmer width={"100%"} height="100%" />
|
||||
</div>
|
||||
|
||||
<div className={styles.priceVariants}>
|
||||
{/* price variants */}
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<SkeletonShimmer key={index} height={"100px"} />
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
export const cardVariants = cva(styles.card, {
|
||||
variants: {
|
||||
availability: {
|
||||
noAvailability: styles.noAvailability,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client"
|
||||
|
||||
import { createElement, useCallback } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
|
||||
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import FlexibilityOption from "../FlexibilityOption"
|
||||
import { cardVariants } from "./cardVariants"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
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 { RateDefinition } from "@/server/routers/hotels/output"
|
||||
|
||||
export default function RoomCard({
|
||||
hotelId,
|
||||
hotelType,
|
||||
rateDefinitions,
|
||||
roomConfiguration,
|
||||
roomCategories,
|
||||
selectedPackages,
|
||||
packages,
|
||||
handleSelectRate,
|
||||
}: RoomCardProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const rates = {
|
||||
saveRate: rateDefinitions.find(
|
||||
(rate) => rate.cancellationRule === "NotCancellable"
|
||||
),
|
||||
changeRate: rateDefinitions.find(
|
||||
(rate) => rate.cancellationRule === "Changeable"
|
||||
),
|
||||
flexRate: rateDefinitions.find(
|
||||
(rate) => rate.cancellationRule === "CancellableBefore6PM"
|
||||
),
|
||||
}
|
||||
|
||||
function findProductForRate(rate: RateDefinition | undefined) {
|
||||
return rate
|
||||
? roomConfiguration.products.find(
|
||||
(product) =>
|
||||
product.productType.public?.rateCode === rate.rateCode ||
|
||||
product.productType.member?.rateCode === rate.rateCode
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getRateDefinitionForRate(rate: RateDefinition | undefined) {
|
||||
return rateDefinitions.find((def) => def.rateCode === rate?.rateCode)
|
||||
}
|
||||
|
||||
const getBreakfastMessage = (rate: RateDefinition | undefined) => {
|
||||
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||
return intl.formatMessage({
|
||||
id: "Breakfast deal can be purchased at the hotel.",
|
||||
})
|
||||
}
|
||||
return getRateDefinitionForRate(rate)?.breakfastIncluded
|
||||
? intl.formatMessage({ id: "Breakfast is included." })
|
||||
: intl.formatMessage({ id: "Breakfast selection in next step." })
|
||||
}
|
||||
|
||||
const petRoomPackage =
|
||||
(selectedPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
|
||||
packages?.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
|
||||
undefined
|
||||
|
||||
const selectedRoom = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.some(
|
||||
(roomType) => roomType.code === roomConfiguration.roomTypeCode
|
||||
)
|
||||
)
|
||||
|
||||
const { name, roomSize, totalOccupancy, images } = selectedRoom || {}
|
||||
|
||||
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" })
|
||||
|
||||
const rateKey = useCallback(
|
||||
(key: string) => {
|
||||
switch (key) {
|
||||
case "flexRate":
|
||||
return freeCancelation
|
||||
case "saveRate":
|
||||
return nonRefundable
|
||||
default:
|
||||
return freeBooking
|
||||
}
|
||||
},
|
||||
[freeCancelation, freeBooking, nonRefundable]
|
||||
)
|
||||
|
||||
const classNames = cardVariants({
|
||||
availability:
|
||||
roomConfiguration.status === "NotAvailable"
|
||||
? "noAvailability"
|
||||
: "default",
|
||||
})
|
||||
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<div>
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.chipContainer}>
|
||||
{roomConfiguration.roomsLeft > 0 &&
|
||||
roomConfiguration.roomsLeft < 5 && (
|
||||
<span className={styles.chip}>
|
||||
<Footnote color="burgundy" textTransform="uppercase">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount, number} left" },
|
||||
{ amount: roomConfiguration.roomsLeft }
|
||||
)}
|
||||
</Footnote>
|
||||
</span>
|
||||
)}
|
||||
{roomConfiguration.features
|
||||
.filter((feature) => selectedPackages.includes(feature.code))
|
||||
.map((feature) => (
|
||||
<span className={styles.chip} key={feature.code}>
|
||||
{createElement(getIconForFeatureCode(feature.code), {
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: "burgundy",
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
|
||||
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
|
||||
<ImageGallery
|
||||
images={images}
|
||||
title={roomConfiguration.roomType}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.specification}>
|
||||
{totalOccupancy && (
|
||||
<Caption color="uiTextMediumContrast" className={styles.guests}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Max {max, plural, one {{range} guest} other {{range} guests}}",
|
||||
},
|
||||
{ max: totalOccupancy.max, range: totalOccupancy.range }
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
{roomSize && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{roomSize.min === roomSize.max
|
||||
? intl.formatMessage(
|
||||
{ id: "{roomSize} m²" },
|
||||
{
|
||||
roomSize: roomSize.min,
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{
|
||||
id: "{roomSizeMin} - {roomSizeMax} m²",
|
||||
},
|
||||
{
|
||||
roomSizeMin: roomSize.min,
|
||||
roomSizeMax: roomSize.max,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
<div className={styles.toggleSidePeek}>
|
||||
{roomConfiguration.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<Subtitle className={styles.name} type="two">
|
||||
{name}
|
||||
</Subtitle>
|
||||
{/* Out of scope for now
|
||||
<Body>{descriptions?.short}</Body>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{getBreakfastMessage(rates.flexRate)}
|
||||
</Caption>
|
||||
{roomConfiguration.status === "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>
|
||||
) : (
|
||||
Object.entries(rates).map(([key, rate]) => (
|
||||
<FlexibilityOption
|
||||
key={key}
|
||||
name={rateKey(key)}
|
||||
value={key.toLowerCase()}
|
||||
paymentTerm={key === "flexRate" ? payLater : payNow}
|
||||
product={findProductForRate(rate)}
|
||||
priceInformation={getRateDefinitionForRate(rate)?.generalTerms}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
petRoomPackage={petRoomPackage}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
38
components/HotelReservation/SelectRate/RoomList/index.tsx
Normal file
38
components/HotelReservation/SelectRate/RoomList/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import RoomCard from "./RoomCard"
|
||||
|
||||
import styles from "./roomSelection.module.css"
|
||||
|
||||
import type { RoomListProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
|
||||
|
||||
export default function RoomList({
|
||||
roomsAvailability,
|
||||
roomCategories,
|
||||
availablePackages,
|
||||
selectedPackages,
|
||||
setRateCode,
|
||||
hotelType,
|
||||
}: RoomListProps) {
|
||||
const { roomConfigurations, rateDefinitions } = roomsAvailability
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<ul className={styles.roomList}>
|
||||
{roomConfigurations.map((roomConfiguration, index) => (
|
||||
<RoomCard
|
||||
hotelId={roomsAvailability.hotelId.toString()}
|
||||
hotelType={hotelType}
|
||||
rateDefinitions={rateDefinitions}
|
||||
roomConfiguration={roomConfiguration}
|
||||
roomCategories={roomCategories}
|
||||
handleSelectRate={setRateCode}
|
||||
selectedPackages={selectedPackages}
|
||||
packages={availablePackages}
|
||||
key={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.wrapper {
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
Reference in New Issue
Block a user