feat: adjust select rate ui to latest design

This commit is contained in:
Simon Emanuelsson
2025-02-17 15:10:48 +01:00
parent 2c72957dc6
commit 4c23700d52
76 changed files with 819 additions and 654 deletions

View File

@@ -42,7 +42,7 @@ export default function Header({
busyStatus: "FREE",
categories: ["booking", "hotel", "stay"],
created: generateDateTime(booking.createDateTime),
description: hotel.hotelContent.texts.descriptions.medium,
description: hotel.hotelContent.texts.descriptions?.medium,
end: generateDateTime(booking.checkOutDate),
endInputType: "utc",
geo: {

View File

@@ -14,6 +14,7 @@ export default function ToggleSidePeek({
hotelId,
roomTypeCode,
intent = "textInverted",
title,
}: ToggleSidePeekProps) {
const intl = useIntl()
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
@@ -29,7 +30,7 @@ export default function ToggleSidePeek({
intent={intent}
wrapping
>
{intl.formatMessage({ id: "See room details" })}
{title ? title : intl.formatMessage({ id: "See room details" })}
<ChevronRight height="14" />
</Button>
)

View File

@@ -137,7 +137,7 @@ function HotelCard({
</div>
</div>
<Body className={styles.hotelDescription}>
{hotelData.hotelContent.texts.descriptions.short}
{hotelData.hotelContent.texts.descriptions?.short}
</Body>
<div className={styles.facilities}>
{amenities.map((facility) => {

View File

@@ -45,7 +45,7 @@ export default function ActionPanel({
busyStatus: "FREE",
categories: ["booking", "hotel", "stay"],
created: generateDateTime(booking.createDateTime),
description: hotel.hotelContent.texts.descriptions.medium,
description: hotel.hotelContent.texts.descriptions?.medium,
end: generateDateTime(booking.checkOutDate),
endInputType: "utc",
geo: {

View File

@@ -58,7 +58,7 @@ export default async function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
)}
</Caption>
<Body color="uiTextHighContrast">
{hotel.hotelContent.texts.descriptions.medium}
{hotel.hotelContent.texts.descriptions?.medium}
</Body>
</div>
</div>

View File

@@ -8,6 +8,7 @@ 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"
@@ -72,7 +73,7 @@ export default function SelectedRoomPanel() {
return (
<div className={styles.selectedRoomPanel}>
<div>
<div className={styles.content}>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Room {roomIndex}" },
@@ -90,20 +91,22 @@ export default function SelectedRoomPanel() {
{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.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 variant="icon" size="small" onClick={modifyRate}>
<EditIcon />
{intl.formatMessage({ id: "Modify" })}
<Button clean onClick={modifyRate}>
<Chip size="small" variant="uiTextHighContrast">
<EditIcon />
{intl.formatMessage({ id: "Modify" })}
</Chip>
</Button>
</div>
</div>

View File

@@ -1,50 +1,53 @@
.selectedRoomPanel {
display: flex;
flex-direction: row;
justify-content: space-between;
display: grid;
grid-template-areas: "content image";
grid-template-columns: 1fr 190px;
position: relative;
}
.modifyButtonContainer {
position: absolute;
right: var(--Spacing-x2);
bottom: var(--Spacing-x2);
.content {
grid-area: content;
}
.imageContainer {
width: 187px;
height: 105px;
position: relative;
border-radius: var(--Corner-radius-Small);
overflow: hidden;
display: flex;
grid-area: image;
}
.titleContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
.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 (max-width: 768px) {
.imageContainer {
width: 120px;
height: 80px;
}
.imageAndModifyButtonContainer {
display: flex;
flex-direction: column;
align-items: flex-end;
@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;
}
.modifyButtonContainer {
position: relative;
bottom: 0;
right: 0;
.img {
max-height: 300px;
}
}
@media screen and (max-width: 500px) {
.img {
max-height: 190px;
}
}

View File

@@ -3,6 +3,8 @@ 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"
@@ -17,7 +19,13 @@ export default function MultiRoomWrapper({
}: React.PropsWithChildren<{ isMultiRoom: boolean }>) {
const intl = useIntl()
const activeRoom = useRatesStore((state) => state.activeRoom)
const { bookingRoom, isActiveRoom, roomNr, selectedRate } = useRoomContext()
const {
actions: { closeSection },
bookingRoom,
isActiveRoom,
roomNr,
selectedRate,
} = useRoomContext()
const onlyAdultsMsg = intl.formatMessage(
{ id: "{adults} adults" },
@@ -56,19 +64,33 @@ export default function MultiRoomWrapper({
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={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 />

View File

@@ -4,7 +4,13 @@
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
padding: var(--Spacing-x2);
padding: var(--Spacing-x3);
}
.header {
align-items: center;
display: flex;
justify-content: space-between;
}
.roomPanel,
@@ -27,27 +33,21 @@
gap: var(--Spacing-x2);
}
.roomSelectionPanelContainer.active .roomSelectionPanel,
.roomSelectionPanelContainer.selected .roomPanel {
grid-template-rows: 1fr;
opacity: 1;
height: auto;
opacity: 1;
}
.roomSelectionPanelContainer.active .roomPanel {
padding-top: var(--Spacing-x1);
}
.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

@@ -138,20 +138,20 @@ export default function PriceList({
<Caption color="uiTextMediumContrast">
{isUserLoggedIn
? intl.formatMessage(
{ id: "{memberPrice} {currency}" },
{
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)
{ id: "{memberPrice} {currency}" },
{
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)
: intl.formatMessage(
{ id: "{publicPrice}/{memberPrice} {currency}" },
{
publicPrice: totalPublicRequestedPricePerNight,
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)}
{ id: "{publicPrice}/{memberPrice} {currency}" },
{
publicPrice: totalPublicRequestedPricePerNight,
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)}
</Caption>
</dd>
</div>

View File

@@ -23,6 +23,7 @@
cursor: pointer;
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
.checkIcon {
width: 24px;
height: 24px;
@@ -33,10 +34,18 @@
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;
@@ -81,11 +90,13 @@ input[type="radio"]:checked + .card .checkIcon {
.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

@@ -34,6 +34,7 @@ export default function FlexibilityOption({
const {
actions: { selectRate },
isMainRoom,
roomNr,
} = useRoomContext()
function handleSelect() {
@@ -74,7 +75,8 @@ export default function FlexibilityOption({
<label>
<input
checked={isSelected}
name={`rateCode-${rate.rateCode}`}
className={styles.radio}
name={`rateCode-${roomNr}-${rate.rateCode}`}
onChange={handleSelect}
type="radio"
value={rate.rateCode}

View File

@@ -14,23 +14,29 @@ export default function RoomSize({ roomSize }: RoomSizeProps) {
if (roomSize.min === roomSize.max) {
return (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{roomSize} m²" },
{ roomSize: roomSize.min }
)}
</Caption>
<>
<Caption color="uiTextMediumContrast"></Caption>
<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>
<>
<Caption color="uiTextMediumContrast"></Caption>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{roomSizeMin} - {roomSizeMax} m²" },
{
roomSizeMin: roomSize.min,
roomSizeMax: roomSize.max,
}
)}
</Caption>
</>
)
}

View File

@@ -35,13 +35,17 @@ function getBreakfastMessage(
memberBreakfastIncluded: boolean,
hotelType: string | undefined,
userIsLoggedIn: boolean,
msgs: Record<"included" | "noSelection" | "scandicgo" | "notIncluded", string>
msgs: Record<
"included" | "noSelection" | "scandicgo" | "notIncluded",
string
>,
roomNr: number
) {
if (hotelType === HotelTypeEnum.ScandicGo) {
return msgs.scandicgo
}
if (userIsLoggedIn && memberBreakfastIncluded) {
if (userIsLoggedIn && memberBreakfastIncluded && roomNr === 1) {
return msgs.included
}
@@ -81,7 +85,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories,
}))
const { isMainRoom, selectedPackage, selectedRate } = useRoomContext()
const { isMainRoom, roomNr, selectedPackage, selectedRate } = useRoomContext()
const classNames = cardVariants({
availability:
@@ -105,7 +109,8 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
roomConfiguration.breakfastIncludedInAllRatesMember,
hotelType,
isUserLoggedIn,
breakfastMessages
breakfastMessages,
roomNr
)
if (!rateDefinitions) {
@@ -124,7 +129,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
)
)
const { name, roomSize, totalOccupancy, images } = selectedRoom || {}
const { images, name, occupancy, roomSize } = selectedRoom || {}
const galleryImages = mapApiImagesToGalleryImages(images || [])
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
@@ -203,86 +208,87 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
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>
<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>
) : 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>
<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>
<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" })}
/>
)}
<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
</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>
<>
{/** 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>
</div>
</>
) : (
<>
<Caption color="uiTextHighContrast" type="bold">
{breakfastMessage}
</Caption>
<Caption color="uiTextHighContrast">{breakfastMessage}</Caption>
{roomConfiguration.products.map((product) => {
const rate = getRateInfo(product)
const isSelectedRateCode =

View File

@@ -1,112 +1,100 @@
.card {
font-size: 14px;
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;
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;
padding: 0 var(--Spacing-x2) var(--Spacing-x2);
position: relative;
}
div[data-multiroom="true"] .card {
border: none;
padding: 0;
}
.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);
.imageContainer {
margin: 0 calc(-1 * var(--Spacing-x2));
min-height: 190px;
position: relative;
}
.specification .guests {
border-right: 1px solid var(--Base-Border-Subtle);
padding-right: var(--Spacing-x1);
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;
}
.toggleSidePeek button {
.specification .toggleSidePeek button {
padding: 0;
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) 0;
padding-bottom: var(--Spacing-x-half);
}
.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 {
.container {
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%;
grid-row: span 4;
grid-template-rows: subgrid;
}
.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);
}
margin: 0;
padding: var(--Spacing-x2);
}

View File

@@ -41,16 +41,14 @@ export default function RoomSelectionPanel() {
</div>
) : null}
<RoomTypeFilter />
<div className={styles.wrapper}>
<ul className={styles.roomList}>
{rooms.map((roomConfiguration) => (
<RoomCard
key={roomConfiguration.roomTypeCode}
roomConfiguration={roomConfiguration}
/>
))}
</ul>
</div>
<ul className={styles.roomList}>
{rooms.map((roomConfiguration) => (
<RoomCard
key={roomConfiguration.roomTypeCode}
roomConfiguration={roomConfiguration}
/>
))}
</ul>
</>
)
}

View File

@@ -9,12 +9,6 @@
width: 100%;
}
.roomList input[type="radio"] {
opacity: 0;
position: fixed;
width: 0;
}
.hotelAlert {
width: 100%;
margin: 0 auto;