Merged in feature/select-rate-vertical-data-flow (pull request #2535)

Feature/select rate vertical data flow

* add fix from SW-2666

* use translations for room packages

* move types to it's own file

* Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/select-rate-vertical-data-flow

* merge

* feature/select-rate: double rate for campaing rates

* revert NODE_ENV check in Cookiebot script

* revert testing values

* fix(SW-3171): fix all filter selected in price details

* fix(SW-3166): multiroom anchoring when changing filter

* fix(SW-3172): check hotelType, show correct breakfast message

* Merge branch 'feature/select-rate-vertical-data-flow' of bitbucket.org:scandic-swap/web into feature/select-rate-vertical-data-flow

* fix: show special needs icons for subsequent roomTypes SW-3167

* fix: Display strike through text when logged in SW-3168

* fix: Reinstate the scrollToView behaviour when selecting a rate SW-3169

* merge

* .

* PR fixes

* fix: don't return notFound()

* .

* always include defaults for room packages

* merge

* merge

* merge

* Remove floating h1 for new select-rate


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-08-13 12:45:40 +00:00
parent 706f2d8dfe
commit 68cd061c6d
126 changed files with 8751 additions and 315 deletions

View File

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

View File

@@ -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 { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
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 { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useBreakpoint } from "@/hooks/useBreakpoint"
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>
</>
)
}

View File

@@ -0,0 +1,34 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import BookingCodeChip from "@/components/BookingCodeChip"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
export function RemoveBookingCodeButton() {
const {
input: { bookingCode },
} = useSelectRateContext()
const router = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
if (!bookingCode) {
return null
}
return (
<BookingCodeChip
bookingCode={bookingCode}
filledIcon
withCloseButton={true}
withText={false}
onClose={() => {
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.delete("bookingCode")
const url = `${pathname}?${newSearchParams.toString()}`
router.replace(url)
}}
/>
)
}

View File

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

View File

@@ -0,0 +1,8 @@
.additionalInformation {
color: var(--Text-Tertiary);
padding: var(--Space-x1) var(--Space-x15);
}
.additionalInformationPrice {
color: var(--Text-Default);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
export type FormValues = {
selectedPackages: PackageEnum[]
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,134 @@
"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,
})

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
"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 { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
import { RoomPackageFilter } from "./RoomPackageFilter"
import styles from "./roomsHeader.module.css"
export function RoomsHeader({ roomIndex }: { roomIndex: number }) {
return (
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
<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>
)
}

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: flex-start;
}
@media screen and (min-width: 768px) {
.container {
grid-template-columns: 1fr auto;
}
}