feat(SW-2043): Added new room packages filter

* feat(SW-2043): Added new room packages filter

* fix(SW-2043): Fixed issue with not updating price when selecting pet room

Approved-by: Tobias Johansson
Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-04-01 09:54:09 +00:00
parent 35c1724afb
commit df32c08350
29 changed files with 489 additions and 222 deletions

View File

@@ -239,7 +239,10 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
<SignupPromoDesktop
memberPrice={{
amount: rateSummary.reduce(
(total, { features, package: roomPackage, product }) => {
(
total,
{ features, packages: roomPackages, product }
) => {
if (!("member" in product) || !product.member) {
return total
}
@@ -248,8 +251,9 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
if (!memberPrice) {
return total
}
const hasSelectedPetRoom =
roomPackage === RoomPackageCodeEnum.PET_ROOM
const hasSelectedPetRoom = roomPackages.includes(
RoomPackageCodeEnum.PET_ROOM
)
if (!hasSelectedPetRoom) {
return total + memberPrice
}

View File

@@ -38,7 +38,7 @@ export function calculateTotalPrice(
if (
petRoomPackage &&
isPetRoom &&
room.package === RoomPackageCodeEnum.PET_ROOM
room.packages.includes(RoomPackageCodeEnum.PET_ROOM)
) {
petRoomPrice = Number(petRoomPackage.localPrice.totalPrice)
}

View File

@@ -1,15 +1,8 @@
.bookingCodeFilter {
display: flex;
justify-content: flex-end;
width: 100%;
}
.bookingCodeFilterSelect {
min-width: 200px;
}
@media screen and (max-width: 767px) {
.bookingCodeFilter {
margin-bottom: var(--Spacing-x3);
}
}

View File

@@ -0,0 +1,43 @@
.checkboxWrapper {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
cursor: pointer;
border-radius: var(--Corner-radius-Medium);
transition: background-color 0.3s;
color: var(--Text-Default);
}
.checkboxWrapper:hover {
background-color: var(--UI-Input-Controls-Surface-Hover);
}
.checkbox {
width: 24px;
height: 24px;
min-width: 24px;
border: 1px solid var(--UI-Input-Controls-Border-Normal);
border-radius: var(--Corner-radius-Small);
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--UI-Input-Controls-Surface-Normal);
}
.checkboxWrapper[data-selected] .checkbox {
border-color: var(--UI-Input-Controls-Fill-Selected);
background-color: var(--UI-Input-Controls-Fill-Selected);
}
@media screen and (max-width: 767px) {
.checkboxWrapper:hover {
background-color: transparent;
}
.checkboxWrapper[data-selected] {
background-color: transparent;
}
}

View File

@@ -0,0 +1,48 @@
"use client"
import { Checkbox as AriaCheckbox } from "react-aria-components"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./checkbox.module.css"
import type { MaterialSymbolProps } from "react-material-symbols"
interface CheckboxProps {
name: string
value: string
isSelected: boolean
iconName: MaterialSymbolProps["icon"]
onChange: (value: string) => void
}
export default function Checkbox({
isSelected,
name,
value,
iconName,
onChange,
}: CheckboxProps) {
return (
<AriaCheckbox
className={styles.checkboxWrapper}
isSelected={isSelected}
onChange={() => onChange(value)}
>
{({ isSelected }) => (
<>
<span className={styles.checkbox}>
{isSelected && <MaterialIcon icon="check" color="Icon/Inverted" />}
</span>
<Typography variant="Body/Paragraph/mdRegular">
<span>{name}</span>
</Typography>
{iconName ? (
<MaterialIcon icon={iconName} color="Icon/Default" />
) : null}
</>
)}
</AriaCheckbox>
)
}

View File

@@ -0,0 +1,96 @@
"use client"
import { Button, Dialog, DialogTrigger, Popover } from "react-aria-components"
import { useIntl } from "react-intl"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRatesStore } from "@/stores/select-rate"
import Divider from "@/components/TempDesignSystem/Divider"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import Checkbox from "./Checkbox"
import { getIconNameByPackageCode } from "./utils"
import styles from "./roomPackageFilter.module.css"
export default function RoomPackageFilter() {
const packageOptions = useRatesStore((state) => state.packageOptions)
const {
actions: { togglePackage },
selectedPackages,
} = useRoomContext()
const intl = useIntl()
return (
<div className={styles.roomPackageFilter}>
{selectedPackages.map((pkg) => (
<Button
key={pkg}
onPress={() => togglePackage(pkg)}
className={styles.activeFilterButton}
>
<MaterialIcon
icon={getIconNameByPackageCode(pkg)}
size={16}
color="Icon/Interactive/Default"
/>
<MaterialIcon
icon="close"
size={16}
color="Icon/Interactive/Default"
/>
</Button>
))}
<DialogTrigger>
<ChipButton variant="Outlined">
{intl.formatMessage({ id: "Room preferences" })}
<MaterialIcon
icon="keyboard_arrow_down"
size={20}
color="CurrentColor"
/>
</ChipButton>
<Popover placement="bottom end">
<Dialog className={styles.dialog}>
<div className={styles.filters}>
{packageOptions.map((option) => (
<Checkbox
key={option.code}
name={option.description}
value={option.code}
iconName={getIconNameByPackageCode(option.code)}
isSelected={selectedPackages.includes(option.code)}
onChange={() => togglePackage(option.code)}
/>
))}
</div>
<div className={styles.footer}>
<Divider color="borderDividerSubtle" />
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.additionalInformation}>
{intl.formatMessage(
{
id: "<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.",
},
{
b: (str) => (
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.additionalInformationPrice}>
{str}
</span>
</Typography>
),
}
)}
</p>
</Typography>
</div>
</Dialog>
</Popover>
</DialogTrigger>
</div>
)
}

View File

@@ -0,0 +1,42 @@
.roomPackageFilter {
display: flex;
gap: var(--Space-x1);
}
.dialog {
display: grid;
gap: var(--Space-x1);
padding: var(--Space-x2);
flex-direction: column;
align-items: flex-end;
border-radius: var(--Corner-radius-md);
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
max-width: 340px;
}
.footer {
display: grid;
gap: var(--Space-x1);
padding: 0 var(--Space-x15);
}
.additionalInformation {
color: var(--Text-Tertiary);
}
.additionalInformationPrice {
color: var(--Text-Default);
}
.activeFilterButton {
display: flex;
justify-content: center;
align-items: center;
padding: 0 var(--Space-x1);
gap: var(--Space-x05);
border-radius: var(--Corner-radius-Small);
background-color: var(--Surface-Secondary-Default-dark);
border-width: 0;
cursor: pointer;
}

View File

@@ -0,0 +1,18 @@
import type { MaterialSymbolProps } from "react-material-symbols"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
export function getIconNameByPackageCode(
packageCode: RoomPackageCodeEnum
): MaterialSymbolProps["icon"] {
switch (packageCode) {
case RoomPackageCodeEnum.PET_ROOM:
return "pets"
case RoomPackageCodeEnum.ACCESSIBILITY_ROOM:
return "accessible"
case RoomPackageCodeEnum.ALLERGY_ROOM:
return "mode_fan"
default:
return "star"
}
}

View File

@@ -1,93 +0,0 @@
"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/SelectRate/Room"
import styles from "./roomFilter.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
export default function RoomTypeFilter() {
const filterOptions = useRatesStore((state) => state.filterOptions)
const {
actions: { selectPackage },
rooms,
selectedPackage,
totalRooms,
} = useRoomContext()
const intl = useIntl()
const availableRooms = rooms.filter(
(room) => room.status === AvailabilityEnum.Available
).length
// 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()
selectPackage(selected.value as RoomPackageCodeEnum)
} else {
selectPackage(undefined)
}
}
const notAllRoomsAvailableText = intl.formatMessage(
{
id: "{availableRooms}/{numberOfRooms, plural, one {# room type} other {# room types}} available",
},
{
availableRooms,
numberOfRooms: totalRooms,
}
)
const allRoomsAvailableText = intl.formatMessage(
{
id: "{numberOfRooms, plural, one {# room type} other {# room types}} available",
},
{
numberOfRooms: totalRooms,
}
)
return (
<div className={styles.container}>
<Caption color="uiTextHighContrast">
{availableRooms !== totalRooms
? notAllRoomsAvailableText
: allRoomsAvailableText}
</Caption>
<ToggleButtonGroup
aria-label={intl.formatMessage({ id: "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.code}
>
<div className={styles.circle} />
<Caption color="uiTextHighContrast">{option.description}</Caption>
</ToggleButton>
))}
</ToggleButtonGroup>
</div>
)
}

