Merged in feat/sw-2879-booking-widget-to-booking-flow-package (pull request #2532)
feat(SW-2879): Move BookingWidget to booking-flow package * Fix lockfile * Fix styling * a tiny little booking widget test * Tiny fixes * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Remove unused scripts * lint:fix * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Tiny lint fixes * update test * Update Input in booking-flow * Clean up comments etc * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Setup tracking context for booking-flow * Add missing use client * Fix temp tracking function * Pass booking to booking-widget * Remove comment * Add use client to booking widget tracking provider * Add use client to tracking functions * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Move debug page * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package Approved-by: Bianca Widstam
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
|
||||
import Counter from "../Counter"
|
||||
|
||||
import styles from "./adult-selector.module.css"
|
||||
|
||||
type AdultSelectorProps = {
|
||||
roomIndex: number
|
||||
currentAdults: number
|
||||
}
|
||||
export default function AdultSelector({
|
||||
roomIndex = 0,
|
||||
currentAdults,
|
||||
}: AdultSelectorProps) {
|
||||
const name = `rooms.${roomIndex}.adults`
|
||||
const intl = useIntl()
|
||||
const adultsLabel = intl.formatMessage({
|
||||
defaultMessage: "Adults",
|
||||
})
|
||||
const { setValue } = useFormContext()
|
||||
|
||||
function increaseAdultsCount() {
|
||||
if (currentAdults < 6) {
|
||||
setValue(name, currentAdults + 1, { shouldDirty: true })
|
||||
}
|
||||
}
|
||||
|
||||
function decreaseAdultsCount() {
|
||||
if (currentAdults > 1) {
|
||||
setValue(name, currentAdults - 1, { shouldDirty: true })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{adultsLabel}
|
||||
</Caption>
|
||||
<Counter
|
||||
count={currentAdults}
|
||||
handleOnDecrease={decreaseAdultsCount}
|
||||
handleOnIncrease={increaseAdultsCount}
|
||||
disableDecrease={currentAdults == 1}
|
||||
disableIncrease={currentAdults == 6}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
|
||||
import styles from "./child-selector.module.css"
|
||||
|
||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
|
||||
const ageList = [...Array(13)].map((_, i) => ({
|
||||
label: i.toString(),
|
||||
value: i,
|
||||
}))
|
||||
|
||||
const childDefaultValues = { age: -1, bed: -1 }
|
||||
|
||||
type ChildBed = {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
type ChildInfoSelectorProps = {
|
||||
child: Child
|
||||
adults: number
|
||||
index: number
|
||||
roomIndex: number
|
||||
childrenInAdultsBed: number
|
||||
}
|
||||
|
||||
export default function ChildInfoSelector({
|
||||
child,
|
||||
childrenInAdultsBed,
|
||||
adults,
|
||||
index = 0,
|
||||
roomIndex = 0,
|
||||
}: ChildInfoSelectorProps) {
|
||||
const ageFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.age`
|
||||
const bedFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.bed`
|
||||
const intl = useIntl()
|
||||
const ageLabel = intl.formatMessage({
|
||||
defaultMessage: "Age",
|
||||
})
|
||||
const bedLabel = intl.formatMessage({
|
||||
defaultMessage: "Bed preference",
|
||||
})
|
||||
const errorMessage = intl.formatMessage({
|
||||
defaultMessage: "Child age is required",
|
||||
})
|
||||
const { setValue, formState } = useFormContext()
|
||||
|
||||
function updateSelectedBed(bed: number) {
|
||||
setValue(`rooms.${roomIndex}.childrenInRoom.${index}.bed`, bed)
|
||||
}
|
||||
|
||||
function updateSelectedAge(age: number) {
|
||||
setValue(`rooms.${roomIndex}.childrenInRoom.${index}.age`, age)
|
||||
const availableBedTypes = getAvailableBeds(age)
|
||||
updateSelectedBed(availableBedTypes[0].value)
|
||||
}
|
||||
|
||||
const allBedTypes: ChildBed[] = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "In adult's bed",
|
||||
}),
|
||||
value: ChildBedMapEnum.IN_ADULTS_BED,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "In crib",
|
||||
}),
|
||||
value: ChildBedMapEnum.IN_CRIB,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "In extra bed",
|
||||
}),
|
||||
value: ChildBedMapEnum.IN_EXTRA_BED,
|
||||
},
|
||||
]
|
||||
|
||||
function getAvailableBeds(age: number) {
|
||||
let availableBedTypes: ChildBed[] = []
|
||||
if (age <= 5 && (adults > childrenInAdultsBed || child.bed === 0)) {
|
||||
availableBedTypes.push(allBedTypes[0])
|
||||
}
|
||||
if (age < 3) {
|
||||
availableBedTypes.push(allBedTypes[1])
|
||||
}
|
||||
if (age > 2) {
|
||||
availableBedTypes.push(allBedTypes[2])
|
||||
}
|
||||
return availableBedTypes
|
||||
}
|
||||
|
||||
const roomErrors =
|
||||
//@ts-expect-error: formState is typed with FormValues
|
||||
formState.errors.rooms?.[roomIndex]?.childrenInRoom?.[index]
|
||||
|
||||
const ageError = roomErrors?.age
|
||||
const bedError = roomErrors?.bed
|
||||
|
||||
return (
|
||||
<>
|
||||
<div key={index} className={styles.childInfoContainer}>
|
||||
<div>
|
||||
<DeprecatedSelect
|
||||
required={true}
|
||||
items={ageList}
|
||||
label={ageLabel}
|
||||
aria-label={ageLabel}
|
||||
value={child.age ?? childDefaultValues.age}
|
||||
onSelect={(key) => {
|
||||
updateSelectedAge(key as number)
|
||||
}}
|
||||
maxHeight={180}
|
||||
name={ageFieldName}
|
||||
isNestedInModal={true}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{child.age >= 0 ? (
|
||||
<DeprecatedSelect
|
||||
items={getAvailableBeds(child.age)}
|
||||
label={bedLabel}
|
||||
aria-label={bedLabel}
|
||||
value={child.bed ?? childDefaultValues.bed}
|
||||
onSelect={(key) => {
|
||||
updateSelectedBed(key as number)
|
||||
}}
|
||||
name={bedFieldName}
|
||||
isNestedInModal={true}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{roomErrors && roomErrors.message ? (
|
||||
<Caption color="red" className={styles.error}>
|
||||
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
|
||||
{roomErrors.message}
|
||||
</Caption>
|
||||
) : null}
|
||||
|
||||
{ageError || bedError ? (
|
||||
<Caption color="red" className={styles.error}>
|
||||
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
|
||||
{errorMessage}
|
||||
</Caption>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captionBold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.childInfoContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: 1fr 2fr;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
|
||||
import Counter from "../Counter"
|
||||
import ChildInfoSelector from "./ChildInfoSelector"
|
||||
|
||||
import styles from "./child-selector.module.css"
|
||||
|
||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
|
||||
type ChildSelectorProps = {
|
||||
roomIndex: number
|
||||
currentAdults: number
|
||||
currentChildren: Child[]
|
||||
childrenInAdultsBed: number
|
||||
}
|
||||
export default function ChildSelector({
|
||||
roomIndex = 0,
|
||||
currentAdults,
|
||||
childrenInAdultsBed,
|
||||
currentChildren,
|
||||
}: ChildSelectorProps) {
|
||||
const intl = useIntl()
|
||||
const childrenLabel = intl.formatMessage({
|
||||
defaultMessage: "Children",
|
||||
})
|
||||
const { setValue } = useFormContext()
|
||||
|
||||
function increaseChildrenCount(roomIndex: number) {
|
||||
if (currentChildren.length < 5) {
|
||||
setValue(
|
||||
`rooms.${roomIndex}.childrenInRoom.${currentChildren.length}`,
|
||||
{
|
||||
age: undefined,
|
||||
bed: undefined,
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
function decreaseChildrenCount(roomIndex: number) {
|
||||
if (currentChildren.length > 0) {
|
||||
currentChildren.pop()
|
||||
setValue(`rooms.${roomIndex}.childrenInRoom`, currentChildren, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={styles.container}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{childrenLabel}
|
||||
</Caption>
|
||||
<Counter
|
||||
count={currentChildren.length}
|
||||
handleOnDecrease={() => {
|
||||
decreaseChildrenCount(roomIndex)
|
||||
}}
|
||||
handleOnIncrease={() => {
|
||||
increaseChildrenCount(roomIndex)
|
||||
}}
|
||||
disableDecrease={currentChildren.length == 0}
|
||||
disableIncrease={currentChildren.length == 5}
|
||||
/>
|
||||
</section>
|
||||
{currentChildren.map((child, index) => (
|
||||
<ChildInfoSelector
|
||||
roomIndex={roomIndex}
|
||||
index={index}
|
||||
child={child}
|
||||
adults={currentAdults}
|
||||
key={"child_" + index}
|
||||
childrenInAdultsBed={childrenInAdultsBed}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.counterContainer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.counterBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.counterBtn:not([disabled]) {
|
||||
box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
|
||||
import styles from "./counter.module.css"
|
||||
|
||||
type CounterProps = {
|
||||
count: number
|
||||
handleOnIncrease: () => void
|
||||
handleOnDecrease: () => void
|
||||
disableIncrease: boolean
|
||||
disableDecrease: boolean
|
||||
}
|
||||
|
||||
export default function Counter({
|
||||
count,
|
||||
handleOnIncrease,
|
||||
handleOnDecrease,
|
||||
disableIncrease,
|
||||
disableDecrease,
|
||||
}: CounterProps) {
|
||||
return (
|
||||
<div className={styles.counterContainer}>
|
||||
<Button
|
||||
className={styles.counterBtn}
|
||||
intent="inverted"
|
||||
onClick={handleOnDecrease}
|
||||
size="small"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
wrapping={true}
|
||||
disabled={disableDecrease}
|
||||
>
|
||||
<MaterialIcon icon="remove" color="CurrentColor" />
|
||||
</Button>
|
||||
<Body color="baseTextHighContrast" textAlign="center">
|
||||
{count}
|
||||
</Body>
|
||||
<Button
|
||||
className={styles.counterBtn}
|
||||
onClick={handleOnIncrease}
|
||||
intent="inverted"
|
||||
variant="icon"
|
||||
theme="base"
|
||||
wrapping={true}
|
||||
size="small"
|
||||
disabled={disableIncrease}
|
||||
>
|
||||
<MaterialIcon icon="add" color="CurrentColor" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import { Tooltip } from "@scandic-hotels/design-system/Tooltip"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||
|
||||
import { GuestsRoom } from "./GuestsRoom"
|
||||
|
||||
import styles from "./guests-rooms-picker.module.css"
|
||||
|
||||
import type { GuestsRoom as TGuestsRoom } from ".."
|
||||
import type { BookingWidgetSchema } from "../Client"
|
||||
|
||||
const MAX_ROOMS = 4
|
||||
|
||||
interface GuestsRoomsPickerDialogProps {
|
||||
rooms: TGuestsRoom[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function GuestsRoomsPickerDialog({
|
||||
rooms,
|
||||
onClose,
|
||||
}: GuestsRoomsPickerDialogProps) {
|
||||
const intl = useIntl()
|
||||
const { getFieldState, trigger, setValue, getValues } =
|
||||
useFormContext<BookingWidgetSchema>()
|
||||
const roomsValue = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
|
||||
const addRoomLabel = intl.formatMessage({
|
||||
defaultMessage: "Add room",
|
||||
})
|
||||
const doneLabel = intl.formatMessage({
|
||||
defaultMessage: "Done",
|
||||
})
|
||||
// Disable add room if booking code is either voucher or corporate cheque, or reward night is enabled
|
||||
const addRoomDisabledTextForSpecialRate = getValues(SEARCH_TYPE_REDEMPTION)
|
||||
? intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Multi-room booking is not available with reward night.",
|
||||
})
|
||||
: getValues("bookingCode.value")?.toLowerCase().startsWith("vo") &&
|
||||
intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Multi-room booking is not available with this booking code.",
|
||||
})
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
const isValid = await trigger("rooms")
|
||||
if (isValid) onClose()
|
||||
}, [trigger, onClose])
|
||||
|
||||
const handleAddRoom = useCallback(() => {
|
||||
setValue("rooms", [...roomsValue, { adults: 1, childrenInRoom: [] }], {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}, [roomsValue, setValue])
|
||||
|
||||
const handleRemoveRoom = useCallback(
|
||||
(index: number) => {
|
||||
const updatedRooms = roomsValue.filter((_, i) => i !== index)
|
||||
|
||||
setValue("rooms", updatedRooms, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
|
||||
if (updatedRooms.length === 1) {
|
||||
trigger("bookingCode.value")
|
||||
trigger(SEARCH_TYPE_REDEMPTION)
|
||||
}
|
||||
},
|
||||
[roomsValue, trigger, setValue]
|
||||
)
|
||||
|
||||
// Validate rooms when they change
|
||||
useEffect(() => {
|
||||
const fieldState = getFieldState("rooms")
|
||||
if (fieldState.invalid) trigger("rooms")
|
||||
}, [roomsValue, getFieldState, trigger])
|
||||
|
||||
const isInvalid =
|
||||
getFieldState("rooms").invalid ||
|
||||
roomsValue.some((room) =>
|
||||
room.childrenInRoom.some((child) => child.age === undefined)
|
||||
)
|
||||
const canAddRooms = rooms.length < MAX_ROOMS
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={styles.contentWrapper}>
|
||||
<header className={styles.header}>
|
||||
<button type="button" className={styles.close} onClick={onClose}>
|
||||
<MaterialIcon icon="close" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className={styles.contentContainer}>
|
||||
{rooms.map((room, index) => (
|
||||
<GuestsRoom
|
||||
key={index}
|
||||
room={room}
|
||||
index={index}
|
||||
onRemove={handleRemoveRoom}
|
||||
/>
|
||||
))}
|
||||
|
||||
{addRoomDisabledTextForSpecialRate ? (
|
||||
<div className={styles.addRoomMobileContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
theme="base"
|
||||
fullWidth
|
||||
onPress={handleAddRoom}
|
||||
disabled
|
||||
>
|
||||
<MaterialIcon icon="add" color="CurrentColor" />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
<div className={styles.errorContainer}>
|
||||
<Typography
|
||||
className={styles.error}
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon
|
||||
icon="error"
|
||||
size={20}
|
||||
color="Icon/Feedback/Error"
|
||||
isFilled
|
||||
/>
|
||||
{addRoomDisabledTextForSpecialRate}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
canAddRooms && (
|
||||
<div className={styles.addRoomMobileContainer}>
|
||||
<Button
|
||||
className={styles.addRoomBtn}
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
theme="base"
|
||||
fullWidth
|
||||
onPress={handleAddRoom}
|
||||
>
|
||||
<MaterialIcon icon="add" color="CurrentColor" />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<footer className={styles.footer}>
|
||||
{addRoomDisabledTextForSpecialRate ? (
|
||||
<div className={styles.hideOnMobile}>
|
||||
<Tooltip
|
||||
text={addRoomDisabledTextForSpecialRate}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
theme="base"
|
||||
disabled
|
||||
onPress={handleAddRoom}
|
||||
>
|
||||
<MaterialIcon icon="add_circle" color="CurrentColor" />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
canAddRooms && (
|
||||
<div className={styles.hideOnMobile}>
|
||||
<Button
|
||||
className={styles.addRoomBtn}
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
theme="base"
|
||||
onPress={handleAddRoom}
|
||||
>
|
||||
<MaterialIcon icon="add_circle" color="CurrentColor" />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
onPress={handleClose}
|
||||
disabled={isInvalid}
|
||||
className={styles.hideOnDesktop}
|
||||
intent="tertiary"
|
||||
theme="base"
|
||||
size="large"
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={handleClose}
|
||||
disabled={isInvalid}
|
||||
className={styles.hideOnMobile}
|
||||
intent="tertiary"
|
||||
theme="base"
|
||||
size="small"
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
|
||||
import AdultSelector from "../AdultSelector"
|
||||
import ChildSelector from "../ChildSelector"
|
||||
|
||||
import styles from "../guests-rooms-picker.module.css"
|
||||
|
||||
import type { GuestsRoom } from "../.."
|
||||
|
||||
export function GuestsRoom({
|
||||
room,
|
||||
index,
|
||||
onRemove,
|
||||
}: {
|
||||
room: GuestsRoom
|
||||
index: number
|
||||
onRemove: (index: number) => void
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const roomLabel = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: index + 1,
|
||||
}
|
||||
)
|
||||
|
||||
const childrenInAdultsBed = room.childrenInRoom.filter(
|
||||
(child) => child.bed === ChildBedMapEnum.IN_ADULTS_BED
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className={styles.roomContainer}>
|
||||
<section className={styles.roomDetailsContainer}>
|
||||
<Subtitle type="two" className={styles.roomHeading}>
|
||||
{roomLabel}
|
||||
</Subtitle>
|
||||
<AdultSelector roomIndex={index} currentAdults={room.adults} />
|
||||
<ChildSelector
|
||||
roomIndex={index}
|
||||
currentAdults={room.adults}
|
||||
currentChildren={room.childrenInRoom}
|
||||
childrenInAdultsBed={childrenInAdultsBed}
|
||||
/>
|
||||
{index !== 0 && (
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
theme="secondaryLight"
|
||||
onPress={() => onRemove(index)}
|
||||
size="small"
|
||||
className={styles.roomActionsButton}
|
||||
>
|
||||
<MaterialIcon icon="delete" color="CurrentColor" />
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Remove room",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
.triggerDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
display: flex;
|
||||
padding: var(--Space-x2);
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
color: var(--UI-Text-Error);
|
||||
}
|
||||
|
||||
.pickerContainerMobile {
|
||||
--header-height: 72px;
|
||||
--sticky-button-height: 140px;
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 20px;
|
||||
transition: top 300ms ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"content";
|
||||
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
|
||||
}
|
||||
|
||||
.pickerContainerDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.roomContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.roomDetailsContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.roomHeading {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--Text-Default);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 20px var(--Spacing-x-one-and-half) 0;
|
||||
}
|
||||
|
||||
.guestsAndRooms {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.roomContainer {
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.roomContainer:last-of-type {
|
||||
padding-bottom: calc(var(--sticky-button-height) + 20px);
|
||||
}
|
||||
|
||||
.roomActionsButton {
|
||||
margin-left: auto;
|
||||
color: var(--Base-Text-Accent);
|
||||
}
|
||||
|
||||
.footer button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contentWrapper
|
||||
.addRoomMobileContainer
|
||||
.addRoomBtn:is(:focus, :focus-visible, :focus-within),
|
||||
.footer .hideOnMobile .addRoomBtn:is(:focus, :focus-visible, :focus-within),
|
||||
.roomActionsButton:is(:focus, :focus-visible, :focus-within) {
|
||||
outline: -webkit-focus-ring-color auto 1px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.contentContainer {
|
||||
grid-area: content;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-area: header;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-self: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 7.5%,
|
||||
#ffffff 82.5%
|
||||
);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x7);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer .hideOnMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.addRoomMobileContainer {
|
||||
display: grid;
|
||||
padding-bottom: calc(var(--sticky-button-height) + 20px);
|
||||
}
|
||||
|
||||
.addRoomMobileContainer button {
|
||||
width: 150px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.addRoomMobileContainer .addRoomMobileDisabledText {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
background-color: var(--Background-Primary);
|
||||
margin: 0 var(--Spacing-x2);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.pickerContainerMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.roomContainer {
|
||||
padding: var(--Spacing-x2) 0 0 0;
|
||||
}
|
||||
|
||||
.roomContainer:first-of-type {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.roomContainer:last-of-type {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.triggerMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.triggerDesktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.triggerDesktop > span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pickerContainerDesktop {
|
||||
--header-height: 72px;
|
||||
--sticky-button-height: 140px;
|
||||
|
||||
background-color: var(--Main-Grey-White);
|
||||
display: grid;
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
box-shadow: var(--popup-box-shadow);
|
||||
max-width: calc(100vw - 20px);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.pickerContainerDesktop:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
grid-template-columns: auto auto;
|
||||
padding-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.footer button {
|
||||
margin-left: auto;
|
||||
width: 125px;
|
||||
}
|
||||
|
||||
.footer .hideOnDesktop,
|
||||
.addRoomMobileContainer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useId, useState } from "react"
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
Popover,
|
||||
} from "react-aria-components"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import PickerForm from "./Form"
|
||||
|
||||
import styles from "./guests-rooms-picker.module.css"
|
||||
|
||||
import type { GuestsRoom } from ".."
|
||||
import type { BookingWidgetSchema } from "../Client"
|
||||
|
||||
export default function GuestsRoomsPickerForm({
|
||||
ariaLabelledBy,
|
||||
}: {
|
||||
ariaLabelledBy?: string
|
||||
}) {
|
||||
const { trigger } = useFormContext<BookingWidgetSchema>()
|
||||
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
|
||||
|
||||
const popoverId = useId()
|
||||
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
|
||||
const [isDesktop, setIsDesktop] = useState(true)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
const childCount = rooms[0]?.childrenInRoom.length ?? 0 // ToDo Update for multiroom later
|
||||
|
||||
//isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed".
|
||||
async function setOverflowClip(isOpen: boolean) {
|
||||
const bodyElement = document.body
|
||||
if (bodyElement) {
|
||||
if (isOpen) {
|
||||
bodyElement.style.overflow = "visible"
|
||||
} else {
|
||||
// !important needed to override 'overflow: hidden' set by react-aria.
|
||||
// 'overflow: hidden' does not work in combination with other sticky positioned elements, which clip does.
|
||||
bodyElement.style.overflow = "clip !important"
|
||||
}
|
||||
}
|
||||
if (!isOpen) {
|
||||
const state = await trigger("rooms")
|
||||
if (state) {
|
||||
setIsOpen(isOpen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsDesktop(checkIsDesktop)
|
||||
}, [checkIsDesktop])
|
||||
|
||||
const updateHeight = useCallback(() => {
|
||||
// Get available space for picker to show without going beyond screen
|
||||
const bookingWidget = document.getElementById("booking-widget")
|
||||
let maxHeight =
|
||||
window.innerHeight -
|
||||
(bookingWidget?.getBoundingClientRect().bottom ?? 0) -
|
||||
50
|
||||
const innerContainerHeight = document
|
||||
.getElementsByClassName(popoverId)[0]
|
||||
?.getBoundingClientRect().height
|
||||
if (
|
||||
maxHeight != containerHeight &&
|
||||
innerContainerHeight &&
|
||||
maxHeight <= innerContainerHeight
|
||||
) {
|
||||
setContainerHeight(maxHeight)
|
||||
} else if (
|
||||
containerHeight &&
|
||||
innerContainerHeight &&
|
||||
maxHeight > innerContainerHeight
|
||||
) {
|
||||
setContainerHeight(0)
|
||||
}
|
||||
}, [containerHeight, popoverId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop && rooms.length > 0) {
|
||||
updateHeight()
|
||||
}
|
||||
}, [childCount, isDesktop, updateHeight, rooms])
|
||||
|
||||
return isDesktop ? (
|
||||
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
|
||||
<Trigger
|
||||
rooms={rooms}
|
||||
className={styles.triggerDesktop}
|
||||
triggerFn={() => {
|
||||
setIsOpen(true)
|
||||
}}
|
||||
/>
|
||||
<Popover
|
||||
className={popoverId}
|
||||
placement="bottom start"
|
||||
offset={36}
|
||||
style={containerHeight ? { overflow: "auto" } : undefined}
|
||||
>
|
||||
<Dialog className={styles.pickerContainerDesktop}>
|
||||
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
|
||||
<Trigger
|
||||
rooms={rooms}
|
||||
className={styles.triggerMobile}
|
||||
triggerFn={() => {
|
||||
setIsOpen(true)
|
||||
}}
|
||||
ariaLabelledBy={ariaLabelledBy}
|
||||
/>
|
||||
<Modal>
|
||||
<Dialog className={styles.pickerContainerMobile}>
|
||||
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function Trigger({
|
||||
rooms,
|
||||
className,
|
||||
triggerFn,
|
||||
ariaLabelledBy,
|
||||
}: {
|
||||
rooms: GuestsRoom[]
|
||||
className: string
|
||||
triggerFn?: () => void
|
||||
ariaLabelledBy?: string
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
const parts = [
|
||||
intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
|
||||
},
|
||||
{ totalRooms: rooms.length }
|
||||
),
|
||||
intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{ totalAdults: rooms.reduce((acc, room) => acc + room.adults, 0) }
|
||||
),
|
||||
]
|
||||
|
||||
if (rooms.some((room) => room.childrenInRoom.length > 0)) {
|
||||
parts.push(
|
||||
intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{
|
||||
totalChildren: rooms.reduce(
|
||||
(acc, room) => acc + room.childrenInRoom.length,
|
||||
0
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`${className} ${styles.btn}`}
|
||||
type="button"
|
||||
onPress={triggerFn}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span className={styles.guestsAndRooms}>{parts.join(", ")}</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user