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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"+46 8 517 517 00": "+46 8 517 517 00",
|
||||
"/night per adult": "/nat per voksen",
|
||||
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
|
||||
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/nat</b> Vigtig information om priser og funktioner i kæledyrsvenlige værelser.",
|
||||
"<b>Included</b> (based on availability)": "<b>Inkluderet</b> (baseret på tilgængelighed)",
|
||||
"<b>Total price</b> (incl VAT)": "<b>Samlet pris</b> (inkl. moms)",
|
||||
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
|
||||
@@ -444,6 +445,7 @@
|
||||
"Log out": "Log ud",
|
||||
"Long {long} ∙ Lat {lat}": "Long {long} ∙ Lat {lat}",
|
||||
"Low floor": "Lav etage",
|
||||
"Lowest price (last 30 days)": "Laveste pris (sidste 30 dage)",
|
||||
"MY SAVED CARDS": "MINE SAVEDE KORT",
|
||||
"Main guest": "Main guest",
|
||||
"Main menu": "Hovedmenu",
|
||||
@@ -683,6 +685,7 @@
|
||||
"Room details": "Room details",
|
||||
"Room facilities": "Værelsesfaciliteter",
|
||||
"Room is prepaid": "Værelset er forudbetalt",
|
||||
"Room preferences": "Værelsespræferencer",
|
||||
"Room sold out": "Værelse solgt ud",
|
||||
"Room total": "Værelse total",
|
||||
"Room type": "Værelsestype",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"+46 8 517 517 00": "+46 8 517 517 00",
|
||||
"/night per adult": "/Nacht pro Erwachsenem",
|
||||
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
|
||||
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/Nacht</b> Wichtige Informationen zu Preisen und Eigenschaften von haustierfreundlichen Zimmern.",
|
||||
"<b>Included</b> (based on availability)": "<b>Inbegriffen</b> (je nach Verfügbarkeit)",
|
||||
"<b>Total price</b> (incl VAT)": "<b>Gesamtpreis</b> (inkl. MwSt.)",
|
||||
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
|
||||
@@ -445,6 +446,7 @@
|
||||
"Log out": "Ausloggen",
|
||||
"Long {long} ∙ Lat {lat}": "Long {long} ∙ Lat {lat}",
|
||||
"Low floor": "Niedrige Etage",
|
||||
"Lowest price (last 30 days)": "Niedrigster Preis (letzte 30 Tage)",
|
||||
"MY SAVED CARDS": "MEINE SAVEDEN KARTEN",
|
||||
"Main guest": "Main guest",
|
||||
"Main menu": "Hauptmenü",
|
||||
@@ -682,6 +684,7 @@
|
||||
"Room details": "Room details",
|
||||
"Room facilities": "Zimmerausstattung",
|
||||
"Room is prepaid": "Zimmer ist vorausbezahlt",
|
||||
"Room preferences": "Zimmerpräferenzen",
|
||||
"Room sold out": "Zimmer verkauft",
|
||||
"Room total": "Zimmer total",
|
||||
"Room type": "Zimmertyp",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"+46 8 517 517 00": "+46 8 517 517 00",
|
||||
"/night per adult": "/night per adult",
|
||||
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
|
||||
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.",
|
||||
"<b>Included</b> (based on availability)": "<b>Included</b> (based on availability)",
|
||||
"<b>Total price</b> (incl VAT)": "<b>Total price</b> (incl VAT)",
|
||||
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
|
||||
@@ -443,6 +444,7 @@
|
||||
"Log out": "Log out",
|
||||
"Long {long} ∙ Lat {lat}": "Long {long} ∙ Lat {lat}",
|
||||
"Low floor": "Low floor",
|
||||
"Lowest price (last 30 days)": "Lowest price (last 30 days)",
|
||||
"MY SAVED CARDS": "MY SAVED CARDS",
|
||||
"Main guest": "Main guest",
|
||||
"Main menu": "Main menu",
|
||||
@@ -681,6 +683,7 @@
|
||||
"Room details": "Room details",
|
||||
"Room facilities": "Room facilities",
|
||||
"Room is prepaid": "Room is prepaid",
|
||||
"Room preferences": "Room preferences",
|
||||
"Room sold out": "Room sold out",
|
||||
"Room total": "Room total",
|
||||
"Room type": "Room type",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"+46 8 517 517 00": "+46 8 517 517 00",
|
||||
"/night per adult": "/yötä aikuista kohti",
|
||||
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
|
||||
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/yö</b> Tärkeitä tietoja hinnoista ja lemmikkieläinystävällisten huoneiden ominaisuuksista.",
|
||||
"<b>Included</b> (based on availability)": "<b>Sisältyy</b> (saatavuuden mukaan)",
|
||||
"<b>Total price</b> (incl VAT)": "<b>Kokonaishinta</b> (sis. ALV)",
|
||||
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
|
||||
@@ -444,6 +445,7 @@
|
||||
"Log out": "Kirjaudu ulos",
|
||||
"Long {long} ∙ Lat {lat}": "Long {long} ∙ Lat {lat}",
|
||||
"Low floor": "Alhainen kerros",
|
||||
"Lowest price (last 30 days)": "Alin hinta (viimeiset 30 päivää)",
|
||||
"MY SAVED CARDS": "MINUN SAVED CARDS",
|
||||
"Main guest": "Main guest",
|
||||
"Main menu": "Päävalikko",
|
||||
@@ -681,6 +683,7 @@
|
||||
"Room details": "Room details",
|
||||
"Room facilities": "Huoneen varustelu",
|
||||
"Room is prepaid": "Huone on maksettu etukäteen",
|
||||
"Room preferences": "Huoneen mieltymykset",
|
||||
"Room sold out": "Huone slutsattu",
|
||||
"Room total": "Huoneen kokonaishinta",
|
||||
"Room type": "Huonetyyppi",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"+46 8 517 517 00": "+46 8 517 517 00",
|
||||
"/night per adult": "/natt per voksen",
|
||||
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
|
||||
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/natt</b> Viktig informasjon om priser og funksjoner for dyrevennlige rom.",
|
||||
"<b>Included</b> (based on availability)": "<b>Inkludert</b> (basert på tilgjengelighet)",
|
||||
"<b>Total price</b> (incl VAT)": "<b>Totalpris</b> (inkl. mva)",
|
||||
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
|
||||
@@ -443,6 +444,7 @@
|
||||
"Log out": "Logg ut",
|
||||
"Long {long} ∙ Lat {lat}": "Long {long} ∙ Lat {lat}",
|
||||
"Low floor": "Lav posisjon",
|
||||
"Lowest price (last 30 days)": "Laveste pris (siste 30 dager)",
|
||||
"MY SAVED CARDS": "MINE SAVEDE KORT",
|
||||
"Main guest": "Main guest",
|
||||
"Main menu": "Hovedmeny",
|
||||
@@ -680,6 +682,7 @@
|
||||
"Room details": "Room details",
|
||||
"Room facilities": "Romfasiliteter",
|
||||
"Room is prepaid": "Rommet er forhåndsbetalt",
|
||||
"Room preferences": "Rompreferanser",
|
||||
"Room total": "Rom total",
|
||||
"Room type": "Romtype",
|
||||
"Room {roomIndex}": "Rom {roomIndex}",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"+46 8 517 517 00": "+46 8 517 517 00",
|
||||
"/night per adult": "/natt per vuxen",
|
||||
"1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points",
|
||||
"<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.": "<b>200 SEK/natt</b> Viktig information om prissättning och funktioner för husdjursvänliga rum.",
|
||||
"<b>Included</b> (based on availability)": "<b>Ingår</b> (baserat på tillgänglighet)",
|
||||
"<b>Total price</b> (incl VAT)": "<b>Totalpris</b> (inkl moms)",
|
||||
"<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>": "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
|
||||
@@ -443,6 +444,7 @@
|
||||
"Log out": "Logga ut",
|
||||
"Long {long} ∙ Lat {lat}": "Long {long} ∙ Lat {lat}",
|
||||
"Low floor": "Lågt läge",
|
||||
"Lowest price (last 30 days)": "Lägsta pris (senaste 30 dagarna)",
|
||||
"MY SAVED CARDS": "MINE SAVEDE KORT",
|
||||
"Main guest": "Main guest",
|
||||
"Main menu": "Huvudmeny",
|
||||
@@ -680,6 +682,7 @@
|
||||
"Room details": "Room details",
|
||||
"Room facilities": "Rumfaciliteter",
|
||||
"Room is prepaid": "Rummet är förbetalt",
|
||||
"Room preferences": "Rumspreferenser",
|
||||
"Room sold out": "Rum slutsålt",
|
||||
"Room total": "Rum total",
|
||||
"Room type": "Rumstyp",
|
||||
|
||||
@@ -29,7 +29,7 @@ export function createRatesStore({
|
||||
searchParams,
|
||||
vat,
|
||||
}: InitialState) {
|
||||
const filterOptions = [
|
||||
const packageOptions = [
|
||||
{
|
||||
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
description: labels.accessibilityRoom,
|
||||
@@ -84,10 +84,10 @@ export function createRatesStore({
|
||||
rateSummary[idx] = {
|
||||
features: selectedRoom.features,
|
||||
product,
|
||||
packages: room.packages ?? [],
|
||||
rate: product.rate,
|
||||
roomType: selectedRoom.roomType,
|
||||
roomTypeCode: selectedRoom.roomTypeCode,
|
||||
package: room.packages?.[0],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export function createRatesStore({
|
||||
return {
|
||||
activeRoom,
|
||||
booking,
|
||||
filterOptions,
|
||||
packageOptions,
|
||||
hotelType,
|
||||
isUserLoggedIn,
|
||||
packages,
|
||||
@@ -132,14 +132,16 @@ export function createRatesStore({
|
||||
|
||||
// Since features are fetched async based on query string, we need to read from query string to apply correct filtering
|
||||
const packagesParam = searchParams.get(`room[${idx}].packages`)
|
||||
const selectedPackage = isRoomPackageCode(packagesParam)
|
||||
? packagesParam
|
||||
: undefined
|
||||
const selectedPackages = packagesParam
|
||||
? packagesParam.split(",").filter(isRoomPackageCode)
|
||||
: []
|
||||
|
||||
let rooms: RoomConfiguration[] = roomConfiguration
|
||||
if (selectedPackage) {
|
||||
if (selectedPackages.length) {
|
||||
rooms = roomConfiguration.filter((r) =>
|
||||
r.features.find((f) => f.code === selectedPackage)
|
||||
selectedPackages.some((pkg) =>
|
||||
r.features.find((f) => f.code === pkg)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -203,35 +205,48 @@ export function createRatesStore({
|
||||
})
|
||||
)
|
||||
},
|
||||
selectPackage(code) {
|
||||
togglePackage(code) {
|
||||
return set(
|
||||
produce((state: RatesState) => {
|
||||
state.rooms[idx].selectedPackage = code
|
||||
const isSelected =
|
||||
state.rooms[idx].selectedPackages.includes(code)
|
||||
const selectedPackages = isSelected
|
||||
? state.rooms[idx].selectedPackages.filter(
|
||||
(pkg) => pkg !== code
|
||||
)
|
||||
: [...state.rooms[idx].selectedPackages, code]
|
||||
state.rooms[idx].selectedPackages = selectedPackages
|
||||
|
||||
const roomConfiguration = state.roomConfigurations[idx]
|
||||
if (roomConfiguration) {
|
||||
const searchParams = new URLSearchParams(state.searchParams)
|
||||
if (code) {
|
||||
if (selectedPackages.length) {
|
||||
state.rooms[idx].rooms = roomConfiguration.filter(
|
||||
(room) =>
|
||||
room.features.find((feat) => feat.code === code)
|
||||
selectedPackages.every((pkg) =>
|
||||
room.features.find((feat) => feat.code === pkg)
|
||||
)
|
||||
)
|
||||
searchParams.set(
|
||||
`room[${idx}].packages`,
|
||||
selectedPackages.join(",")
|
||||
)
|
||||
searchParams.set(`room[${idx}].packages`, code)
|
||||
|
||||
if (state.rateSummary[idx]) {
|
||||
state.rateSummary[idx].package = code
|
||||
state.rateSummary[idx].packages = selectedPackages
|
||||
}
|
||||
} else {
|
||||
state.rooms[idx].rooms = roomConfiguration
|
||||
searchParams.delete(`room[${idx}].packages`)
|
||||
|
||||
if (state.rateSummary[idx]) {
|
||||
state.rateSummary[idx].package = undefined
|
||||
state.rateSummary[idx].packages = []
|
||||
}
|
||||
searchParams.delete(`room[${idx}].packages`)
|
||||
}
|
||||
|
||||
state.searchParams = new ReadonlyURLSearchParams(
|
||||
searchParams
|
||||
)
|
||||
|
||||
window.history.pushState(
|
||||
{},
|
||||
"",
|
||||
@@ -251,7 +266,7 @@ export function createRatesStore({
|
||||
state.rooms[idx].selectedRate = selectedRate
|
||||
state.rateSummary[idx] = {
|
||||
features: selectedRate.features,
|
||||
package: state.rooms[idx].selectedPackage,
|
||||
packages: state.rooms[idx].selectedPackages,
|
||||
product: selectedRate.product,
|
||||
rate: selectedRate.product.rate,
|
||||
roomType: selectedRate.roomType,
|
||||
@@ -346,11 +361,12 @@ export function createRatesStore({
|
||||
selectedFilter: booking.bookingCode
|
||||
? BookingCodeFilterEnum.Discounted
|
||||
: BookingCodeFilterEnum.All,
|
||||
selectedPackage,
|
||||
selectedPackages,
|
||||
selectedRate:
|
||||
selectedRate && product
|
||||
? {
|
||||
features: selectedRate.features,
|
||||
packages: selectedPackages,
|
||||
product,
|
||||
roomType: selectedRate.roomType,
|
||||
roomTypeCode: selectedRate.roomTypeCode,
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface SelectRateSearchParams {
|
||||
|
||||
export type Rate = {
|
||||
features: RoomConfiguration["features"]
|
||||
package?: RoomPackageCodeEnum | undefined
|
||||
packages: RoomPackageCodeEnum[]
|
||||
priceName?: string
|
||||
priceTerm?: string
|
||||
product: Product
|
||||
|
||||
@@ -28,7 +28,7 @@ interface Actions {
|
||||
closeSection: () => void
|
||||
modifyRate: () => void
|
||||
selectFilter: (filter: BookingCodeFilterEnum) => void
|
||||
selectPackage: (code: RoomPackageCodeEnum | undefined) => void
|
||||
togglePackage: (code: RoomPackageCodeEnum) => void
|
||||
selectRate: (rate: SelectedRate) => void
|
||||
}
|
||||
|
||||
@@ -44,14 +44,14 @@ export interface SelectedRoom {
|
||||
bookingRoom: RoomBooking
|
||||
rooms: RoomConfiguration[]
|
||||
selectedFilter: BookingCodeFilterEnum | undefined
|
||||
selectedPackage: RoomPackageCodeEnum | undefined
|
||||
selectedPackages: RoomPackageCodeEnum[]
|
||||
selectedRate: SelectedRate | null
|
||||
}
|
||||
|
||||
export interface RatesState {
|
||||
activeRoom: number
|
||||
booking: SelectRateSearchParams
|
||||
filterOptions: DefaultFilterOptions[]
|
||||
packageOptions: DefaultFilterOptions[]
|
||||
hotelType: string | undefined
|
||||
isUserLoggedIn: boolean
|
||||
packages: NonNullable<Packages>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import 'react-material-symbols/rounded'
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { fn } from '@storybook/test'
|
||||
|
||||
import { ChipButton } from './ChipButton.tsx'
|
||||
import { MaterialSymbol } from 'react-material-symbols'
|
||||
import { ChipButton } from './ChipButton.tsx'
|
||||
import { config as chipButtonConfig } from './variants'
|
||||
|
||||
const meta: Meta<typeof ChipButton> = {
|
||||
title: 'Components/Chip/ChipButton 🚧',
|
||||
title: 'Components/Chip/ChipButton',
|
||||
component: ChipButton,
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
type: 'string',
|
||||
options: Object.keys(chipButtonConfig.variants.variant),
|
||||
},
|
||||
onPress: {
|
||||
table: {
|
||||
disable: true,
|
||||
@@ -32,3 +40,16 @@ export const Default: Story = {
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const Outlined: Story = {
|
||||
args: {
|
||||
variant: 'Outlined',
|
||||
onPress: fn(),
|
||||
children: (
|
||||
<>
|
||||
Button Chip
|
||||
<MaterialSymbol icon="keyboard_arrow_down" size={20} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { Button as ButtonRAC } from 'react-aria-components'
|
||||
|
||||
import { Typography } from '../Typography'
|
||||
|
||||
import styles from './chip-button.module.css'
|
||||
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import { ChipButtonProps } from './types'
|
||||
import { variants } from './variants'
|
||||
|
||||
export function ChipButton({
|
||||
children,
|
||||
variant,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof ButtonRAC>) {
|
||||
}: ChipButtonProps) {
|
||||
const classNames = variants({
|
||||
variant,
|
||||
})
|
||||
|
||||
return (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<ButtonRAC {...props} className={[styles.chip, className].join(' ')}>
|
||||
<ButtonRAC {...props} className={[className, classNames].join(' ')}>
|
||||
{children}
|
||||
</ButtonRAC>
|
||||
</Typography>
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
.chip {
|
||||
background-color: var(--Component-Button-Inverted-Fill-Default);
|
||||
border-color: var(--Component-Button-Inverted-Border-Default);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-radius: var(--Corner-radius-sm);
|
||||
padding: var(--Space-x1) var(--Space-x15);
|
||||
color: var(--Text-Interactive-Default);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
/* TODO: change to proper Component-variable once it is available */
|
||||
background-color: var(--Scandic-Peach-10);
|
||||
/* TODO: change to proper Component-variable once it is available */
|
||||
color: var(--Scandic-Red-100);
|
||||
.Default {
|
||||
border: 1px solid var(--Component-Button-Inverted-Border-Default);
|
||||
}
|
||||
|
||||
.Default:hover {
|
||||
background-color: var(--Surface-Primary-Hover-Accent);
|
||||
}
|
||||
|
||||
.Outlined {
|
||||
border: 1px solid var(--Border-Intense);
|
||||
}
|
||||
|
||||
.Outlined:hover {
|
||||
background-color: var(--Surface-Primary-Hover);
|
||||
}
|
||||
|
||||
.Outlined:focus,
|
||||
.Outlined:active {
|
||||
border-color: var(--Border-Interactive-Selected);
|
||||
}
|
||||
|
||||
10
packages/design-system/lib/components/ChipButton/types.ts
Normal file
10
packages/design-system/lib/components/ChipButton/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Button } from 'react-aria-components'
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import type { variants } from './variants'
|
||||
|
||||
export interface ChipButtonProps
|
||||
extends ComponentProps<typeof Button>,
|
||||
VariantProps<typeof variants> {}
|
||||
29
packages/design-system/lib/components/ChipButton/variants.ts
Normal file
29
packages/design-system/lib/components/ChipButton/variants.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
import { deepmerge } from 'deepmerge-ts'
|
||||
import styles from './chip-button.module.css'
|
||||
|
||||
export const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
Default: styles.Default,
|
||||
Outlined: styles.Outlined,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'Default',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const variants = cva(styles.chip, config)
|
||||
|
||||
const chipConfig = {
|
||||
variants: {
|
||||
typography: config.variants.variant,
|
||||
},
|
||||
defaultVariants: config.defaultVariants,
|
||||
} as const
|
||||
|
||||
export function withChipButton<T>(config: T) {
|
||||
return deepmerge(chipConfig, config)
|
||||
}
|
||||
Reference in New Issue
Block a user