Merged in feat/sw-2874-move-select-rate (pull request #2750)
Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
.bookingCodeFilter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
border-radius: var(--Corner-radius-md);
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
box-shadow: var(--popup-box-shadow);
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.radio {
|
||||
padding: var(--Space-x1);
|
||||
}
|
||||
|
||||
.radio[data-hovered] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio[data-focus-visible]::before {
|
||||
outline: 1px auto var(--Border-Interactive-Focus);
|
||||
}
|
||||
|
||||
.radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.radio::before {
|
||||
flex-shrink: 0;
|
||||
content: "";
|
||||
margin-right: var(--Space-x15);
|
||||
background-color: var(--Surface-UI-Fill-Default);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 2px var(--Base-Border-Normal);
|
||||
}
|
||||
|
||||
.radio[data-selected]::before {
|
||||
box-shadow: inset 0 0 0 8px var(--Surface-UI-Fill-Active);
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--Overlay-40);
|
||||
|
||||
&[data-entering] {
|
||||
animation: overlay-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: overlay-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--Space-x2) var(--Space-x05);
|
||||
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-anim 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-anim 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modalDialog {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
padding: 0 var(--Space-x1);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 var(--Space-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.radioGroup {
|
||||
padding: var(--Space-x1);
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlay-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-anim {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
Popover,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
|
||||
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
|
||||
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { useBreakpoint } from "../../../../../../hooks/useBreakpoint"
|
||||
import { BookingCodeFilterEnum } from "../../../../../../stores/bookingCode-filter"
|
||||
|
||||
import styles from "./bookingCodeFilter.module.css"
|
||||
|
||||
export function BookingCodeFilter({ roomIndex }: { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const displayAsModal = useBreakpoint("mobile")
|
||||
|
||||
const {
|
||||
input,
|
||||
getAvailabilityForRoom,
|
||||
bookingCodeFilter,
|
||||
actions: { selectBookingCodeFilter },
|
||||
} = useSelectRateContext()
|
||||
const roomAvailability = getAvailabilityForRoom(roomIndex)
|
||||
|
||||
const bookingCodeFilterItems = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Booking code rates",
|
||||
}),
|
||||
value: BookingCodeFilterEnum.Discounted,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "All rates",
|
||||
}),
|
||||
value: BookingCodeFilterEnum.All,
|
||||
},
|
||||
]
|
||||
|
||||
async function updateFilterValue(selectedFilter: string) {
|
||||
selectBookingCodeFilter(selectedFilter as BookingCodeFilterEnum)
|
||||
}
|
||||
|
||||
const hideFilter = (roomAvailability ?? []).some((room) => {
|
||||
room.products.some((product) => {
|
||||
const isRedemption = Array.isArray(product)
|
||||
if (isRedemption) {
|
||||
return true
|
||||
}
|
||||
|
||||
switch (product.rateDefinition.rateType) {
|
||||
case RateTypeEnum.Arb:
|
||||
case RateTypeEnum.CorporateCheque:
|
||||
case RateTypeEnum.Voucher:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (hideFilter || !input?.bookingCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.bookingCodeFilter}>
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ChipButton variant="Outlined">
|
||||
{
|
||||
bookingCodeFilterItems.find(
|
||||
(item) => item.value === bookingCodeFilter
|
||||
)?.label
|
||||
}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</ChipButton>
|
||||
{!displayAsModal ? (
|
||||
<Popover placement="bottom end" isNonModal>
|
||||
<Dialog className={styles.dialog}>
|
||||
{({ close }) => {
|
||||
function handleChangeFilterValue(value: string) {
|
||||
updateFilterValue(value)
|
||||
close()
|
||||
}
|
||||
return (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<RadioGroup
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Booking Code Filter",
|
||||
})}
|
||||
onChange={handleChangeFilterValue}
|
||||
name="bookingCodeFilter"
|
||||
value={bookingCodeFilter}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
{bookingCodeFilterItems.map((item) => (
|
||||
<Radio
|
||||
aria-label={item.label}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className={styles.radio}
|
||||
autoFocus={bookingCodeFilter === item.value}
|
||||
>
|
||||
{item.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Typography>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
) : (
|
||||
<ModalOverlay className={styles.modalOverlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog className={styles.modalDialog}>
|
||||
{({ close }) => {
|
||||
function handleChangeFilterValue(value: string) {
|
||||
updateFilterValue(value)
|
||||
close()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Room rates",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<IconButton
|
||||
theme="Black"
|
||||
style="Muted"
|
||||
onPress={() => {
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="close"
|
||||
size={24}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<RadioGroup
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Booking Code Filter",
|
||||
})}
|
||||
onChange={handleChangeFilterValue}
|
||||
name="bookingCodeFilter"
|
||||
value={bookingCodeFilter}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
{bookingCodeFilterItems.map((item) => (
|
||||
<Radio
|
||||
aria-label={item.label}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className={styles.radio}
|
||||
>
|
||||
{item.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Typography>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
|
||||
export function RemoveBookingCodeButton() {
|
||||
const {
|
||||
input: { bookingCode },
|
||||
actions: { removeBookingCode },
|
||||
} = useSelectRateContext()
|
||||
|
||||
if (!bookingCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<BookingCodeChip
|
||||
bookingCode={bookingCode}
|
||||
filledIcon
|
||||
withCloseButton={true}
|
||||
withText={false}
|
||||
onClose={() => {
|
||||
removeBookingCode()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./petRoom.module.css"
|
||||
|
||||
export default function PetRoomMessage({
|
||||
priceData,
|
||||
}: {
|
||||
priceData?: { price: number; currency: string }
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!priceData) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.additionalInformation}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>",
|
||||
},
|
||||
{
|
||||
b: (str) => (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span className={styles.additionalInformationPrice}>{str}</span>
|
||||
</Typography>
|
||||
),
|
||||
price: formatPrice(intl, priceData.price, priceData.currency),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.additionalInformation {
|
||||
color: var(--Text-Tertiary);
|
||||
padding: var(--Space-x1) var(--Space-x15);
|
||||
}
|
||||
|
||||
.additionalInformationPrice {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.checkboxGroup {
|
||||
display: grid;
|
||||
gap: var(--Space-x15);
|
||||
}
|
||||
|
||||
.checkboxWrapper {
|
||||
display: grid;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.checkboxField {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--Space-x15);
|
||||
padding: var(--Space-x1) var(--Space-x15);
|
||||
cursor: pointer;
|
||||
border-radius: var(--Corner-radius-md);
|
||||
transition: background-color 0.3s;
|
||||
color: var(--Text-Default);
|
||||
|
||||
&[data-disabled] {
|
||||
cursor: unset;
|
||||
|
||||
.checkbox {
|
||||
border-color: var(--Border-Interactive-Disabled);
|
||||
background-color: var(--Surface-UI-Fill-Disabled);
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--Base-Text-Disabled);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not([data-disabled]) {
|
||||
background-color: var(--UI-Input-Controls-Surface-Hover);
|
||||
}
|
||||
|
||||
&[data-focus-visible] .checkbox {
|
||||
/* Used this value as it makes sense from a token name perspective and has a good contrast, but we need to decide for a default ui state */
|
||||
outline: 2px solid var(--Border-Interactive-Focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&[data-selected] .checkbox {
|
||||
border-color: var(--Surface-UI-Fill-Active);
|
||||
background-color: var(--Surface-UI-Fill-Active);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border: 1px solid var(--Border-Interactive-Default);
|
||||
border-radius: var(--Corner-radius-sm);
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--Surface-UI-Fill-Default);
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.checkboxField:hover:not([data-disabled]) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.checkboxField[data-selected] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
import { Checkbox, CheckboxGroup } from "react-aria-components"
|
||||
import { Controller, useFormContext } from "react-hook-form"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { usePackageLabels } from "../../usePackageLabels"
|
||||
import { getIconNameByPackageCode } from "../../utils"
|
||||
|
||||
import styles from "./checkbox.module.css"
|
||||
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type { FormValues } from "../formValues"
|
||||
|
||||
export function PackageCheckboxes({
|
||||
availablePackages,
|
||||
}: {
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message?: ReactNode
|
||||
}[]
|
||||
}) {
|
||||
const { control } = useFormContext<FormValues>()
|
||||
const packageLabels = usePackageLabels()
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="selectedPackages"
|
||||
render={({ field }) => {
|
||||
const allergyRoomSelected = includesAllergyRoom(field.value)
|
||||
const petRoomSelected = includesPetRoom(field.value)
|
||||
return (
|
||||
<CheckboxGroup {...field} className={styles.checkboxGroup}>
|
||||
{availablePackages?.map((option) => {
|
||||
const isAllergyRoom = checkIsAllergyRoom(option.code)
|
||||
const isPetRoom = checkIsPetRoom(option.code)
|
||||
const isDisabled =
|
||||
(isPetRoom && allergyRoomSelected) ||
|
||||
(isAllergyRoom && petRoomSelected)
|
||||
|
||||
const isSelected = field.value.includes(option.code)
|
||||
const iconName = getIconNameByPackageCode(option.code)
|
||||
|
||||
return (
|
||||
<div key={option.code} className={styles.checkboxWrapper}>
|
||||
<Checkbox
|
||||
key={option.code}
|
||||
className={styles.checkboxField}
|
||||
isDisabled={isDisabled}
|
||||
value={option.code}
|
||||
>
|
||||
<span className={styles.checkbox}>
|
||||
{isSelected ? (
|
||||
<MaterialIcon icon="check" color="Icon/Inverted" />
|
||||
) : null}
|
||||
</span>
|
||||
<Typography
|
||||
className={styles.text}
|
||||
variant="Body/Paragraph/mdRegular"
|
||||
>
|
||||
<span>{packageLabels[option.code]}</span>
|
||||
</Typography>
|
||||
{iconName ? (
|
||||
<MaterialIcon icon={iconName} color="Icon/Default" />
|
||||
) : null}
|
||||
</Checkbox>
|
||||
{option.message}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CheckboxGroup>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function includesAllergyRoom(codes: PackageEnum[]) {
|
||||
return codes.includes(RoomPackageCodeEnum.ALLERGY_ROOM)
|
||||
}
|
||||
|
||||
export function includesPetRoom(codes: PackageEnum[]) {
|
||||
return codes.includes(RoomPackageCodeEnum.PET_ROOM)
|
||||
}
|
||||
|
||||
export function checkIsAllergyRoom(
|
||||
code: PackageEnum
|
||||
): code is RoomPackageCodeEnum.ALLERGY_ROOM {
|
||||
return code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
}
|
||||
|
||||
export function checkIsPetRoom(
|
||||
code: PackageEnum
|
||||
): code is RoomPackageCodeEnum.PET_ROOM {
|
||||
return code === RoomPackageCodeEnum.PET_ROOM
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.footer {
|
||||
padding: 0 var(--Space-x15);
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: var(--Space-x15) 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
.footer {
|
||||
margin-top: var(--Space-x5);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.buttonContainer {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export type FormValues = {
|
||||
selectedPackages: PackageEnum[]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { PackageCheckboxes } from "./Checkboxes"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type { FormValues } from "./formValues"
|
||||
|
||||
export function RoomPackagesForm({
|
||||
close,
|
||||
selectedPackages,
|
||||
onSelectPackages,
|
||||
availablePackages,
|
||||
}: {
|
||||
close: () => void
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message: ReactNode
|
||||
}[]
|
||||
selectedPackages: PackageEnum[]
|
||||
onSelectPackages: (packages: PackageEnum[]) => void
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
const methods = useForm<FormValues>({
|
||||
values: {
|
||||
selectedPackages: selectedPackages,
|
||||
},
|
||||
})
|
||||
|
||||
function clearSelectedPackages() {
|
||||
onSelectPackages([])
|
||||
close()
|
||||
}
|
||||
|
||||
function onSubmit(data: FormValues) {
|
||||
onSelectPackages(data.selectedPackages)
|
||||
close()
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<PackageCheckboxes availablePackages={availablePackages} />
|
||||
<div className={styles.footer}>
|
||||
<Divider color="Border/Divider/Subtle" className={styles.divider} />
|
||||
<div className={styles.buttonContainer}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<Button variant="Tertiary" size="Small" type="submit">
|
||||
{intl.formatMessage({ defaultMessage: "Apply" })}
|
||||
</Button>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<Button
|
||||
onPress={clearSelectedPackages}
|
||||
size="Small"
|
||||
variant="Text"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Clear",
|
||||
})}
|
||||
</Button>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { type ReactNode, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
|
||||
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { RoomPackagesForm } from "./Form"
|
||||
|
||||
import styles from "./roomPackageFilter.module.css"
|
||||
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export function RoomPackageFilterModal({
|
||||
selectedPackages,
|
||||
onSelectPackages,
|
||||
availablePackages,
|
||||
}: {
|
||||
onSelectPackages: (packages: PackageEnum[]) => void
|
||||
selectedPackages: PackageEnum[]
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message: ReactNode
|
||||
}[]
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ChipButton variant="Outlined">
|
||||
{intl.formatMessage({ defaultMessage: "Special needs" })}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</ChipButton>
|
||||
|
||||
<ModalOverlay className={styles.modalOverlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog className={styles.modalDialog}>
|
||||
<div className={styles.header}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>
|
||||
{intl.formatMessage({ defaultMessage: "Special needs" })}
|
||||
</h3>
|
||||
</Typography>
|
||||
<IconButton
|
||||
theme="Black"
|
||||
style="Muted"
|
||||
onPress={() => setIsOpen(false)}
|
||||
>
|
||||
<MaterialIcon icon="close" size={24} color="CurrentColor" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<RoomPackagesForm
|
||||
close={() => setIsOpen(false)}
|
||||
availablePackages={availablePackages}
|
||||
selectedPackages={selectedPackages}
|
||||
onSelectPackages={onSelectPackages}
|
||||
/>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { 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/MaterialIcon"
|
||||
|
||||
import { RoomPackagesForm } from "./Form"
|
||||
|
||||
import styles from "./roomPackageFilter.module.css"
|
||||
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export function RoomPackageFilterPopover({
|
||||
selectedPackages,
|
||||
onSelectPackages,
|
||||
availablePackages,
|
||||
}: {
|
||||
onSelectPackages: (packages: PackageEnum[]) => void
|
||||
selectedPackages: PackageEnum[]
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message: ReactNode
|
||||
}[]
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ChipButton variant="Outlined">
|
||||
{intl.formatMessage({ defaultMessage: "Special needs" })}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</ChipButton>
|
||||
|
||||
<Popover placement="bottom end" className={styles.popover}>
|
||||
<Dialog>
|
||||
<RoomPackagesForm
|
||||
close={() => setIsOpen(false)}
|
||||
availablePackages={availablePackages}
|
||||
selectedPackages={selectedPackages}
|
||||
onSelectPackages={onSelectPackages}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
import { Button as ButtonRAC } from "react-aria-components"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { useBreakpoint } from "../../../../../../hooks/useBreakpoint"
|
||||
import PetRoomMessage from "./Form/Checkboxes/PetRoomMessage"
|
||||
import { RoomPackageFilterModal } from "./Modal"
|
||||
import { RoomPackageFilterPopover } from "./Popover"
|
||||
import { usePackageLabels } from "./usePackageLabels"
|
||||
import { getIconNameByPackageCode } from "./utils"
|
||||
|
||||
import styles from "./roomPackageFilter.module.css"
|
||||
|
||||
import type { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export function RoomPackageFilter({ roomIndex }: { roomIndex: number }) {
|
||||
const displayAsModal = useBreakpoint("mobile")
|
||||
|
||||
const {
|
||||
getPackagesForRoom,
|
||||
actions: { selectPackages },
|
||||
} = useSelectRateContext()
|
||||
|
||||
const { selectedPackages, availablePackages } = getPackagesForRoom(roomIndex)
|
||||
|
||||
function deletePackage(code: PackageEnum) {
|
||||
selectPackages({
|
||||
roomIndex,
|
||||
packages: selectedPackages
|
||||
.filter((pkg) => pkg.code !== code)
|
||||
.map((pkg) => pkg.code),
|
||||
})
|
||||
}
|
||||
|
||||
const petRoomPackage = availablePackages.find(
|
||||
(x) => x.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const packageLabels = usePackageLabels()
|
||||
const packageMessages = packageMessageMap({
|
||||
petRoomPrice:
|
||||
petRoomPackage && !("type" in petRoomPackage)
|
||||
? petRoomPackage.localPrice
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const packages = availablePackages
|
||||
.map((x) => {
|
||||
if (!isRoomPackage(x)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
code: x.code,
|
||||
message: packageMessages[x.code],
|
||||
}
|
||||
})
|
||||
.filter((x) => {
|
||||
return !!x
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={styles.roomPackageFilter}>
|
||||
<div className={styles.selectedPackages}>
|
||||
{selectedPackages.map((pkg) => (
|
||||
<Typography
|
||||
key={pkg.code}
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
>
|
||||
<span className={styles.selectedPackage}>
|
||||
<MaterialIcon
|
||||
icon={getIconNameByPackageCode(pkg.code)}
|
||||
size={16}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
{packageLabels[pkg.code] ?? pkg.description}
|
||||
<ButtonRAC
|
||||
onPress={() => deletePackage(pkg.code)}
|
||||
className={styles.removeButton}
|
||||
>
|
||||
<MaterialIcon icon="close" size={16} color="CurrentColor" />
|
||||
</ButtonRAC>
|
||||
</span>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
{displayAsModal ? (
|
||||
<div>
|
||||
<RoomPackageFilterModal
|
||||
availablePackages={packages}
|
||||
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||
onSelectPackages={(packages) => {
|
||||
selectPackages({ roomIndex, packages })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<RoomPackageFilterPopover
|
||||
availablePackages={packages}
|
||||
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||
onSelectPackages={(packages) => {
|
||||
selectPackages({ roomIndex, packages })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isRoomPackage(x: {
|
||||
code: BreakfastPackageEnum | RoomPackageCodeEnum
|
||||
}): x is { code: RoomPackageCodeEnum } {
|
||||
return Object.values(RoomPackageCodeEnum).includes(
|
||||
x.code as RoomPackageCodeEnum
|
||||
)
|
||||
}
|
||||
|
||||
const packageMessageMap = ({
|
||||
petRoomPrice,
|
||||
}: {
|
||||
petRoomPrice?: { price: number; currency: string }
|
||||
}): Record<RoomPackageCodeEnum, ReactNode | undefined> => ({
|
||||
[RoomPackageCodeEnum.PET_ROOM]: <PetRoomMessage priceData={petRoomPrice} />,
|
||||
[RoomPackageCodeEnum.ACCESSIBILITY_ROOM]: undefined,
|
||||
[RoomPackageCodeEnum.ALLERGY_ROOM]: undefined,
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
.roomPackageFilter {
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.selectedPackages {
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--Overlay-40);
|
||||
|
||||
&[data-entering] {
|
||||
animation: overlay-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: overlay-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--Space-x2) var(--Space-x05);
|
||||
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-anim 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-anim 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modalDialog {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 var(--Space-x15);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
padding: 0 var(--Space-x15);
|
||||
}
|
||||
|
||||
.selectedPackage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--Space-x1);
|
||||
gap: var(--Space-x05);
|
||||
border-radius: var(--Corner-radius-sm);
|
||||
background-color: var(--Surface-Secondary-Default-dark);
|
||||
color: var(--Text-Interactive-Default);
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
padding: var(--Space-x05);
|
||||
margin: calc(-1 * var(--Space-x05));
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.popover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.roomPackageFilter {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popover {
|
||||
padding: var(--Space-x2);
|
||||
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;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
padding: 0 var(--Space-x1);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlay-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-anim {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
export const usePackageLabels = () => {
|
||||
const intl = useIntl()
|
||||
|
||||
const labels: Record<RoomPackageCodeEnum, string> = {
|
||||
[RoomPackageCodeEnum.ALLERGY_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Allergy-friendly room",
|
||||
}),
|
||||
[RoomPackageCodeEnum.PET_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Pet-friendly room",
|
||||
}),
|
||||
[RoomPackageCodeEnum.ACCESSIBILITY_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Accessible room",
|
||||
}),
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import type { MaterialSymbolProps } from "@scandic-hotels/design-system/Icons/MaterialIcon/MaterialSymbol"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export function getIconNameByPackageCode(
|
||||
packageCode: PackageEnum
|
||||
): 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { ErrorBoundary } from "../../../../ErrorBoundary/ErrorBoundary"
|
||||
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
|
||||
import { RoomPackageFilter } from "./RoomPackageFilter"
|
||||
|
||||
import styles from "./roomsHeader.module.css"
|
||||
|
||||
export function RoomsHeader({ roomIndex }: { roomIndex: number }) {
|
||||
return (
|
||||
<ErrorBoundary fallback={<div>Unable to render rooms header</div>}>
|
||||
<InnerRoomsHeader roomIndex={roomIndex} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function InnerRoomsHeader({ roomIndex }: { roomIndex: number }) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<AvailableRoomCount roomIndex={roomIndex} />
|
||||
<div className={styles.filters}>
|
||||
<RemoveBookingCodeButton />
|
||||
<RoomPackageFilter roomIndex={roomIndex} />
|
||||
{/* <BookingCodeFilter roomIndex={roomIndex} /> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AvailableRoomCount({ roomIndex }: { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
const { isFetching, getAvailabilityForRoom } = useSelectRateContext()
|
||||
|
||||
const roomAvailability = getAvailabilityForRoom(roomIndex) ?? []
|
||||
|
||||
const availableRooms = roomAvailability.filter(
|
||||
(x) => x.status === AvailabilityEnum.Available
|
||||
).length
|
||||
|
||||
const totalRooms = roomAvailability.length
|
||||
|
||||
const notAllRoomsAvailableText = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{availableRooms}/{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
||||
},
|
||||
{
|
||||
availableRooms,
|
||||
numberOfRooms: totalRooms,
|
||||
}
|
||||
)
|
||||
|
||||
const allRoomsAvailableText = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
||||
},
|
||||
{
|
||||
numberOfRooms: totalRooms,
|
||||
}
|
||||
)
|
||||
|
||||
if (isFetching) {
|
||||
return <SkeletonShimmer height="30px" width="25ch" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant="Title/Subtitle/md" className={styles.availableRooms}>
|
||||
<p>
|
||||
{availableRooms !== totalRooms
|
||||
? notAllRoomsAvailableText
|
||||
: allRoomsAvailableText}
|
||||
</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -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: flex-start;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user