Merged master into feat/SW-888-skeleton-loaders

This commit is contained in:
Linus Flood
2024-11-14 19:14:31 +00:00
39 changed files with 745 additions and 780 deletions

View File

@@ -18,7 +18,7 @@ import { setLang } from "@/i18n/serverContext"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { LangParams, PageArgs } from "@/types/params"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectRatePage({
params,

View File

@@ -114,6 +114,8 @@
/* Z-INDEX */
--header-z-index: 11;
--menu-overlay-z-index: 11;
--booking-widget-z-index: 10;
--booking-widget-open-z-index: 100;
--dialog-z-index: 9;
--sidepeek-z-index: 100;
--lightbox-z-index: 150;

View File

@@ -42,11 +42,11 @@ export default function BookingWidgetClient({
const bookingWidgetSearchData: BookingWidgetSearchParams | undefined =
searchParams
? (getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
? getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
adults: "number",
age: "number",
bed: "number",
}) as BookingWidgetSearchParams)
})
: undefined
const getLocationObj = (destination: string): Location | undefined => {
@@ -79,6 +79,16 @@ export default function BookingWidgetClient({
)
: undefined
const defaultRoomsData = bookingWidgetSearchData?.room?.map((room) => ({
adults: room.adults,
child: room.child ?? [],
})) ?? [
{
adults: 1,
child: [],
},
]
const methods = useForm<BookingWidgetSchema>({
defaultValues: {
search: selectedLocation?.name ?? "",
@@ -96,12 +106,7 @@ export default function BookingWidgetClient({
bookingCode: "",
redemption: false,
voucher: false,
rooms: bookingWidgetSearchData?.room ?? [
{
adults: 1,
child: [],
},
],
rooms: defaultRoomsData,
},
shouldFocusError: false,
mode: "all",
@@ -158,21 +163,24 @@ export default function BookingWidgetClient({
return (
<FormProvider {...methods}>
<section ref={bookingWidgetRef} className={styles.containerDesktop}>
<Form locations={locations} type={type} />
</section>
<section className={styles.containerMobile} data-open={isOpen}>
<button
className={styles.close}
onClick={closeMobileSearch}
type="button"
>
<CloseLargeIcon />
</button>
<Form locations={locations} type={type} />
<section
ref={bookingWidgetRef}
className={styles.wrapper}
data-open={isOpen}
>
<MobileToggleButton openMobileSearch={openMobileSearch} />
<div className={styles.formContainer}>
<button
className={styles.close}
onClick={closeMobileSearch}
type="button"
>
<CloseLargeIcon />
</button>
<Form locations={locations} type={type} />
</div>
</section>
<div className={styles.backdrop} onClick={closeMobileSearch} />
<MobileToggleButton openMobileSearch={openMobileSearch} />
</FormProvider>
)
}

View File

@@ -6,8 +6,6 @@
display: grid;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x2);
position: sticky;
top: 0;
z-index: 1;
background-color: var(--Base-Surface-Primary-light-Normal);
}

View File

@@ -31,12 +31,6 @@ export default function MobileToggleButton({
const location = useWatch({ name: "location" })
const rooms: BookingWidgetSchema["rooms"] = useWatch({ name: "rooms" })
const bookingWidgetMobileRef = useRef(null)
useStickyPosition({
ref: bookingWidgetMobileRef,
name: StickyElementNameEnum.BOOKING_WIDGET_MOBILE,
})
const parsedLocation: Location | null = location
? JSON.parse(decodeURIComponent(location))
: null
@@ -67,7 +61,6 @@ export default function MobileToggleButton({
className={locationAndDateIsSet ? styles.complete : styles.partial}
onClick={openMobileSearch}
role="button"
ref={bookingWidgetMobileRef}
>
{!locationAndDateIsSet && (
<>

View File

@@ -1,60 +1,63 @@
.containerDesktop,
.containerMobile,
.close {
display: none;
.wrapper {
position: sticky;
z-index: var(--booking-widget-z-index);
}
@media screen and (max-width: 767px) {
.containerMobile {
background-color: var(--UI-Input-Controls-Surface-Normal);
bottom: -100%;
display: grid;
gap: var(--Spacing-x3);
grid-template-rows: 36px 1fr;
height: calc(100dvh - 20px);
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
position: fixed;
transition: bottom 300ms ease;
width: 100%;
z-index: 10000;
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
}
.formContainer {
display: grid;
grid-template-rows: auto 1fr;
background-color: var(--UI-Input-Controls-Surface-Normal);
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
gap: var(--Spacing-x3);
height: calc(100dvh - 20px);
width: 100%;
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
position: fixed;
bottom: -100%;
transition: bottom 300ms ease;
}
.containerMobile[data-open="true"] {
bottom: 0;
}
.wrapper[data-open="true"] {
z-index: var(--booking-widget-open-z-index);
}
.close {
background: none;
border: none;
cursor: pointer;
justify-self: flex-end;
}
.wrapper[data-open="true"] .formContainer {
bottom: 0;
}
.containerMobile[data-open="true"] + .backdrop {
background-color: rgba(0, 0, 0, 0.4);
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
z-index: 1000;
}
.close {
background: none;
border: none;
cursor: pointer;
justify-self: flex-end;
padding: 0;
}
.wrapper[data-open="true"] + .backdrop {
background-color: rgba(0, 0, 0, 0.4);
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
z-index: calc(var(--booking-widget-open-z-index) - 1);
}
@media screen and (min-width: 768px) {
.containerDesktop {
display: block;
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
position: sticky;
.wrapper {
top: 0;
z-index: 10;
background-color: var(--Base-Surface-Primary-light-Normal);
}
}
@media screen and (min-width: 1367px) {
.container {
z-index: 9;
.formContainer {
display: block;
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
height: auto;
position: static;
padding: 0;
}
.close {
display: none;
}
}

View File

@@ -1,5 +1,5 @@
.container {
--header-height: 68px;
--header-height: 72px;
--sticky-button-height: 120px;
display: grid;
@@ -11,12 +11,10 @@
}
.header {
align-self: flex-start;
align-self: flex-end;
background-color: var(--Main-Grey-White);
display: grid;
grid-area: header;
grid-template-columns: 1fr 24px;
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x2);
padding: var(--Spacing-x3) var(--Spacing-x2);
position: sticky;
top: 0;
z-index: 10;

View File

@@ -38,7 +38,7 @@
.hideWrapper {
bottom: 0;
left: 0;
overflow: auto;
overflow: hidden;
position: fixed;
right: 0;
top: 100%;

View File

@@ -6,7 +6,6 @@ import { dt } from "@/lib/dt"
import DatePicker from "@/components/DatePicker"
import GuestsRoomsPickerForm from "@/components/GuestsRoomsPicker"
import GuestsRoomsProvider from "@/components/GuestsRoomsPicker/Provider/GuestsRoomsProvider"
import { SearchIcon } from "@/components/Icons"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Button from "@/components/TempDesignSystem/Button"
@@ -26,12 +25,10 @@ export default function FormContent({
const intl = useIntl()
const selectedDate = useWatch({ name: "date" })
const rooms = intl.formatMessage({ id: "Guests & Rooms" })
const roomsLabel = intl.formatMessage({ id: "Guests & Rooms" })
const nights = dt(selectedDate.toDate).diff(dt(selectedDate.fromDate), "days")
const selectedGuests = useWatch({ name: "rooms" })
return (
<>
<div className={styles.input}>
@@ -51,12 +48,10 @@ export default function FormContent({
<div className={styles.rooms}>
<label>
<Caption color="red" type="bold" asChild>
<span>{rooms}</span>
<span>{roomsLabel}</span>
</Caption>
</label>
<GuestsRoomsProvider selectedGuests={selectedGuests}>
<GuestsRoomsPickerForm name="rooms" />
</GuestsRoomsProvider>
<GuestsRoomsPickerForm />
</div>
</div>
<div className={styles.voucherContainer}>

View File

@@ -1,16 +1,37 @@
import { z } from "zod"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { Location } from "@/types/trpc/routers/hotel/locations"
export const guestRoomSchema = z.object({
adults: z.number().default(1),
child: z.array(
z.object({
age: z.number().nonnegative(),
bed: z.number(),
})
),
})
export const guestRoomSchema = z
.object({
adults: z.number().default(1),
child: z
.array(
z.object({
age: z.number().min(0, "Age is required"),
bed: z.number().min(0, "Bed choice is required"),
})
)
.default([]),
})
.superRefine((value, ctx) => {
const childrenInAdultsBed = value.child.filter(
(c) => c.bed === ChildBedMapEnum.IN_ADULTS_BED
)
if (value.adults < childrenInAdultsBed.length) {
const lastAdultBedIndex = value.child
.map((c) => c.bed)
.lastIndexOf(ChildBedMapEnum.IN_ADULTS_BED)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"You cannot have more children in adults bed than adults in the room",
path: ["child", lastAdultBedIndex],
})
}
})
export const guestRoomsSchema = z.array(guestRoomSchema)

View File

@@ -3,54 +3,32 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Counter from "../Counter"
import styles from "./adult-selector.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import {
AdultSelectorProps,
Child,
} from "@/types/components/bookingWidget/guestsRoomsPicker"
import { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
export default function AdultSelector({
roomIndex = 0,
currentAdults,
}: SelectorProps) {
const name = `rooms.${roomIndex}.adults`
const intl = useIntl()
const adultsLabel = intl.formatMessage({ id: "Adults" })
const { setValue } = useFormContext()
const { adults, child, childrenInAdultsBed } = useGuestsRoomsStore(
(state) => state.rooms[roomIndex]
)
const increaseAdults = useGuestsRoomsStore((state) => state.increaseAdults)
const decreaseAdults = useGuestsRoomsStore((state) => state.decreaseAdults)
function increaseAdultsCount(roomIndex: number) {
if (adults < 6) {
increaseAdults(roomIndex)
setValue(`rooms.${roomIndex}.adults`, adults + 1)
function increaseAdultsCount() {
if (currentAdults < 6) {
setValue(name, currentAdults + 1)
}
}
function decreaseAdultsCount(roomIndex: number) {
if (adults > 1) {
decreaseAdults(roomIndex)
setValue(`rooms.${roomIndex}.adults`, adults - 1)
if (childrenInAdultsBed > adults) {
const toUpdateIndex = child.findIndex(
(child: Child) => child.bed == ChildBedMapEnum.IN_ADULTS_BED
)
if (toUpdateIndex != -1) {
setValue(
`rooms.${roomIndex}.children.${toUpdateIndex}.bed`,
child[toUpdateIndex].age < 3
? ChildBedMapEnum.IN_CRIB
: ChildBedMapEnum.IN_EXTRA_BED
)
}
}
function decreaseAdultsCount() {
if (currentAdults > 1) {
setValue(name, currentAdults - 1)
}
}
@@ -60,15 +38,11 @@ export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
{adultsLabel}
</Caption>
<Counter
count={adults}
handleOnDecrease={() => {
decreaseAdultsCount(roomIndex)
}}
handleOnIncrease={() => {
increaseAdultsCount(roomIndex)
}}
disableDecrease={adults == 1}
disableIncrease={adults == 6}
count={currentAdults}
handleOnDecrease={decreaseAdultsCount}
handleOnIncrease={increaseAdultsCount}
disableDecrease={currentAdults == 1}
disableIncrease={currentAdults == 6}
/>
</section>
)

View File

@@ -3,8 +3,6 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import { ErrorCircleIcon } from "@/components/Icons"
import Select from "@/components/TempDesignSystem/Select"
import Caption from "@/components/TempDesignSystem/Text/Caption"
@@ -17,55 +15,35 @@ import {
ChildInfoSelectorProps,
} from "@/types/components/bookingWidget/guestsRoomsPicker"
const ageList = [...Array(13)].map((_, i) => ({
label: i.toString(),
value: i,
}))
export default function ChildInfoSelector({
child = { age: -1, bed: -1 },
childrenInAdultsBed,
adults,
index = 0,
roomIndex = 0,
}: ChildInfoSelectorProps) {
const ageFieldName = `rooms.${roomIndex}.child.${index}.age`
const bedFieldName = `rooms.${roomIndex}.child.${index}.bed`
const intl = useIntl()
const ageLabel = intl.formatMessage({ id: "Age" })
const ageReqdErrMsg = intl.formatMessage({ id: "Child age is required" })
const bedLabel = intl.formatMessage({ id: "Bed" })
const { setValue } = useFormContext()
const { adults, childrenInAdultsBed } = useGuestsRoomsStore(
(state) => state.rooms[roomIndex]
)
const {
isValidated,
updateChildAge,
updateChildBed,
increaseChildInAdultsBed,
decreaseChildInAdultsBed,
} = useGuestsRoomsStore((state) => ({
isValidated: state.isValidated,
updateChildAge: state.updateChildAge,
updateChildBed: state.updateChildBed,
increaseChildInAdultsBed: state.increaseChildInAdultsBed,
decreaseChildInAdultsBed: state.decreaseChildInAdultsBed,
}))
const ageList = Array.from(Array(13).keys()).map((age) => ({
label: `${age}`,
value: age,
}))
function updateSelectedAge(age: number) {
updateChildAge(age, roomIndex, index)
setValue(`rooms.${roomIndex}.child.${index}.age`, age, {
shouldValidate: true,
})
const availableBedTypes = getAvailableBeds(age)
updateSelectedBed(availableBedTypes[0].value)
}
const errorMessage = intl.formatMessage({ id: "Child age is required" })
const { setValue, formState, register, trigger } = useFormContext()
function updateSelectedBed(bed: number) {
if (bed == ChildBedMapEnum.IN_ADULTS_BED) {
increaseChildInAdultsBed(roomIndex)
} else if (child.bed == ChildBedMapEnum.IN_ADULTS_BED) {
decreaseChildInAdultsBed(roomIndex)
}
updateChildBed(bed, roomIndex, index)
setValue(`rooms.${roomIndex}.child.${index}.bed`, bed)
trigger()
}
function updateSelectedAge(age: number) {
setValue(`rooms.${roomIndex}.child.${index}.age`, age)
const availableBedTypes = getAvailableBeds(age)
updateSelectedBed(availableBedTypes[0].value)
}
const allBedTypes: ChildBed[] = [
@@ -97,6 +75,12 @@ export default function ChildInfoSelector({
return availableBedTypes
}
//@ts-expect-error: formState is typed with FormValues
const roomErrors = formState.errors.rooms?.[roomIndex]?.child?.[index]
const ageError = roomErrors?.age
const bedError = roomErrors?.bed
return (
<>
<div key={index} className={styles.childInfoContainer}>
@@ -110,13 +94,15 @@ export default function ChildInfoSelector({
onSelect={(key) => {
updateSelectedAge(key as number)
}}
name={`rooms.${roomIndex}.child.${index}.age`}
placeholder={ageLabel}
maxHeight={150}
{...register(ageFieldName, {
required: true,
})}
/>
</div>
<div>
{child.age !== -1 ? (
{child.age >= 0 ? (
<Select
items={getAvailableBeds(child.age)}
label={bedLabel}
@@ -125,16 +111,26 @@ export default function ChildInfoSelector({
onSelect={(key) => {
updateSelectedBed(key as number)
}}
name={`rooms.${roomIndex}.child.${index}.age`}
placeholder={bedLabel}
{...register(bedFieldName, {
required: true,
})}
/>
) : null}
</div>
</div>
{isValidated && child.age < 0 ? (
{roomErrors && roomErrors.message ? (
<Caption color="red" className={styles.error}>
<ErrorCircleIcon color="red" />
{ageReqdErrMsg}
{roomErrors.message}
</Caption>
) : null}
{ageError || bedError ? (
<Caption color="red" className={styles.error}>
<ErrorCircleIcon color="red" />
{errorMessage}
</Caption>
) : null}
</>

View File

@@ -3,8 +3,6 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Counter from "../Counter"
@@ -12,40 +10,30 @@ import ChildInfoSelector from "./ChildInfoSelector"
import styles from "./child-selector.module.css"
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
import { ChildSelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
import { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
export default function ChildSelector({
roomIndex = 0,
currentAdults,
childrenInAdultsBed,
currentChildren,
}: SelectorProps) {
const intl = useIntl()
const childrenLabel = intl.formatMessage({ id: "Children" })
const { setValue, trigger } = useFormContext<BookingWidgetSchema>()
const children = useGuestsRoomsStore((state) => state.rooms[roomIndex].child)
const increaseChildren = useGuestsRoomsStore(
(state) => state.increaseChildren
)
const decreaseChildren = useGuestsRoomsStore(
(state) => state.decreaseChildren
)
const { setValue } = useFormContext()
function increaseChildrenCount(roomIndex: number) {
if (children.length < 5) {
increaseChildren(roomIndex)
setValue(
`rooms.${roomIndex}.child.${children.length}`,
{
age: -1,
bed: -1,
},
{ shouldValidate: true }
)
if (currentChildren.length < 5) {
setValue(`rooms.${roomIndex}.child.${currentChildren.length}`, {
age: undefined,
bed: undefined,
})
}
}
function decreaseChildrenCount(roomIndex: number) {
if (children.length > 0) {
const newChildrenList = decreaseChildren(roomIndex)
setValue(`rooms.${roomIndex}.child`, newChildrenList, {
shouldValidate: true,
})
if (currentChildren.length > 0) {
currentChildren.pop()
setValue(`rooms.${roomIndex}.child`, currentChildren)
}
}
@@ -56,23 +44,25 @@ export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
{childrenLabel}
</Caption>
<Counter
count={children.length}
count={currentChildren.length}
handleOnDecrease={() => {
decreaseChildrenCount(roomIndex)
}}
handleOnIncrease={() => {
increaseChildrenCount(roomIndex)
}}
disableDecrease={children.length == 0}
disableIncrease={children.length == 5}
disableDecrease={currentChildren.length == 0}
disableIncrease={currentChildren.length == 5}
/>
</section>
{children.map((child, index) => (
{currentChildren.map((child, index) => (
<ChildInfoSelector
roomIndex={roomIndex}
index={index}
child={child}
adults={currentAdults}
key={"child_" + index}
childrenInAdultsBed={childrenInAdultsBed}
/>
))}
</>

View File

@@ -0,0 +1,167 @@
"use client"
import { useEffect } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { CloseLargeIcon, PlusCircleIcon, PlusIcon } from "../Icons"
import Button from "../TempDesignSystem/Button"
import Divider from "../TempDesignSystem/Divider"
import Subtitle from "../TempDesignSystem/Text/Subtitle"
import { Tooltip } from "../TempDesignSystem/Tooltip"
import AdultSelector from "./AdultSelector"
import ChildSelector from "./ChildSelector"
import styles from "./guests-rooms-picker.module.css"
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function GuestsRoomsPickerDialog({
rooms,
onClose,
}: {
rooms: GuestsRoom[]
onClose: () => void
}) {
const intl = useIntl()
const doneLabel = intl.formatMessage({ id: "Done" })
const roomLabel = intl.formatMessage({ id: "Room" })
const disabledBookingOptionsHeader = intl.formatMessage({
id: "Disabled booking options header",
})
const disabledBookingOptionsText = intl.formatMessage({
id: "Disabled adding room",
})
const addRoomLabel = intl.formatMessage({ id: "Add Room" })
const { getFieldState, trigger } = useFormContext<BookingWidgetSchema>()
const roomsValue = useWatch({ name: "rooms" })
async function handleOnClose() {
const state = await trigger("rooms")
if (state) {
onClose()
}
}
const fieldState = getFieldState("rooms")
useEffect(() => {
if (fieldState.invalid) {
trigger("rooms")
}
}, [roomsValue, fieldState.invalid, trigger])
return (
<>
<section className={styles.contentWrapper}>
<header className={styles.header}>
<button type="button" className={styles.close} onClick={onClose}>
<CloseLargeIcon />
</button>
</header>
<div className={styles.contentContainer}>
{rooms.map((room, index) => {
const currentAdults = room.adults
const currentChildren = room.child
const childrenInAdultsBed =
currentChildren.filter(
(child) => child.bed === ChildBedMapEnum.IN_ADULTS_BED
).length ?? 0
return (
<div className={styles.roomContainer} key={index}>
<section className={styles.roomDetailsContainer}>
<Subtitle type="two" className={styles.roomHeading}>
{roomLabel} {index + 1}
</Subtitle>
<AdultSelector
roomIndex={index}
currentAdults={currentAdults}
currentChildren={currentChildren}
childrenInAdultsBed={childrenInAdultsBed}
/>
<ChildSelector
roomIndex={index}
currentAdults={currentAdults}
currentChildren={currentChildren}
childrenInAdultsBed={childrenInAdultsBed}
/>
</section>
<Divider color="primaryLightSubtle" />
</div>
)
})}
<div className={styles.addRoomMobileContainer}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="top"
arrow="left"
>
{rooms.length < 4 ? (
<Button
intent="text"
variant="icon"
wrapping
disabled
theme="base"
fullWidth
>
<PlusIcon />
{addRoomLabel}
</Button>
) : null}
</Tooltip>
</div>
</div>
</section>
<footer className={styles.footer}>
<div className={styles.hideOnMobile}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="top"
arrow="left"
>
{rooms.length < 4 ? (
<Button
intent="text"
variant="icon"
wrapping
disabled
theme="base"
>
<PlusCircleIcon />
{addRoomLabel}
</Button>
) : null}
</Tooltip>
</div>
<Button
onPress={handleOnClose}
disabled={getFieldState("rooms").invalid}
className={styles.hideOnMobile}
intent="tertiary"
theme="base"
size="small"
>
{doneLabel}
</Button>
<Button
onPress={handleOnClose}
disabled={getFieldState("rooms").invalid}
className={styles.hideOnDesktop}
intent="tertiary"
theme="base"
size="large"
>
{doneLabel}
</Button>
</footer>
</>
)
}

View File

@@ -1,136 +0,0 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import { CloseLargeIcon, PlusCircleIcon, PlusIcon } from "../Icons"
import Button from "../TempDesignSystem/Button"
import Divider from "../TempDesignSystem/Divider"
import Subtitle from "../TempDesignSystem/Text/Subtitle"
import { Tooltip } from "../TempDesignSystem/Tooltip"
import AdultSelector from "./AdultSelector"
import ChildSelector from "./ChildSelector"
import styles from "./guests-rooms-picker.module.css"
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
import { GuestsRoomsPickerProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function GuestsRoomsPicker({
closePicker,
}: GuestsRoomsPickerProps) {
const intl = useIntl()
const doneLabel = intl.formatMessage({ id: "Done" })
const roomLabel = intl.formatMessage({ id: "Room" })
const disabledBookingOptionsHeader = intl.formatMessage({
id: "Disabled booking options header",
})
const disabledBookingOptionsText = intl.formatMessage({
id: "Disabled adding room",
})
const addRoomLabel = intl.formatMessage({ id: "Add Room" })
const { getFieldState } = useFormContext<BookingWidgetSchema>()
const rooms = useGuestsRoomsStore((state) => state.rooms)
// Not in MVP
// const increaseRoom = useGuestsRoomsStore.use.increaseRoom()
// const decreaseRoom = useGuestsRoomsStore.use.decreaseRoom()
return (
<div className={styles.pickerContainer}>
<header className={styles.header}>
<button type="button" className={styles.close} onClick={closePicker}>
<CloseLargeIcon />
</button>
</header>
<div className={styles.contentContainer}>
{rooms.map((room, index) => (
<div className={styles.roomContainer} key={index}>
<section className={styles.roomDetailsContainer}>
<Subtitle type="two" className={styles.roomHeading}>
{roomLabel} {index + 1}
</Subtitle>
<AdultSelector roomIndex={index} />
<ChildSelector roomIndex={index} />
</section>
{/* Not in MVP
{index > 0 ? (
<Button intent="text" onClick={() => decreaseRoom(index)}>
Remove Room
</Button>
) : null} */}
<Divider color="primaryLightSubtle" />
</div>
))}
<div className={styles.addRoomMobileContainer}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="top"
arrow="left"
>
{rooms.length < 4 ? (
<Button
intent="text"
variant="icon"
wrapping
disabled
theme="base"
fullWidth
>
<PlusIcon />
{addRoomLabel}
</Button>
) : null}
</Tooltip>
</div>
</div>
<footer className={styles.footer}>
<div className={styles.hideOnMobile}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="top"
arrow="left"
>
{rooms.length < 4 ? (
<Button
intent="text"
variant="icon"
wrapping
disabled
theme="base"
>
<PlusCircleIcon />
{addRoomLabel}
</Button>
) : null}
</Tooltip>
</div>
<Button
onClick={closePicker}
disabled={getFieldState("rooms").invalid}
className={styles.hideOnMobile}
intent="tertiary"
theme="base"
size="small"
>
{doneLabel}
</Button>
<Button
onClick={closePicker}
disabled={getFieldState("rooms").invalid}
className={styles.hideOnDesktop}
intent="tertiary"
theme="base"
size="large"
>
{doneLabel}
</Button>
</footer>
</div>
)
}

View File

@@ -1,26 +0,0 @@
"use client"
import { PropsWithChildren, useRef } from "react"
import {
GuestsRoomsContext,
type GuestsRoomsStore,
initGuestsRoomsState,
} from "@/stores/guests-rooms"
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function GuestsRoomsProvider({
selectedGuests,
children,
}: PropsWithChildren<{ selectedGuests?: GuestsRoom[] }>) {
const initialStore = useRef<GuestsRoomsStore>()
if (!initialStore.current) {
initialStore.current = initGuestsRoomsState(selectedGuests)
}
return (
<GuestsRoomsContext.Provider value={initialStore.current}>
{children}
</GuestsRoomsContext.Provider>
)
}

View File

@@ -1,10 +1,32 @@
.container {
overflow: hidden;
position: relative;
&[data-isopen="true"] {
overflow: visible;
}
.triggerDesktop {
display: none;
}
.pickerContainerMobile {
--header-height: 72px;
--sticky-button-height: 140px;
background-color: var(--Main-Grey-White);
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 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);
@@ -14,9 +36,6 @@
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x1);
}
.hideWrapper {
background-color: var(--Main-Grey-White);
}
.roomHeading {
margin-bottom: var(--Spacing-x1);
}
@@ -29,43 +48,14 @@
width: 100%;
text-align: left;
}
.body {
opacity: 0.8;
}
.footer {
display: grid;
gap: var(--Spacing-x1);
grid-template-columns: auto;
margin-top: var(--Spacing-x2);
}
@media screen and (max-width: 1366px) {
.hideWrapper {
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 100%;
transition: top 300ms ease;
z-index: 10002;
overflow: hidden;
}
.container[data-isopen="true"] .hideWrapper {
top: 20px;
}
.pickerContainer {
--header-height: 72px;
--sticky-button-height: 140px;
display: grid;
grid-template-areas:
"header"
"content";
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
position: relative;
}
.contentContainer {
grid-area: content;
overflow-y: scroll;
@@ -73,7 +63,6 @@
}
.header {
background-color: var(--Main-Grey-White);
display: grid;
grid-area: header;
padding: var(--Spacing-x3) var(--Spacing-x2);
@@ -101,11 +90,10 @@
rgba(255, 255, 255, 0) 7.5%,
#ffffff 82.5%
);
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x7);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x7);
position: sticky;
bottom: 0;
width: 100%;
z-index: 10;
}
.footer .hideOnMobile {
@@ -121,17 +109,40 @@
}
@media screen and (min-width: 1367px) {
.hideWrapper {
.pickerContainerMobile {
display: none;
}
.contentWrapper {
grid-template-rows: auto;
}
.contentContainer {
overflow-y: visible;
}
.triggerMobile {
display: none;
}
.triggerDesktop {
display: block;
}
.pickerContainerDesktop {
--header-height: 72px;
--sticky-button-height: 140px;
background-color: var(--Main-Grey-White);
display: grid;
border-radius: var(--Corner-radius-Large);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
left: calc((var(--Spacing-x1) + var(--Spacing-x2)) * -1);
max-width: calc(100vw - 20px);
padding: var(--Spacing-x2) var(--Spacing-x3);
position: absolute;
top: calc(100% + var(--Spacing-x2) + 1px + var(--Spacing-x4));
width: 360px;
max-height: calc(100dvh - 77px - var(--Spacing-x6));
overflow-y: auto;
}
.pickerContainerDesktop:focus-visible {
outline: none;
}
.header {
@@ -140,6 +151,7 @@
.footer {
grid-template-columns: auto auto;
padding-top: var(--Spacing-x2);
}
.footer .hideOnDesktop,

View File

@@ -1,67 +1,86 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useEffect, useState } from "react"
import {
Button,
Dialog,
DialogTrigger,
Modal,
Popover,
} from "react-aria-components"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import { guestRoomsSchema } from "@/components/Forms/BookingWidget/schema"
import Body from "@/components/TempDesignSystem/Text/Body"
import GuestsRoomsPicker from "./GuestsRoomsPicker"
import PickerForm from "./Form"
import styles from "./guests-rooms-picker.module.css"
export default function GuestsRoomsPickerForm({
name = "rooms",
}: {
name: string
}) {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
const { setValue } = useFormContext()
const { rooms, adultCount, childCount, setIsValidated } = useGuestsRoomsStore(
(state) => ({
rooms: state.rooms,
adultCount: state.adultCount,
childCount: state.childCount,
setIsValidated: state.setIsValidated,
})
)
const ref = useRef<HTMLDivElement | null>(null)
function handleOnClick() {
setIsOpen((prevIsOpen) => !prevIsOpen)
}
const closePicker = useCallback(() => {
const guestRoomsValidData = guestRoomsSchema.safeParse(rooms)
if (guestRoomsValidData.success) {
setIsOpen(false)
setIsValidated(false)
setValue(name, guestRoomsValidData.data, { shouldValidate: true })
} else {
setIsValidated(true)
}
}, [rooms, name, setValue, setIsValidated, setIsOpen])
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
useEffect(() => {
function handleClickOutside(evt: Event) {
const target = evt.target as HTMLElement
if (ref.current && target && !ref.current.contains(target)) {
closePicker()
export default function GuestsRoomsPickerForm() {
const { watch } = useFormContext()
const rooms = watch("rooms") as GuestsRoom[]
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isDesktop, setIsDesktop] = useState(true)
const htmlElement =
typeof window !== "undefined" ? document.querySelector("body") : null
//isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed".
function setOverflowClip(isOpen: boolean) {
if (htmlElement) {
if (isOpen) {
htmlElement.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.
htmlElement.style.overflow = "clip !important"
}
}
document.addEventListener("click", handleClickOutside)
return () => {
document.removeEventListener("click", handleClickOutside)
}
}, [closePicker])
}
useEffect(() => {
setIsDesktop(checkIsDesktop)
}, [checkIsDesktop])
return isDesktop ? (
<DialogTrigger onOpenChange={setOverflowClip}>
<Trigger rooms={rooms} className={styles.triggerDesktop} />
<Popover placement="bottom start" offset={36}>
<Dialog className={styles.pickerContainerDesktop}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Popover>
</DialogTrigger>
) : (
<DialogTrigger>
<Trigger rooms={rooms} className={styles.triggerMobile} />
<Modal>
<Dialog className={styles.pickerContainerMobile}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
)
}
function Trigger({
rooms,
className,
}: {
rooms: GuestsRoom[]
className: string
}) {
const intl = useIntl()
return (
<div className={styles.container} data-isopen={isOpen} ref={ref}>
<button className={styles.btn} onClick={handleOnClick} type="button">
<Body className={styles.body} asChild>
<span>
<Button className={`${className} ${styles.btn}`} type="button">
<Body>
{rooms.map((room, i) => (
<span key={i}>
{intl.formatMessage(
{ id: "booking.rooms" },
{ totalRooms: rooms.length }
@@ -69,21 +88,18 @@ export default function GuestsRoomsPickerForm({
{", "}
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: adultCount }
{ totalAdults: room.adults }
)}
{childCount > 0
{room.child.length > 0
? ", " +
intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: childCount }
{ totalChildren: room.child.length }
)
: null}
</span>
</Body>
</button>
<div aria-modal className={styles.hideWrapper} role="dialog">
<GuestsRoomsPicker closePicker={closePicker} />
</div>
</div>
))}
</Body>
</Button>
)
}

View File

@@ -98,24 +98,38 @@ export default function RoomFilter({
<FormProvider {...methods}>
<form onSubmit={handleSubmit(submitFilter)}>
<div className={styles.roomsFilter}>
{filterOptions.map((option) => (
<CheckboxChip
name={option.code}
key={option.code}
label={intl.formatMessage({ id: option.description })}
disabled={
(option.code === RoomPackageCodeEnum.ALLERGY_ROOM &&
petFriendly) ||
(option.code === RoomPackageCodeEnum.PET_ROOM &&
allergyFriendly)
}
selected={getValues(option.code)}
Icon={getIconForFeatureCode(option.code)}
/>
))}
<Tooltip text={tooltipText} position="bottom" arrow="right">
<InfoCircleIcon color="uiTextPlaceholder" />
</Tooltip>
{filterOptions.map((option) => {
const { code, description } = option
const isPetRoom = code === RoomPackageCodeEnum.PET_ROOM
const isAllergyRoom = code === RoomPackageCodeEnum.ALLERGY_ROOM
const isDisabled =
(isAllergyRoom && petFriendly) || (isPetRoom && allergyFriendly)
const checkboxChip = (
<CheckboxChip
name={code}
key={code}
label={intl.formatMessage({ id: description })}
disabled={isDisabled}
selected={getValues(code)}
Icon={getIconForFeatureCode(code)}
hasTooltip={isPetRoom}
/>
)
return isPetRoom ? (
<Tooltip
key={option.code}
text={tooltipText}
position="bottom"
arrow="right"
>
{checkboxChip}
</Tooltip>
) : (
checkboxChip
)
})}
</div>
</form>
</FormProvider>

View File

@@ -11,30 +11,38 @@ export function calculatePricesPerNight({
}: CalculatePricesPerNightProps) {
const totalPublicLocalPricePerNight = publicLocalPrice
? petRoomLocalPrice
? Number(publicLocalPrice.pricePerNight) +
Number(petRoomLocalPrice.price) / nights
: Number(publicLocalPrice.pricePerNight)
? Math.floor(
Number(publicLocalPrice.pricePerNight) +
Number(petRoomLocalPrice.price) / nights
)
: Math.floor(Number(publicLocalPrice.pricePerNight))
: undefined
const totalMemberLocalPricePerNight = memberLocalPrice
? petRoomLocalPrice
? Number(memberLocalPrice.pricePerNight) +
Number(petRoomLocalPrice.price) / nights
: Number(memberLocalPrice.pricePerNight)
? Math.floor(
Number(memberLocalPrice.pricePerNight) +
Number(petRoomLocalPrice.price) / nights
)
: Math.floor(Number(memberLocalPrice.pricePerNight))
: undefined
const totalPublicRequestedPricePerNight = publicRequestedPrice
? petRoomRequestedPrice
? Number(publicRequestedPrice.pricePerNight) +
Number(petRoomRequestedPrice.price) / nights
: Number(publicRequestedPrice.pricePerNight)
? Math.floor(
Number(publicRequestedPrice.pricePerNight) +
Number(petRoomRequestedPrice.price) / nights
)
: Math.floor(Number(publicRequestedPrice.pricePerNight))
: undefined
const totalMemberRequestedPricePerNight = memberRequestedPrice
? petRoomRequestedPrice
? Number(memberRequestedPrice.pricePerNight) +
Number(petRoomRequestedPrice.price) / nights
: Number(memberRequestedPrice.pricePerNight)
? Math.floor(
Number(memberRequestedPrice.pricePerNight) +
Number(petRoomRequestedPrice.price) / nights
)
: Math.floor(Number(memberRequestedPrice.pricePerNight))
: undefined
return {

View File

@@ -66,7 +66,7 @@ export default function RoomCard({
(room) => room.name === roomConfiguration.roomType
)
const { roomSize, occupancy, descriptions, images } = selectedRoom || {}
const { roomSize, occupancy, images } = selectedRoom || {}
const mainImage = images?.[0]
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })

View File

@@ -17,9 +17,9 @@ export default function RoomSelection({
user,
packages,
selectedPackages,
setRateSummary,
rateSummary,
}: RoomSelectionProps) {
const [rateSummary, setRateSummary] = useState<Rate | null>(null)
const router = useRouter()
const searchParams = useSearchParams()
const isUserLoggedIn = !!user

View File

@@ -12,7 +12,8 @@ import {
RoomPackageCodeEnum,
type RoomPackageCodes,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
import type { SelectRateProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
import type {
RoomConfiguration,
RoomsAvailability,
@@ -23,10 +24,10 @@ export default function Rooms({
roomCategories = [],
user,
packages,
}: Omit<RoomSelectionProps, "selectedPackages">) {
}: SelectRateProps) {
const visibleRooms: RoomConfiguration[] =
filterDuplicateRoomTypesByLowestPrice(roomsAvailability.roomConfigurations)
const [rateSummary, setRateSummary] = useState<Rate | null>(null)
const [rooms, setRooms] = useState<RoomsAvailability>({
...roomsAvailability,
roomConfigurations: visibleRooms,
@@ -48,6 +49,14 @@ export default function Rooms({
...roomsAvailability,
roomConfigurations: visibleRooms,
})
if (!!rateSummary) {
setRateSummary({
...rateSummary,
features: [],
})
}
return
}
@@ -57,8 +66,26 @@ export default function Rooms({
)
)
setRooms({ ...roomsAvailability, roomConfigurations: filteredRooms })
const petRoomPackage =
(filteredPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
packages.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
undefined
const features = filteredRooms.find((room) =>
room.features.some(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
)
)?.features
if (!!rateSummary) {
setRateSummary({
...rateSummary,
features: petRoomPackage && features ? features : [],
})
}
},
[roomsAvailability, visibleRooms]
[roomsAvailability, visibleRooms, rateSummary, packages]
)
return (
@@ -74,6 +101,8 @@ export default function Rooms({
user={user}
packages={packages}
selectedPackages={selectedPackages}
setRateSummary={setRateSummary}
rateSummary={rateSummary}
/>
</div>
)

View File

@@ -0,0 +1,29 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function BedSingleIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={classNames}
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.0427 14.0548H4.95718V14.9742C4.95718 15.2154 4.87884 15.4189 4.72214 15.5847C4.56545 15.7505 4.37314 15.8334 4.14522 15.8334C3.9173 15.8334 3.72499 15.7505 3.56829 15.5847C3.4116 15.4189 3.33325 15.2154 3.33325 14.9742V10.9484C3.33325 10.3564 3.48757 9.83679 3.79621 9.38962C4.10486 8.94244 4.51084 8.61837 5.01416 8.41739V6.48802C5.01416 5.83485 5.22546 5.28468 5.64807 4.83751C6.07067 4.39033 6.58947 4.16675 7.20448 4.16675H12.7954C13.4104 4.16675 13.9292 4.39033 14.3518 4.83751C14.7744 5.28468 14.9857 5.83485 14.9857 6.48802V8.41739C15.489 8.61837 15.895 8.94244 16.2036 9.38962C16.5123 9.83679 16.6666 10.3564 16.6666 10.9484V14.9742C16.6666 15.2154 16.5882 15.4189 16.4315 15.5847C16.2748 15.7505 16.0825 15.8334 15.8546 15.8334C15.6267 15.8334 15.4344 15.7505 15.2777 15.5847C15.121 15.4189 15.0427 15.2154 15.0427 14.9742V14.0548ZM6.6381 8.23652V5.85495H13.3617V8.23652H6.6381ZM4.95718 10.9497V12.3364H15.0427V10.9497C15.0427 10.6683 14.9524 10.4322 14.772 10.2413C14.5916 10.0503 14.37 9.95486 14.1075 9.95486H5.89237C5.62979 9.95486 5.40828 10.0503 5.22784 10.2413C5.0474 10.4322 4.95718 10.6683 4.95718 10.9497Z"
fill="#57514E"
/>
</svg>
)
}

View File

@@ -0,0 +1,29 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function KingBedSmallIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.04175 10.0104C1.04175 9.57819 1.19474 9.21146 1.50073 8.91021C1.80671 8.60896 2.17455 8.45833 2.60425 8.45833H2.68758V6C2.68758 5.57031 2.84057 5.20247 3.14656 4.89648C3.45255 4.59049 3.82039 4.4375 4.25008 4.4375H15.7501C16.1798 4.4375 16.5476 4.59049 16.8536 4.89648C17.1596 5.20247 17.3126 5.57031 17.3126 6V8.45833H17.3959C17.8256 8.45833 18.1934 8.61133 18.4994 8.91731C18.8054 9.2233 18.9584 9.59114 18.9584 10.0208V12.9688C18.9584 13.2674 18.8542 13.5208 18.6459 13.7292C18.4376 13.9375 18.1843 14.0417 17.8862 14.0417H17.6459V14.9063C17.6459 15.1215 17.5695 15.3056 17.4167 15.4583C17.264 15.6111 17.0799 15.6875 16.8647 15.6875C16.6494 15.6875 16.4654 15.6111 16.3126 15.4583C16.1598 15.3056 16.0834 15.1215 16.0834 14.9063V14.0417H3.91675V14.9063C3.91675 15.1215 3.84036 15.3056 3.68758 15.4583C3.5348 15.6111 3.35078 15.6875 3.1355 15.6875C2.92022 15.6875 2.73619 15.6111 2.58341 15.4583C2.43064 15.3056 2.35425 15.1215 2.35425 14.9063V14.0417H2.11398C1.81582 14.0417 1.56258 13.9375 1.35425 13.7292C1.14591 13.5208 1.04175 13.2674 1.04175 12.9688V10.0104ZM15.7501 8.45833H10.7813V6H15.7501V8.45833ZM9.21883 8.45833H4.25008V6H9.21883V8.45833ZM17.3959 12.4792H2.60425V10.0208H17.3959V12.4792Z"
fill="#57514E"
/>
</svg>
)
}

View File

@@ -9,6 +9,7 @@ export { default as ArrowRightIcon } from "./ArrowRight"
export { default as BarIcon } from "./Bar"
export { default as BathtubIcon } from "./Bathtub"
export { default as BedDoubleIcon } from "./BedDouble"
export { default as BedSingleIcon } from "./BedSingle"
export { default as BikingIcon } from "./Biking"
export { default as BreakfastIcon } from "./Breakfast"
export { default as BusinessIcon } from "./Business"
@@ -77,6 +78,7 @@ export { default as IronIcon } from "./Iron"
export { default as KayakingIcon } from "./Kayaking"
export { default as KettleIcon } from "./Kettle"
export { default as KingBedIcon } from "./KingBed"
export { default as KingBedSmallIcon } from "./KingBedSmall"
export { default as LampIcon } from "./Lamp"
export { default as LaundryMachineIcon } from "./LaundryMachine"
export { default as LocalBarIcon } from "./LocalBar"

View File

@@ -27,16 +27,17 @@
.imagePlaceholder {
height: 100%;
min-height: 190px;
aspect-ratio: 16/9;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
background-size: 160px 160px;
background-size: 180px 180px;
background-position:
0 0,
0 80px,
80px -80px,
-80px 0;
0 90px,
90px -90px,
-90px 0;
}

View File

@@ -20,9 +20,10 @@ export default function ImageGallery({
}: ImageGalleryProps) {
const intl = useIntl()
const [lightboxIsOpen, setLightboxIsOpen] = useState(false)
const [imageError, setImageError] = useState(false)
const imageProps = fill ? { fill } : { height, width: height * 1.5 }
if (!images || images.length === 0) {
if (!images || images.length === 0 || imageError) {
return <div className={styles.imagePlaceholder} />
}
@@ -38,6 +39,7 @@ export default function ImageGallery({
className={styles.image}
src={images[0].imageSizes.medium}
alt={images[0].metaData.altText}
onError={() => setImageError(true)}
{...imageProps}
/>
<div className={styles.imageCount}>

View File

@@ -0,0 +1,37 @@
import { FC } from "react"
import {
BedDoubleIcon,
BedSingleIcon,
KingBedSmallIcon,
} from "@/components/Icons"
import type { IconProps } from "@/types/components/icon"
export function getBedIcon(name: string): FC<IconProps> | null {
const iconMappings = [
{
icon: BedDoubleIcon,
texts: ["Queen"],
},
{
icon: KingBedSmallIcon,
texts: ["King"],
},
{
icon: KingBedSmallIcon,
texts: ["CustomOccupancy"],
},
{
icon: BedSingleIcon,
texts: ["Twin"],
},
{
icon: BedSingleIcon,
texts: ["Single"],
},
]
const icon = iconMappings.find((icon) => icon.texts.includes(name))
return icon ? icon.icon : BedSingleIcon
}

View File

@@ -1,11 +1,11 @@
import { useIntl } from "react-intl"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getBedIcon } from "./bedIcon"
import { getFacilityIcon } from "./facilityIcon"
import styles from "./roomSidePeek.module.css"
@@ -79,15 +79,27 @@ export default function RoomSidePeek({
<Body color="grey">
{intl.formatMessage({ id: "booking.basedOnAvailability" })}
</Body>
{/* TODO: Get data for bed options */}
<ul className={styles.bedOptions}>
{room.roomTypes.map((roomType) => {
const BedIcon = getBedIcon(roomType.mainBed.type)
return (
<li key={roomType.code}>
{BedIcon && (
<BedIcon
color="uiTextMediumContrast"
width={24}
height={24}
/>
)}
<Body color="uiTextMediumContrast" asChild>
<span>{roomType.mainBed.description}</span>
</Body>
</li>
)
})}
</ul>
</div>
</div>
<div className={styles.buttonContainer}>
<Button fullWidth theme="base" intent="primary">
{intl.formatMessage({ id: "booking.selectRoom" })}
{/* TODO: Implement logic for select room */}
</Button>
</div>
</SidePeek>
)
}

View File

@@ -44,6 +44,12 @@
margin-bottom: var(--Spacing-x-half);
}
.bedOptions li {
display: flex;
gap: var(--Spacing-x1);
margin-bottom: var(--Spacing-x-half);
}
.noIcon {
margin-left: var(--Spacing-x4);
}

View File

@@ -1,7 +1,7 @@
import { useMemo } from "react"
import { useFormContext } from "react-hook-form"
import { HeartIcon } from "@/components/Icons"
import { HeartIcon, InfoCircleIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./chip.module.css"
@@ -19,6 +19,7 @@ export default function FilterChip({
value,
selected,
disabled,
hasTooltip,
}: FilterChipProps) {
const { register } = useFormContext()
@@ -43,6 +44,11 @@ export default function FilterChip({
<Caption type="bold" color={color} className={styles.caption}>
{label}
</Caption>
{hasTooltip && (
<InfoCircleIcon color={color} height={iconHeight} width={iconWidth} />
)}
<input
aria-hidden
id={id || name}

View File

@@ -1,5 +1,5 @@
.modal {
--sidepeek-desktop-width: 600px;
--sidepeek-desktop-width: 560px;
}
@keyframes slide-in {
from {

View File

@@ -1,6 +1,6 @@
.tooltipContainer {
position: relative;
display: inline-block;
display: flex;
}
.tooltip {

View File

@@ -1,227 +0,0 @@
"use client"
import { produce } from "immer"
import { createContext, useContext } from "react"
import { create, useStore } from "zustand"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import {
Child,
GuestsRoom,
} from "@/types/components/bookingWidget/guestsRoomsPicker"
const SESSION_STORAGE_KEY = "guests_rooms"
interface extendedGuestsRoom extends GuestsRoom {
childrenInAdultsBed: number
}
interface GuestsRoomsState {
rooms: extendedGuestsRoom[]
adultCount: number
childCount: number
isValidated: boolean
}
interface GuestsRoomsStoreState extends GuestsRoomsState {
increaseAdults: (roomIndex: number) => void
decreaseAdults: (roomIndex: number) => void
increaseChildren: (roomIndex: number) => void
decreaseChildren: (roomIndex: number) => Child[]
updateChildAge: (age: number, roomIndex: number, childIndex: number) => void
updateChildBed: (bed: number, roomIndex: number, childIndex: number) => void
increaseChildInAdultsBed: (roomIndex: number) => void
decreaseChildInAdultsBed: (roomIndex: number) => void
increaseRoom: () => void
decreaseRoom: (roomIndex: number) => void
setIsValidated: (isValidated: boolean) => void
}
export function validateBedTypes(data: extendedGuestsRoom[]) {
data.forEach((room) => {
room.child.forEach((child) => {
const allowedBedTypes: number[] = []
if (child.age <= 5 && room.adults >= room.childrenInAdultsBed) {
allowedBedTypes.push(ChildBedMapEnum.IN_ADULTS_BED)
} else if (child.age <= 5) {
room.childrenInAdultsBed = room.childrenInAdultsBed - 1
}
if (child.age < 3) {
allowedBedTypes.push(ChildBedMapEnum.IN_CRIB)
}
if (child.age > 2) {
allowedBedTypes.push(ChildBedMapEnum.IN_EXTRA_BED)
}
if (!allowedBedTypes.includes(child.bed)) {
child.bed = allowedBedTypes[0]
}
})
})
}
export function initGuestsRoomsState(initData?: GuestsRoom[]) {
const isBrowser = typeof window !== "undefined"
const sessionData = isBrowser
? sessionStorage.getItem(SESSION_STORAGE_KEY)
: null
const defaultGuestsData: extendedGuestsRoom = {
adults: 1,
child: [],
childrenInAdultsBed: 0,
}
const defaultData: GuestsRoomsState = {
rooms: [defaultGuestsData],
adultCount: 1,
childCount: 0,
isValidated: false,
}
let inputData: GuestsRoomsState = defaultData
if (sessionData) {
inputData = JSON.parse(sessionData)
}
if (initData) {
inputData.rooms = initData.map((room) => {
const childrenInAdultsBed = room.child
? room.child.reduce((acc, child) => {
acc = acc + (child.bed == ChildBedMapEnum.IN_ADULTS_BED ? 1 : 0)
return acc
}, 0)
: 0
return { ...defaultGuestsData, ...room, childrenInAdultsBed }
}) as extendedGuestsRoom[]
inputData.adultCount = initData.reduce((acc, room) => {
acc = acc + room.adults
return acc
}, 0)
inputData.childCount = initData.reduce((acc, room) => {
acc = acc + room.child?.length
return acc
}, 0)
validateBedTypes(inputData.rooms)
}
return create<GuestsRoomsStoreState>()((set, get) => ({
...inputData,
increaseAdults: (roomIndex) =>
set(
produce((state: GuestsRoomsState) => {
state.rooms[roomIndex].adults = state.rooms[roomIndex].adults + 1
state.adultCount = state.adultCount + 1
})
),
decreaseAdults: (roomIndex) =>
set(
produce((state: GuestsRoomsState) => {
state.rooms[roomIndex].adults = state.rooms[roomIndex].adults - 1
state.adultCount = state.adultCount - 1
if (
state.rooms[roomIndex].childrenInAdultsBed >
state.rooms[roomIndex].adults
) {
const toUpdateIndex = state.rooms[roomIndex].child.findIndex(
(child) => child.bed == ChildBedMapEnum.IN_ADULTS_BED
)
if (toUpdateIndex != -1) {
state.rooms[roomIndex].child[toUpdateIndex].bed =
state.rooms[roomIndex].child[toUpdateIndex].age < 3
? ChildBedMapEnum.IN_CRIB
: ChildBedMapEnum.IN_EXTRA_BED
state.rooms[roomIndex].childrenInAdultsBed =
state.rooms[roomIndex].adults
}
}
})
),
increaseChildren: (roomIndex) =>
set(
produce((state: GuestsRoomsState) => {
state.rooms[roomIndex].child.push({
age: -1,
bed: -1,
})
state.childCount = state.childCount + 1
})
),
decreaseChildren: (roomIndex) => {
set(
produce((state: GuestsRoomsState) => {
const roomChildren = state.rooms[roomIndex].child
if (
roomChildren.length &&
roomChildren[roomChildren.length - 1].bed ==
ChildBedMapEnum.IN_ADULTS_BED
) {
state.rooms[roomIndex].childrenInAdultsBed =
state.rooms[roomIndex].childrenInAdultsBed - 1
}
state.rooms[roomIndex].child.pop()
state.childCount = state.childCount - 1
})
)
return get().rooms[roomIndex].child
},
updateChildAge: (age, roomIndex, childIndex) =>
set(
produce((state: GuestsRoomsState) => {
state.rooms[roomIndex].child[childIndex].age = age
})
),
updateChildBed: (bed, roomIndex, childIndex) =>
set(
produce((state: GuestsRoomsState) => {
state.rooms[roomIndex].child[childIndex].bed = bed
})
),
increaseChildInAdultsBed: (roomIndex) =>
set(
produce((state: GuestsRoomsState) => {
state.rooms[roomIndex].childrenInAdultsBed =
state.rooms[roomIndex].childrenInAdultsBed + 1
})
),
decreaseChildInAdultsBed: (roomIndex) =>
set(
produce((state: GuestsRoomsState) => {
state.rooms[roomIndex].childrenInAdultsBed =
state.rooms[roomIndex].childrenInAdultsBed - 1
})
),
increaseRoom: () =>
set(
produce((state: GuestsRoomsState) => {
state.rooms.push({
adults: 1,
child: [],
childrenInAdultsBed: 0,
})
})
),
decreaseRoom: (roomIndex) =>
set(
produce((state: GuestsRoomsState) => {
state.rooms.splice(roomIndex, 1)
})
),
setIsValidated: (isValidated) => set(() => ({ isValidated })),
}))
}
export type GuestsRoomsStore = ReturnType<typeof initGuestsRoomsState>
export const GuestsRoomsContext = createContext<GuestsRoomsStore | null>(null)
export const useGuestsRoomsStore = <T>(
selector: (store: GuestsRoomsStoreState) => T
): T => {
const guestsRoomsContextStore = useContext(GuestsRoomsContext)
if (!guestsRoomsContextStore) {
throw new Error(
`guestsRoomsContextStore must be used within GuestsRoomsContextProvider`
)
}
return useStore(guestsRoomsContextStore, selector)
}

View File

@@ -3,7 +3,6 @@ import { create } from "zustand"
export enum StickyElementNameEnum {
SITEWIDE_ALERT = "SITEWIDE_ALERT",
BOOKING_WIDGET = "BOOKING_WIDGET",
BOOKING_WIDGET_MOBILE = "BOOKING_WIDGET_MOBILE",
HOTEL_TAB_NAVIGATION = "HOTEL_TAB_NAVIGATION",
HOTEL_STATIC_MAP = "HOTEL_STATIC_MAP",
}
@@ -32,7 +31,6 @@ interface StickyStore {
const priorityMap: Record<StickyElementNameEnum, number> = {
[StickyElementNameEnum.SITEWIDE_ALERT]: 1,
[StickyElementNameEnum.BOOKING_WIDGET]: 2,
[StickyElementNameEnum.BOOKING_WIDGET_MOBILE]: 2,
[StickyElementNameEnum.HOTEL_TAB_NAVIGATION]: 3,
[StickyElementNameEnum.HOTEL_STATIC_MAP]: 3,

View File

@@ -13,26 +13,23 @@ export type GuestsRoom = {
child: Child[]
}
export interface GuestsRoomsPickerProps {
closePicker: () => void
}
export type GuestsRoomPickerProps = {
index: number
}
export type AdultSelectorProps = {
roomIndex: number
}
export type ChildSelectorProps = {
export type SelectorProps = {
roomIndex: number
currentAdults: number
currentChildren: Child[]
childrenInAdultsBed: number
}
export type ChildInfoSelectorProps = {
child: Child
adults: number
index: number
roomIndex: number
childrenInAdultsBed: number
}
export interface CounterProps {

View File

@@ -11,6 +11,7 @@ export interface FilterChipProps {
value?: string
selected?: boolean
disabled?: boolean
hasTooltip?: boolean
}
export type FilterChipCheckboxProps = Omit<FilterChipProps, "type">

View File

@@ -2,6 +2,7 @@ import type { RoomData } from "@/types/hotel"
import type { SafeUser } from "@/types/user"
import type { RoomsAvailability } from "@/server/routers/hotels/output"
import type { RoomPackageCodes, RoomPackageData } from "./roomFilter"
import type { Rate } from "./selectRate"
export interface RoomSelectionProps {
roomsAvailability: RoomsAvailability
@@ -9,4 +10,13 @@ export interface RoomSelectionProps {
user: SafeUser
packages: RoomPackageData
selectedPackages: RoomPackageCodes[]
setRateSummary: (rateSummary: Rate) => void
rateSummary: Rate | null
}
export interface SelectRateProps {
roomsAvailability: RoomsAvailability
roomCategories: RoomData[]
user: SafeUser
packages: RoomPackageData
}