feat: SW-276 Implemented child age validation

This commit is contained in:
Hrishikesh Vaipurkar
2024-09-18 13:24:35 +02:00
parent 24f7bc290d
commit a7167dde6a
10 changed files with 110 additions and 54 deletions

View File

@@ -2,6 +2,18 @@ import { z } from "zod"
import type { Location } from "@/types/trpc/routers/hotel/locations" import type { Location } from "@/types/trpc/routers/hotel/locations"
export const guestRoomsSchema = z.array(
z.object({
adults: z.number().default(1),
children: z.array(
z.object({
age: z.number().nonnegative(),
bed: z.number(),
})
),
})
)
export const bookingWidgetSchema = z.object({ export const bookingWidgetSchema = z.object({
bookingCode: z.string(), // Update this as required when working with booking codes component bookingCode: z.string(), // Update this as required when working with booking codes component
date: z.object({ date: z.object({
@@ -25,17 +37,7 @@ export const bookingWidgetSchema = z.object({
{ message: "Required" } { message: "Required" }
), ),
redemption: z.boolean().default(false), redemption: z.boolean().default(false),
rooms: z.array( rooms: guestRoomsSchema,
z.object({
adults: z.number().default(1),
children: z.array(
z.object({
age: z.number(),
bed: z.number(),
})
),
})
),
search: z.string({ coerce: true }).min(1, "Required"), search: z.string({ coerce: true }).min(1, "Required"),
voucher: z.boolean().default(false), voucher: z.boolean().default(false),
}) })

View File

@@ -12,6 +12,7 @@ type ChildSelectorProps = {
index: number index: number
availableBedTypes?: ChildBed[] availableBedTypes?: ChildBed[]
updateChild: (child: Child, index: number) => void updateChild: (child: Child, index: number) => void
childAgeError: boolean
} }
export default function ChildInfoSelector({ export default function ChildInfoSelector({
@@ -23,6 +24,7 @@ export default function ChildInfoSelector({
{ label: "In extra bed", value: 2 }, { label: "In extra bed", value: 2 },
], ],
updateChild = (child: Child, index: number) => {}, updateChild = (child: Child, index: number) => {},
childAgeError,
}: ChildSelectorProps) { }: ChildSelectorProps) {
const intl = useIntl() const intl = useIntl()
const ageLabel = intl.formatMessage({ id: "Age" }) const ageLabel = intl.formatMessage({ id: "Age" })
@@ -55,29 +57,38 @@ export default function ChildInfoSelector({
return ( return (
<> <>
<Select <div>
size={2}
items={ageList}
label={ageLabel}
aria-label={ageLabel}
value={child.age}
onSelect={(key) => {
handleOnSelect(key, "age")
}}
name="age"
/>
{child.age !== -1 ? (
<Select <Select
items={availableBedTypes} required={true}
label={bedLabel} items={ageList}
aria-label={bedLabel} label={ageLabel}
value={child.bed} aria-label={ageLabel}
value={child.age}
onSelect={(key) => { onSelect={(key) => {
handleOnSelect(key, "bed") handleOnSelect(key, "age")
}} }}
name="bed" name="age"
placeholder={ageLabel}
/> />
) : null} {childAgeError && child.age < 0 ? (
<span>Child Age is required</span>
) : null}
</div>
<div>
{child.age !== -1 ? (
<Select
items={availableBedTypes}
label={bedLabel}
aria-label={bedLabel}
value={child.bed}
onSelect={(key) => {
handleOnSelect(key, "bed")
}}
name="bed"
placeholder={bedLabel}
/>
) : null}
</div>
</> </>
) )
} }

View File

@@ -5,8 +5,9 @@
} }
.childInfoContainer { .childInfoContainer {
display: flex; display: grid;
gap: 20px; gap: var(--Spacing-x2);
grid-template-columns: 1fr 2fr;
} }
.textCenter { .textCenter {

View File

@@ -17,6 +17,7 @@ export default function ChildSelector({
roomChildren = [], roomChildren = [],
adultCount = 1, adultCount = 1,
updateChildren = (children: Child[]) => {}, updateChildren = (children: Child[]) => {},
childAgeError,
}: ChildSelectorProps) { }: ChildSelectorProps) {
const intl = useIntl() const intl = useIntl()
const childrenLabel = intl.formatMessage({ id: "Children" }) const childrenLabel = intl.formatMessage({ id: "Children" })
@@ -59,19 +60,32 @@ export default function ChildSelector({
} }
roomChildren.forEach((child, index) => { roomChildren.forEach((child, index) => {
let types: typeof availableBedTypes = [] let types: typeof availableBedTypes = []
let selectedBed: boolean = false
if ( if (
child.age <= 5 && child.age <= 5 &&
(availableInAdultsBed > 0 || childInAdultsBedIndices.indexOf(index) != -1) (availableInAdultsBed > 0 || childInAdultsBedIndices.indexOf(index) != -1)
) { ) {
types.push(availableBedTypes[0]) types.push(availableBedTypes[0])
if (child.bed == 0) {
selectedBed = true
}
} }
if (child.age < 3) { if (child.age < 3) {
types.push(availableBedTypes[1]) types.push(availableBedTypes[1])
if (child.bed == 1) {
selectedBed = true
}
} }
if (child.age > 2) { if (child.age > 2) {
types.push(availableBedTypes[2]) types.push(availableBedTypes[2])
if (child.bed == 2) {
selectedBed = true
}
} }
childBedTypes[index] = types childBedTypes[index] = types
if (!selectedBed) {
child.bed = types[0].value
}
}) })
return ( return (
@@ -93,6 +107,7 @@ export default function ChildSelector({
child={child} child={child}
availableBedTypes={childBedTypes[index]} availableBedTypes={childBedTypes[index]}
updateChild={updateChildInfo} updateChild={updateChildInfo}
childAgeError={childAgeError}
/> />
</div> </div>
))} ))}

View File

@@ -1,5 +1,4 @@
.container { .container {
width: 280px;
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x1); padding-bottom: var(--Spacing-x1);

View File

@@ -17,6 +17,7 @@ export default function GuestsRoomPicker({
handleOnSelect = (selected: GuestsRoom, index: number) => {}, handleOnSelect = (selected: GuestsRoom, index: number) => {},
room = { adults: 1, children: [] }, room = { adults: 1, children: [] },
index = 1, index = 1,
childAgeError,
}: GuestsRoomPickerProps) { }: GuestsRoomPickerProps) {
const intl = useIntl() const intl = useIntl()
const roomLabel = intl.formatMessage({ id: "Room" }) const roomLabel = intl.formatMessage({ id: "Room" })
@@ -41,6 +42,7 @@ export default function GuestsRoomPicker({
roomChildren={room.children} roomChildren={room.children}
adultCount={room.adults} adultCount={room.adults}
updateChildren={updateChildren} updateChildren={updateChildren}
childAgeError={childAgeError}
/> />
</section> </section>
) )

View File

@@ -23,6 +23,7 @@ export default function GuestsRoomsPicker({
}, },
], ],
closePicker, closePicker,
childAgeError,
}: GuestsRoomsPickerProps) { }: GuestsRoomsPickerProps) {
const lang = useLang() const lang = useLang()
const [selectedGuests, setSelectedGuests] = const [selectedGuests, setSelectedGuests] =
@@ -62,26 +63,29 @@ export default function GuestsRoomsPicker({
return ( return (
<> <>
{selectedGuests.map((room, index) => ( {selectedGuests.map((room, index) => (
<section key={index}> <section className={styles.roomContainer} key={index}>
<GuestsRoomPicker <GuestsRoomPicker
room={room} room={room}
handleOnSelect={handleSelectRoomGuests} handleOnSelect={handleSelectRoomGuests}
index={index} index={index}
childAgeError={childAgeError}
/> />
<Divider></Divider> {/* Not in MVP
{index > 0 ? ( {index > 0 ? (
<Button intent="text" onClick={() => removeRoom(index)}> <Button intent="text" onClick={() => removeRoom(index)}>
Remove Room Remove Room
</Button> </Button>
) : null} ) : null} */}
<Divider></Divider>
</section> </section>
))} ))}
<div className={styles.footer}> <div className={styles.footer}>
{/* Not in MVP
{selectedGuests.length < 4 ? ( {selectedGuests.length < 4 ? (
<Button intent="text" onClick={addRoom}> <Button intent="text" onClick={addRoom}>
Add Room Add Room
</Button> </Button>
) : null} ) : null} */}
<Button onClick={closePicker}>Done</Button> <Button onClick={closePicker}>Done</Button>
</div> </div>
</> </>

View File

@@ -6,6 +6,10 @@
overflow: visible; overflow: visible;
} }
} }
.roomContainer {
display: grid;
gap: var(--Spacing-x1);
}
.hideWrapper { .hideWrapper {
background-color: var(--Main-Grey-White); background-color: var(--Main-Grey-White);
border-radius: var(--Corner-radius-Medium); border-radius: var(--Corner-radius-Medium);
@@ -14,6 +18,8 @@
position: absolute; position: absolute;
/** BookingWidget padding + border-width */ /** BookingWidget padding + border-width */
top: calc(100% + var(--Spacing-x2) + 1px); top: calc(100% + var(--Spacing-x2) + 1px);
width: 360px;
max-width: 100vw; /* for small screens having view port width of 320px */
} }
.btn { .btn {
background: none; background: none;
@@ -28,7 +34,7 @@
} }
.footer { .footer {
display: grid; display: grid;
gap: 20px; gap: var(--Spacing-x1);
grid-template-columns: auto auto; grid-template-columns: auto;
margin: 10px 0 0; margin: 10px 0 0;
} }

View File

@@ -1,9 +1,10 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { useFormContext, useWatch } from "react-hook-form" import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { guestRoomsSchema } from "@/components/Forms/BookingWidget/schema"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import GuestsRoomsPicker from "./GuestsRoomsPicker" import GuestsRoomsPicker from "./GuestsRoomsPicker"
@@ -20,6 +21,7 @@ export default function GuestsRoomsPickerForm({
}: GuestsRoomsFormProps) { }: GuestsRoomsFormProps) {
const intl = useIntl() const intl = useIntl()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [isAgeError, setIsAgeError] = useState(false)
const selectedGuests = useWatch({ name }) const selectedGuests = useWatch({ name })
const { register, setValue } = useFormContext() const { register, setValue } = useFormContext()
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
@@ -28,19 +30,29 @@ export default function GuestsRoomsPickerForm({
} }
function handleSelectGuest(selected: GuestsRoom[]) { function handleSelectGuest(selected: GuestsRoom[]) {
setValue(name, selected) setValue(name, selected)
setIsAgeError(false)
} }
const closePicker = useCallback(() => {
const guestRoomsValidData = guestRoomsSchema.safeParse(selectedGuests)
if (guestRoomsValidData.success) {
setIsOpen(false)
} else {
setIsAgeError(true)
}
}, [selectedGuests])
useEffect(() => { useEffect(() => {
function handleClickOutside(evt: Event) { function handleClickOutside(evt: Event) {
const target = evt.target as HTMLElement const target = evt.target as HTMLElement
if (ref.current && target && !ref.current.contains(target)) { if (ref.current && target && !ref.current.contains(target)) {
setIsOpen(false) closePicker()
} }
} }
document.addEventListener("click", handleClickOutside) document.addEventListener("click", handleClickOutside)
return () => { return () => {
document.removeEventListener("click", handleClickOutside) document.removeEventListener("click", handleClickOutside)
} }
}, [setIsOpen]) }, [closePicker])
let selectedAdultsCount = 0 let selectedAdultsCount = 0
let selectedChildrenCount = 0 let selectedChildrenCount = 0
@@ -51,33 +63,34 @@ export default function GuestsRoomsPickerForm({
}) })
const selectedRoomsCount = selectedGuests.length const selectedRoomsCount = selectedGuests.length
const childCountLabel = const childCountLabel =
(selectedChildrenCount > 1 selectedChildrenCount > 1
? intl.formatMessage({ id: "Children" }) ? intl.formatMessage({ id: "Children" })
: intl.formatMessage({ id: "Child" })) + ", " : intl.formatMessage({ id: "Child" })
return ( return (
<div className={styles.container} data-isopen={isOpen} ref={ref}> <div className={styles.container} data-isopen={isOpen} ref={ref}>
<button className={styles.btn} onClick={handleOnClick} type="button"> <button className={styles.btn} onClick={handleOnClick} type="button">
<Body className={styles.body}> <Body className={styles.body}>
{selectedAdultsCount}{" "}
{selectedAdultsCount > 1
? intl.formatMessage({ id: "Adults" })
: intl.formatMessage({ id: "Adult" })}{" "}
{", "}
{selectedChildrenCount > 0
? selectedChildrenCount + " " + childCountLabel
: null}
{selectedRoomsCount}{" "} {selectedRoomsCount}{" "}
{selectedRoomsCount > 1 {selectedRoomsCount > 1
? intl.formatMessage({ id: "Rooms" }) ? intl.formatMessage({ id: "Rooms" })
: intl.formatMessage({ id: "Room" })} : intl.formatMessage({ id: "Room" })}
{", "}
{selectedAdultsCount}{" "}
{selectedAdultsCount > 1
? intl.formatMessage({ id: "Adults" })
: intl.formatMessage({ id: "Adult" })}
{selectedChildrenCount > 0
? ", " + selectedChildrenCount + " " + childCountLabel
: null}
</Body> </Body>
</button> </button>
<div aria-modal className={styles.hideWrapper} role="dialog"> <div aria-modal className={styles.hideWrapper} role="dialog">
<GuestsRoomsPicker <GuestsRoomsPicker
handleOnSelect={handleSelectGuest} handleOnSelect={handleSelectGuest}
initialSelected={selectedGuests} initialSelected={selectedGuests}
closePicker={handleOnClick} closePicker={closePicker}
childAgeError={isAgeError}
/> />
</div> </div>
</div> </div>

View File

@@ -21,12 +21,14 @@ export interface GuestsRoomsPickerProps {
handleOnSelect: (selected: GuestsRoom[]) => void handleOnSelect: (selected: GuestsRoom[]) => void
initialSelected?: GuestsRoom[] initialSelected?: GuestsRoom[]
closePicker: () => void closePicker: () => void
childAgeError: boolean
} }
export type GuestsRoomPickerProps = { export type GuestsRoomPickerProps = {
handleOnSelect: (selected: GuestsRoom, index: number) => void handleOnSelect: (selected: GuestsRoom, index: number) => void
room: GuestsRoom room: GuestsRoom
index: number index: number
childAgeError: boolean
} }
export type AdultSelectorProps = { export type AdultSelectorProps = {
@@ -38,4 +40,5 @@ export type ChildSelectorProps = {
roomChildren: Child[] roomChildren: Child[]
adultCount: number adultCount: number
updateChildren: (children: Child[]) => void updateChildren: (children: Child[]) => void
childAgeError: boolean
} }