feat(SW-718) Refactor select rate to support multiroom

This commit is contained in:
Pontus Dreij
2025-01-21 10:31:15 +01:00
parent 7d716dcf4a
commit edcf146ce1
27 changed files with 202 additions and 131 deletions

View File

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

View File

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

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

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