Merged in fix/BOOK-323-enter-details-scroll-error (pull request #2986)
Fix/BOOK-323 enter details scroll error * fix(BOOK-323): scroll to invalid element on submit on enter details * fix(BOOK-323): update error message design * fix(BOOK-323): clean up * fix(BOOK-323): scroll to fields in room in right order * fix(BOOK-323): add id to translations * fix(BOOK-323): remove undefined * fix(BOOK-323): fix submitting state * fix(BOOK-323): use ref in multiroom for scrolling to right element, add membershipNo * fix(BOOK-323): fix invalid border country * fix(BOOK-323): use error message component * fix(BOOK-323): fix invalid focused styling on mobile * fix(BOOK-323): remove redundant dependency in callback Approved-by: Erik Tiekstra
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input"
|
||||
|
||||
@@ -51,7 +52,7 @@ const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
|
||||
ref
|
||||
) {
|
||||
const intl = useIntl()
|
||||
const { control } = useFormContext()
|
||||
const { control, formState } = useFormContext()
|
||||
const config = useBookingFlowConfig()
|
||||
|
||||
return (
|
||||
@@ -96,14 +97,15 @@ const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
|
||||
</Caption>
|
||||
) : null}
|
||||
{fieldState.error && !hideError ? (
|
||||
<Caption className={styles.error} fontOnly>
|
||||
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
|
||||
{getErrorMessage(
|
||||
<ErrorMessage
|
||||
errors={formState.errors}
|
||||
name={name}
|
||||
messageLabel={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
fieldState.error.message
|
||||
)}
|
||||
</Caption>
|
||||
/>
|
||||
) : null}
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
@@ -3,15 +3,3 @@
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.error {
|
||||
align-items: center;
|
||||
color: var(--Text-Interactive-Error);
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
margin: var(--Space-x1) 0 0;
|
||||
}
|
||||
|
||||
.error svg {
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
@@ -15,3 +15,7 @@
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
width: min(696px, 100%);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import RadioCard from "@scandic-hotels/design-system/Form/RadioCard"
|
||||
import { MessageBanner } from "@scandic-hotels/design-system/MessageBanner"
|
||||
import { trackBedSelection } from "@scandic-hotels/tracking/booking"
|
||||
import {
|
||||
BedTypeEnum,
|
||||
@@ -21,11 +23,20 @@ import styles from "./bedOptions.module.css"
|
||||
import type { IconProps } from "@scandic-hotels/design-system/Icons"
|
||||
|
||||
export default function BedType() {
|
||||
const availableBeds = useEnterDetailsStore((state) => state.availableBeds)
|
||||
const {
|
||||
actions: { updateBedType },
|
||||
room: { bedType, bedTypes },
|
||||
idx,
|
||||
} = useRoomContext()
|
||||
const { addPreSubmitCallback, availableBeds } = useEnterDetailsStore(
|
||||
(state) => ({
|
||||
addPreSubmitCallback: state.actions.addPreSubmitCallback,
|
||||
availableBeds: state.availableBeds,
|
||||
})
|
||||
)
|
||||
const intl = useIntl()
|
||||
const formRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const initialBedType = bedType?.roomTypeCode
|
||||
const [previousBedType, setPreviousBedType] = useState("")
|
||||
|
||||
@@ -56,6 +67,17 @@ export default function BedType() {
|
||||
[bedTypes, updateBedType]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
async function callback() {
|
||||
const isValid = await methods.trigger()
|
||||
if (!isValid && methods.formState.errors.bedType) {
|
||||
return formRef.current ?? undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
addPreSubmitCallback(`${idx}-bedtype`, callback)
|
||||
}, [addPreSubmitCallback, methods, idx])
|
||||
|
||||
const selectedBedType = methods.watch("bedType")
|
||||
const handleSubmit = methods.handleSubmit
|
||||
useEffect(() => {
|
||||
@@ -73,7 +95,19 @@ export default function BedType() {
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.container} ref={formRef}>
|
||||
<div className={styles.errorContainer}>
|
||||
{methods.formState.errors.bedType && (
|
||||
<MessageBanner
|
||||
text={intl.formatMessage({
|
||||
id: "enterDetails.bedType.error.required",
|
||||
defaultMessage: "Bed preference is required",
|
||||
})}
|
||||
type="error"
|
||||
textColor="error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{bedTypes.map((roomType) => {
|
||||
const width =
|
||||
|
||||
@@ -10,3 +10,7 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
width: min(696px, 100%);
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
width: min(696px, 100%);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -10,6 +10,7 @@ import Body from "@scandic-hotels/design-system/Body"
|
||||
import RadioCard from "@scandic-hotels/design-system/Form/RadioCard"
|
||||
import BreakfastBuffetIcon from "@scandic-hotels/design-system/Icons/BreakfastBuffetIcon"
|
||||
import NoBreakfastBuffetIcon from "@scandic-hotels/design-system/Icons/NoBreakfastBuffetIcon"
|
||||
import { MessageBanner } from "@scandic-hotels/design-system/MessageBanner"
|
||||
import { trackBreakfastSelection } from "@scandic-hotels/tracking/booking"
|
||||
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
|
||||
@@ -21,11 +22,16 @@ import styles from "./breakfast.module.css"
|
||||
|
||||
export default function Breakfast() {
|
||||
const intl = useIntl()
|
||||
const formRef = useRef<HTMLDivElement>(null)
|
||||
const packages = useEnterDetailsStore((state) => state.breakfastPackages)
|
||||
const hotelId = useEnterDetailsStore((state) => state.booking.hotelId)
|
||||
const { addPreSubmitCallback } = useEnterDetailsStore((state) => ({
|
||||
addPreSubmitCallback: state.actions.addPreSubmitCallback,
|
||||
}))
|
||||
const {
|
||||
actions: { updateBreakfast },
|
||||
room,
|
||||
idx,
|
||||
} = useRoomContext()
|
||||
|
||||
const hasChildrenInRoom = !!room.childrenInRoom?.length
|
||||
@@ -65,6 +71,19 @@ export default function Breakfast() {
|
||||
[packages, hotelId, room.adults, updateBreakfast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
async function callback() {
|
||||
const isValid = await methods.trigger()
|
||||
if (!isValid && methods.formState.errors.breakfast) {
|
||||
return formRef.current ?? undefined
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
addPreSubmitCallback(`${idx}-breakfast`, callback)
|
||||
}, [addPreSubmitCallback, methods, idx])
|
||||
|
||||
const selectedBreakfast = methods.watch("breakfast")
|
||||
const handleSubmit = methods.handleSubmit
|
||||
useEffect(() => {
|
||||
@@ -75,7 +94,7 @@ export default function Breakfast() {
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.container} ref={formRef}>
|
||||
{hasChildrenInRoom ? (
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
@@ -85,6 +104,19 @@ export default function Breakfast() {
|
||||
})}
|
||||
</Body>
|
||||
) : null}
|
||||
{methods.formState.errors.breakfast && (
|
||||
<div className={styles.errorContainer}>
|
||||
<MessageBanner
|
||||
text={intl.formatMessage({
|
||||
id: "enterDetails.breakfast.error.required",
|
||||
defaultMessage: "Breakfast option is required",
|
||||
})}
|
||||
type="error"
|
||||
textColor="error"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{packages?.map((pkg) => (
|
||||
<RadioCard
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function Details() {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const config = useBookingFlowConfig()
|
||||
const refs = useRef<Record<string, HTMLElement | null>>({})
|
||||
|
||||
const { addPreSubmitCallback, rooms } = useEnterDetailsStore((state) => ({
|
||||
addPreSubmitCallback: state.actions.addPreSubmitCallback,
|
||||
@@ -106,12 +107,32 @@ export default function Details() {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
function callback() {
|
||||
trigger()
|
||||
async function callback() {
|
||||
await trigger()
|
||||
trackFormSubmit()
|
||||
const fieldOrder = [
|
||||
"firstName",
|
||||
"lastName",
|
||||
"countryCode",
|
||||
"email",
|
||||
"phoneNumber",
|
||||
"membershipNo",
|
||||
]
|
||||
for (const name of fieldOrder) {
|
||||
const fieldError =
|
||||
methods.formState.errors[
|
||||
name as keyof typeof methods.formState.errors
|
||||
]
|
||||
if (fieldError && refs.current[name]) {
|
||||
return refs.current[name] ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
addPreSubmitCallback(`${idx}-details`, callback)
|
||||
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit])
|
||||
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit, methods])
|
||||
|
||||
const updateDetailsStore = useCallback(() => {
|
||||
if (isValid) {
|
||||
@@ -188,88 +209,124 @@ export default function Details() {
|
||||
defaultMessage: "Guest information",
|
||||
})}
|
||||
</Footnote>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
id: "common.firstName",
|
||||
defaultMessage: "First name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "lastName",
|
||||
onBlur: updateDetailsStore,
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.firstName = el
|
||||
}}
|
||||
/>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
id: "common.lastName",
|
||||
defaultMessage: "Last name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "firstName",
|
||||
onBlur: updateDetailsStore,
|
||||
}}
|
||||
/>
|
||||
<CountrySelect
|
||||
className={styles.fullWidth}
|
||||
countries={getFormattedCountryList(intl)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
errors.countryCode?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
id: "common.country",
|
||||
defaultMessage: "Country",
|
||||
})}
|
||||
lang={lang}
|
||||
name="countryCode"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<BookingFlowInput
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
id: "common.emailAddress",
|
||||
defaultMessage: "Email address",
|
||||
})}
|
||||
name="email"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<Phone
|
||||
countryLabel={intl.formatMessage({
|
||||
id: "common.countryCode",
|
||||
defaultMessage: "Country code",
|
||||
})}
|
||||
countriesWithTranslatedName={getFormattedCountryList(intl)}
|
||||
defaultCountryCode={getDefaultCountryFromLang(lang)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
errors.phoneNumber?.message
|
||||
)}
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
id: "common.phoneNumber",
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
name="phoneNumber"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
{showMembershipIdInput ? (
|
||||
>
|
||||
<BookingFlowInput
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
id: "common.membershipId",
|
||||
defaultMessage: "Membership ID",
|
||||
id: "common.firstName",
|
||||
defaultMessage: "First name",
|
||||
})}
|
||||
name="membershipNo"
|
||||
type="tel"
|
||||
registerOptions={{ onBlur: updateDetailsStore }}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "lastName",
|
||||
onBlur: updateDetailsStore,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.lastName = el
|
||||
}}
|
||||
>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
id: "common.lastName",
|
||||
defaultMessage: "Last name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "firstName",
|
||||
onBlur: updateDetailsStore,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.countryCode = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
<CountrySelect
|
||||
countries={getFormattedCountryList(intl)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
errors.countryCode?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
id: "common.country",
|
||||
defaultMessage: "Country",
|
||||
})}
|
||||
lang={lang}
|
||||
name="countryCode"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.email = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
id: "common.emailAddress",
|
||||
defaultMessage: "Email address",
|
||||
})}
|
||||
name="email"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.phoneNumber = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
<Phone
|
||||
countryLabel={intl.formatMessage({
|
||||
id: "common.countryCode",
|
||||
defaultMessage: "Country code",
|
||||
})}
|
||||
countriesWithTranslatedName={getFormattedCountryList(intl)}
|
||||
defaultCountryCode={getDefaultCountryFromLang(lang)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
errors.phoneNumber?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
id: "common.phoneNumber",
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
name="phoneNumber"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
{showMembershipIdInput ? (
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.membershipNo = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
id: "common.membershipId",
|
||||
defaultMessage: "Membership ID",
|
||||
})}
|
||||
name="membershipNo"
|
||||
type="tel"
|
||||
registerOptions={{ onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<SpecialRequests registerOptions={{ onBlur: updateDetailsStore }} />
|
||||
</div>
|
||||
|
||||
@@ -23,10 +23,12 @@ export default function Signup({
|
||||
errors,
|
||||
name,
|
||||
registerOptions,
|
||||
refs,
|
||||
}: {
|
||||
errors: FieldErrors
|
||||
name: string
|
||||
registerOptions?: RegisterOptions
|
||||
refs: React.RefObject<Record<string, HTMLElement | null>>
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
@@ -45,15 +47,26 @@ export default function Signup({
|
||||
if (isJoinChecked)
|
||||
return (
|
||||
<div className={styles.additionalFormData}>
|
||||
<BookingFlowInput
|
||||
name="zipCode"
|
||||
label={intl.formatMessage({
|
||||
id: "common.zipCode",
|
||||
defaultMessage: "Zip code",
|
||||
})}
|
||||
registerOptions={{ required: true, ...registerOptions }}
|
||||
/>
|
||||
<div className={styles.dateField}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.zipCode = el
|
||||
}}
|
||||
>
|
||||
<BookingFlowInput
|
||||
name="zipCode"
|
||||
label={intl.formatMessage({
|
||||
id: "common.zipCode",
|
||||
defaultMessage: "Zip code",
|
||||
})}
|
||||
registerOptions={{ required: true, ...registerOptions }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={styles.dateField}
|
||||
ref={(el) => {
|
||||
refs.current.dateOfBirth = el
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<Caption type="bold">
|
||||
<span className={styles.required}>
|
||||
@@ -94,5 +107,13 @@ export default function Signup({
|
||||
|
||||
if (config.enterDetailsMembershipIdInputLocation === "join-card") return null
|
||||
|
||||
return <MembershipNumberInput registerOptions={registerOptions} />
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.membershipNo = el
|
||||
}}
|
||||
>
|
||||
<MembershipNumberInput registerOptions={registerOptions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -40,6 +40,8 @@ type DetailsProps = {
|
||||
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user }: DetailsProps) {
|
||||
const refs = useRef<Record<string, HTMLElement | null>>({})
|
||||
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const config = useBookingFlowConfig()
|
||||
@@ -107,12 +109,36 @@ export default function Details({ user }: DetailsProps) {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
function callback() {
|
||||
trigger()
|
||||
async function callback() {
|
||||
await trigger()
|
||||
trackFormSubmit()
|
||||
const baseFieldOrder = [
|
||||
"firstName",
|
||||
"lastName",
|
||||
"countryCode",
|
||||
"email",
|
||||
"phoneNumber",
|
||||
"membershipNo",
|
||||
]
|
||||
const joinChecked = methods.watch("join")
|
||||
const fieldOrder = joinChecked
|
||||
? [...baseFieldOrder, "zipCode", "dateOfBirth"]
|
||||
: baseFieldOrder
|
||||
for (const name of fieldOrder) {
|
||||
const fieldError =
|
||||
methods.formState.errors[
|
||||
name as keyof typeof methods.formState.errors
|
||||
]
|
||||
if (fieldError && refs.current[name]) {
|
||||
return refs.current[name] ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
addPreSubmitCallback(`${idx}-details`, callback)
|
||||
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit])
|
||||
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit, methods])
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: GuestDetailsSchema) => {
|
||||
@@ -133,12 +159,12 @@ export default function Details({ user }: DetailsProps) {
|
||||
setIncomplete()
|
||||
}
|
||||
}, [
|
||||
handleSubmit,
|
||||
formState.isValid,
|
||||
handleSubmit,
|
||||
onSubmit,
|
||||
setIncomplete,
|
||||
updatePartialGuestData,
|
||||
getValues,
|
||||
setIncomplete,
|
||||
])
|
||||
|
||||
useEffect(updateDetailsStore, [updateDetailsStore])
|
||||
@@ -174,83 +200,114 @@ export default function Details({ user }: DetailsProps) {
|
||||
defaultMessage: "Guest information",
|
||||
})}
|
||||
</Footnote>
|
||||
<BookingFlowInput
|
||||
autoComplete="given-name"
|
||||
label={intl.formatMessage({
|
||||
id: "common.firstName",
|
||||
defaultMessage: "First name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<BookingFlowInput
|
||||
autoComplete="family-name"
|
||||
label={intl.formatMessage({
|
||||
id: "common.lastName",
|
||||
defaultMessage: "Last name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<CountrySelect
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.firstName = el
|
||||
}}
|
||||
>
|
||||
<BookingFlowInput
|
||||
autoComplete="given-name"
|
||||
label={intl.formatMessage({
|
||||
id: "common.firstName",
|
||||
defaultMessage: "First name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.lastName = el
|
||||
}}
|
||||
>
|
||||
<BookingFlowInput
|
||||
autoComplete="family-name"
|
||||
label={intl.formatMessage({
|
||||
id: "common.lastName",
|
||||
defaultMessage: "Last name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.countryCode = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
id: "common.country",
|
||||
defaultMessage: "Country",
|
||||
})}
|
||||
lang={lang}
|
||||
countries={getFormattedCountryList(intl)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
formState.errors.countryCode?.message
|
||||
)}
|
||||
name="countryCode"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
disabled={!!user}
|
||||
/>
|
||||
<BookingFlowInput
|
||||
autoComplete="email"
|
||||
>
|
||||
<CountrySelect
|
||||
label={intl.formatMessage({
|
||||
id: "common.country",
|
||||
defaultMessage: "Country",
|
||||
})}
|
||||
lang={lang}
|
||||
countries={getFormattedCountryList(intl)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
formState.errors.countryCode?.message
|
||||
)}
|
||||
name="countryCode"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
disabled={!!user}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.email = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
id: "common.emailAddress",
|
||||
defaultMessage: "Email address",
|
||||
})}
|
||||
name="email"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<Phone
|
||||
>
|
||||
<BookingFlowInput
|
||||
autoComplete="email"
|
||||
label={intl.formatMessage({
|
||||
id: "common.emailAddress",
|
||||
defaultMessage: "Email address",
|
||||
})}
|
||||
name="email"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.phoneNumber = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
countryLabel={intl.formatMessage({
|
||||
id: "common.countryCode",
|
||||
defaultMessage: "Country code",
|
||||
})}
|
||||
countriesWithTranslatedName={getFormattedCountryList(intl)}
|
||||
defaultCountryCode={getDefaultCountryFromLang(lang)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
formState.errors.phoneNumber?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
id: "common.phoneNumber",
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
name="phoneNumber"
|
||||
disabled={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
>
|
||||
<Phone
|
||||
countryLabel={intl.formatMessage({
|
||||
id: "common.countryCode",
|
||||
defaultMessage: "Country code",
|
||||
})}
|
||||
countriesWithTranslatedName={getFormattedCountryList(intl)}
|
||||
defaultCountryCode={getDefaultCountryFromLang(lang)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
formState.errors.phoneNumber?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
id: "common.phoneNumber",
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
name="phoneNumber"
|
||||
disabled={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
{user ? null : (
|
||||
<div className={styles.fullWidth}>
|
||||
<Signup
|
||||
errors={formState.errors}
|
||||
name="join"
|
||||
registerOptions={{ onBlur: updateDetailsStore }}
|
||||
refs={refs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,15 +95,15 @@ export default function PaymentClient({
|
||||
rooms,
|
||||
totalPrice,
|
||||
isSubmitting,
|
||||
preSubmitCallbacks,
|
||||
setIsSubmitting,
|
||||
runPreSubmitCallbacks,
|
||||
} = useEnterDetailsStore((state) => ({
|
||||
booking: state.booking,
|
||||
rooms: state.rooms,
|
||||
totalPrice: state.totalPrice,
|
||||
preSubmitCallbacks: state.preSubmitCallbacks,
|
||||
isSubmitting: state.isSubmitting,
|
||||
setIsSubmitting: state.actions.setIsSubmitting,
|
||||
runPreSubmitCallbacks: state.actions.runPreSubmitCallbacks,
|
||||
}))
|
||||
|
||||
const bookingMustBeGuaranteed = rooms.some(({ room }, idx) => {
|
||||
@@ -312,35 +312,39 @@ export default function PaymentClient({
|
||||
[hasFlexRates]
|
||||
)
|
||||
|
||||
const scrollToInvalidField = useCallback(async (): Promise<boolean> => {
|
||||
// If any room is not complete/valid, scroll to the first invalid field, this is needed as rooms and other fields are in separate forms
|
||||
|
||||
const invalidField = await runPreSubmitCallbacks()
|
||||
const errorNames = Object.keys(methods.formState.errors)
|
||||
const firstIncompleteRoomIndex = rooms.findIndex((room) => !room.isComplete)
|
||||
|
||||
const scrollToElement = (el: HTMLElement) => {
|
||||
const offset = getTopOffset()
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - offset - 20
|
||||
window.scrollTo({ top, behavior: "smooth" })
|
||||
const input = el.querySelector<HTMLElement>("input")
|
||||
input?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
if (invalidField) {
|
||||
scrollToElement(invalidField)
|
||||
} else if (errorNames.length > 0) {
|
||||
const firstErrorEl = document.querySelector(`[name="${errorNames[0]}"]`)
|
||||
if (firstErrorEl) {
|
||||
scrollToElement(firstErrorEl as HTMLElement)
|
||||
}
|
||||
}
|
||||
|
||||
return firstIncompleteRoomIndex !== -1
|
||||
}, [runPreSubmitCallbacks, rooms, methods.formState.errors, getTopOffset])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: PaymentFormData) => {
|
||||
async (data: PaymentFormData) => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
Object.values(preSubmitCallbacks).forEach((callback) => {
|
||||
callback()
|
||||
})
|
||||
const firstIncompleteRoomIndex = rooms.findIndex(
|
||||
(room) => !room.isComplete
|
||||
)
|
||||
|
||||
// If any room is not complete/valid, scroll to it
|
||||
if (firstIncompleteRoomIndex !== -1) {
|
||||
const roomElement = document.getElementById(
|
||||
`room-${firstIncompleteRoomIndex + 1}`
|
||||
)
|
||||
|
||||
if (!roomElement) {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
const roomElementTop =
|
||||
roomElement.getBoundingClientRect().top + window.scrollY
|
||||
|
||||
window.scrollTo({
|
||||
top: roomElementTop - getTopOffset() - 20,
|
||||
behavior: "smooth",
|
||||
})
|
||||
|
||||
const isRoomInvalid = await scrollToInvalidField()
|
||||
if (isRoomInvalid) {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -502,13 +506,11 @@ export default function PaymentClient({
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
initiateBooking.mutate(payload)
|
||||
},
|
||||
[
|
||||
setIsSubmitting,
|
||||
preSubmitCallbacks,
|
||||
rooms,
|
||||
scrollToInvalidField,
|
||||
getPaymentMethod,
|
||||
savedCreditCards,
|
||||
lang,
|
||||
@@ -517,8 +519,8 @@ export default function PaymentClient({
|
||||
fromDate,
|
||||
toDate,
|
||||
hotelId,
|
||||
rooms,
|
||||
initiateBooking,
|
||||
getTopOffset,
|
||||
isUserLoggedIn,
|
||||
booking.rooms,
|
||||
user?.data?.partnerLoyaltyNumber,
|
||||
@@ -534,6 +536,13 @@ export default function PaymentClient({
|
||||
defaultMessage: "Select payment method",
|
||||
})
|
||||
|
||||
const handleInvalidSubmit = async () => {
|
||||
const valid = await methods.trigger()
|
||||
if (!valid) {
|
||||
await scrollToInvalidField()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cx(styles.paymentSection, {
|
||||
@@ -549,7 +558,7 @@ export default function PaymentClient({
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
onSubmit={methods.handleSubmit(handleSubmit, handleInvalidSubmit)}
|
||||
id={formId}
|
||||
>
|
||||
{booking.searchType === SEARCH_TYPE_REDEMPTION ? (
|
||||
|
||||
@@ -85,9 +85,9 @@ export function RoomSidePeekContent({ room }: RoomSidePeekContentProps) {
|
||||
<ul className={styles.facilityList}>
|
||||
{[...room.roomFacilities]
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((facility) => {
|
||||
.map((facility, index) => {
|
||||
return (
|
||||
<li key={facility.name}>
|
||||
<li key={`${facility.name}-${index}`}>
|
||||
<FacilityIcon
|
||||
name={facility.icon}
|
||||
size={24}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function createDetailsStore(
|
||||
return total
|
||||
}, {})
|
||||
|
||||
return create<DetailsState>()((set) => ({
|
||||
return create<DetailsState>()((set, get) => ({
|
||||
availableBeds,
|
||||
booking: initialState.booking,
|
||||
roomCategories: initialState.roomCategories,
|
||||
@@ -404,6 +404,47 @@ export function createDetailsStore(
|
||||
})
|
||||
)
|
||||
},
|
||||
|
||||
async runPreSubmitCallbacks(): Promise<HTMLElement | undefined> {
|
||||
const callbacks = get().preSubmitCallbacks
|
||||
const stepOrder = ["bedType", "breakfast", "details"]
|
||||
|
||||
const sortedKeys = Object.keys(callbacks).sort((a, b) => {
|
||||
const [aIdx, aStep] = a.split("-")
|
||||
const [bIdx, bStep] = b.split("-")
|
||||
if (aIdx !== bIdx) return Number(aIdx) - Number(bIdx)
|
||||
return stepOrder.indexOf(aStep) - stepOrder.indexOf(bStep)
|
||||
})
|
||||
|
||||
const roomsMap = new Map<string, string[]>()
|
||||
for (const key of sortedKeys) {
|
||||
const [roomIdx] = key.split("-")
|
||||
if (!roomsMap.has(roomIdx)) {
|
||||
roomsMap.set(roomIdx, [])
|
||||
}
|
||||
roomsMap.get(roomIdx)?.push(key)
|
||||
}
|
||||
|
||||
let firstInvalidElement: HTMLElement | undefined = undefined
|
||||
|
||||
for (const roomIdx of Array.from(roomsMap.keys()).sort(
|
||||
(a, b) => Number(a) - Number(b)
|
||||
)) {
|
||||
const roomKeys = roomsMap.get(roomIdx)!
|
||||
const invalidElementsInRoom: HTMLElement[] = []
|
||||
|
||||
for (const key of roomKeys) {
|
||||
const el = await callbacks[key]()
|
||||
if (el) invalidElementsInRoom.push(el)
|
||||
}
|
||||
|
||||
if (!firstInvalidElement && invalidElementsInRoom.length > 0) {
|
||||
firstInvalidElement = invalidElementsInRoom.at(0)
|
||||
}
|
||||
}
|
||||
|
||||
return firstInvalidElement
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -96,7 +96,11 @@ export interface DetailsState {
|
||||
setIsSubmitting: (isSubmitting: boolean) => void
|
||||
toggleSummaryOpen: () => void
|
||||
updateSeachParamString: (searchParamString: string) => void
|
||||
addPreSubmitCallback: (name: string, callback: () => void) => void
|
||||
addPreSubmitCallback: (
|
||||
name: string,
|
||||
callback: () => Promise<HTMLElement | undefined>
|
||||
) => void
|
||||
runPreSubmitCallbacks: () => Promise<HTMLElement | undefined>
|
||||
}
|
||||
availableBeds: Record<string, number>
|
||||
booking: DetailsBooking
|
||||
@@ -112,7 +116,7 @@ export interface DetailsState {
|
||||
hotelName: string
|
||||
roomCategories: RoomCategories
|
||||
defaultCurrency: CurrencyEnum
|
||||
preSubmitCallbacks: Record<string, () => void>
|
||||
preSubmitCallbacks: Record<string, () => Promise<HTMLElement | undefined>>
|
||||
}
|
||||
|
||||
export type PersistedState = {
|
||||
|
||||
Reference in New Issue
Block a user