View File

@@ -1,70 +0,0 @@
.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,57 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import BookingCodeFilter from "../BookingCodeFilter"
import RoomPackageFilter from "../RoomPackageFilter"
import styles from "./roomsHeader.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
export default function RoomsHeader() {
const { rooms, totalRooms } = useRoomContext()
const intl = useIntl()
const availableRooms = rooms.filter(
(room) => room.status === AvailabilityEnum.Available
).length
const notAllRoomsAvailableText = intl.formatMessage(
{
id: "{availableRooms}/{numberOfRooms, plural, one {# room type} other {# room types}} available",
},
{
availableRooms,
numberOfRooms: totalRooms,
}
)
const allRoomsAvailableText = intl.formatMessage(
{
id: "{numberOfRooms, plural, one {# room type} other {# room types}} available",
},
{
numberOfRooms: totalRooms,
}
)
return (
<div className={styles.container}>
<Typography variant="Title/Subtitle/md" className={styles.availableRooms}>
<p>
{availableRooms !== totalRooms
? notAllRoomsAvailableText
: allRoomsAvailableText}
</p>
</Typography>
<div className={styles.filters}>
<RoomPackageFilter />
<BookingCodeFilter />
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
.container {
display: grid;
gap: var(--Space-x3);
align-items: center;
}
.availableRooms {
color: var(--Text-Default);
}
.filters {
display: flex;
gap: var(--Space-x1);
align-items: center;
}
@media screen and (min-width: 768px) {
.container {
grid-template-columns: 1fr auto;
}
}

View File

@@ -35,7 +35,7 @@ export default function Rates({
actions: { selectRate },
isFetchingAdditionalRate,
selectedFilter,
selectedPackage,
selectedPackages,
} = useRoomContext()
const { nights, petRoomPackage } = useRatesStore((state) => ({
nights: dt(state.booking.toDate).diff(state.booking.fromDate, "days"),
@@ -46,8 +46,9 @@ export default function Rates({
selectRate({ features, product, roomType, roomTypeCode })
}
const petRoomPackageSelected =
selectedPackage === RoomPackageCodeEnum.PET_ROOM
const petRoomPackageSelected = selectedPackages.includes(
RoomPackageCodeEnum.PET_ROOM
)
const sharedProps = {
handleSelectRate,

View File

@@ -20,7 +20,7 @@ export default function RoomImage({
roomTypeCode,
}: RoomListItemImageProps) {
const intl = useIntl()
const { selectedPackage } = useRoomContext()
const { selectedPackages } = useRoomContext()
const roomCategories = useRatesStore((state) => state.roomCategories)
const showLowInventory = roomsLeft > 0 && roomsLeft < 5
@@ -45,7 +45,7 @@ export default function RoomImage({
</span>
) : null}
{features
.filter((feature) => selectedPackage === feature.code)
.filter((feature) => selectedPackages.includes(feature.code))
.map((feature) => (
<span className={styles.chip} key={feature.code}>
{IconForFeatureCode({ featureCode: feature.code, size: 16 })}

View File

@@ -6,11 +6,10 @@ import { useRatesStore } from "@/stores/select-rate"
import RoomProvider from "@/providers/SelectRate/RoomProvider"
import { trackLowestRoomPrice } from "@/utils/tracking"
import BookingCodeFilter from "./BookingCodeFilter"
import MultiRoomWrapper from "./MultiRoomWrapper"
import NoAvailabilityAlert from "./NoAvailabilityAlert"
import RoomsHeader from "./RoomsHeader"
import RoomsList from "./RoomsList"
import RoomTypeFilter from "./RoomTypeFilter"
import styles from "./rooms.module.css"
@@ -77,9 +76,8 @@ export default function Rooms() {
room={rooms[idx]}
>
<MultiRoomWrapper isMultiRoom={bookingRooms.length > 1}>
<RoomsHeader />
<NoAvailabilityAlert />
<RoomTypeFilter />
<BookingCodeFilter />
<RoomsList />
</MultiRoomWrapper>
</RoomProvider>