Merged in fix/sw-2501-remove-continue-button-light (pull request #1892)

fix(sw-2501): remove the continue buttons on enter details

This removes the continue buttons on the enter details page.

This is only that, not the refactoring of the whole enter details page with changing to one form etc. Since I just didn’t complete that refactor today I decided to do this light variant for now.

A quick explanation is that the continue buttons are removed and instead the form is submitted (meaning saving the form data to the store) on blur on the input elements IF the form is valid. If it’s invalid we change the isComplete flag in the store to false. This will hopefully also fix a bug where you were able to submit old data if the new data is invalid.

When clicking the submit button and a room is incomplete/invalid the browser scrolls to the first invalid room.

Approved-by: Erik Tiekstra
This commit is contained in:
Niclas Edenvin
2025-04-30 08:56:16 +00:00
parent 7070770581
commit 341be43a53
10 changed files with 111 additions and 135 deletions

View File

@@ -1,11 +1,9 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useMemo } from "react"
import { useCallback, useEffect, useMemo } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { useEnterDetailsStore } from "@/stores/enter-details"
import SpecialRequests from "@/components/HotelReservation/EnterDetails/Details/SpecialRequests"
@@ -26,16 +24,12 @@ const formID = "enter-details"
export default function Details() {
const intl = useIntl()
const { canProceedToPayment, lastRoom, rooms } = useEnterDetailsStore(
(state) => ({
canProceedToPayment: state.canProceedToPayment,
lastRoom: state.lastRoom,
rooms: state.rooms,
})
)
const { rooms } = useEnterDetailsStore((state) => ({
rooms: state.rooms,
}))
const {
actions: { updateDetails },
actions: { updateDetails, setIncomplete },
idx,
room,
roomNr,
@@ -58,7 +52,6 @@ export default function Details() {
[idx, rooms]
)
const isPaymentNext = idx === lastRoom
const methods = useForm<MultiroomDetailsSchema>({
criteriaMode: "all",
mode: "all",
@@ -78,6 +71,16 @@ export default function Details() {
},
})
const updateDetailsStore = useCallback(() => {
if (methods.formState.isValid) {
methods.handleSubmit(updateDetails)()
} else {
setIncomplete()
}
}, [methods, setIncomplete, updateDetails])
useEffect(updateDetailsStore, [methods.formState.isValid, updateDetailsStore])
// Trigger validation of the room manually when another room changes its data.
// Only do it if the field has a value, to avoid error states before the user
// has filled anything in.
@@ -125,6 +128,7 @@ export default function Details() {
registerOptions={{
required: true,
deps: "lastName",
onBlur: updateDetailsStore,
}}
/>
<Input
@@ -136,6 +140,7 @@ export default function Details() {
registerOptions={{
required: true,
deps: "firstName",
onBlur: updateDetailsStore,
}}
/>
<CountrySelect
@@ -144,7 +149,7 @@ export default function Details() {
defaultMessage: "Country",
})}
name="countryCode"
registerOptions={{ required: true }}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<Input
className={styles.fullWidth}
@@ -152,7 +157,7 @@ export default function Details() {
defaultMessage: "Email address",
})}
name="email"
registerOptions={{ required: true }}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<Phone
className={styles.fullWidth}
@@ -160,7 +165,7 @@ export default function Details() {
defaultMessage: "Phone number",
})}
name="phoneNumber"
registerOptions={{ required: true }}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
{guestIsGoingToJoin ? null : (
<Input
@@ -170,35 +175,11 @@ export default function Details() {
})}
name="membershipNo"
type="tel"
registerOptions={{ onBlur: updateDetailsStore }}
/>
)}
<SpecialRequests />
<SpecialRequests registerOptions={{ onBlur: updateDetailsStore }} />
</div>
<footer className={styles.footer}>
<Button
isDisabled={
!(
methods.formState.isValid ||
(isPaymentNext && canProceedToPayment)
)
}
variant="Tertiary"
typography="Body/Paragraph/mdBold"
size="Medium"
type="submit"
>
{isPaymentNext
? intl.formatMessage({
defaultMessage: "Continue",
})
: intl.formatMessage(
{
defaultMessage: "Continue to room {nextRoomNumber}",
},
{ nextRoomNumber: roomNr + 1 }
)}
</Button>
</footer>
</form>
</FormProvider>
)

View File

@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { useWatch } from "react-hook-form"
import { type RegisterOptions, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import DateSelect from "@/components/TempDesignSystem/Form/Date"
@@ -10,7 +10,13 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./signup.module.css"
export default function Signup({ name }: { name: string }) {
export default function Signup({
name,
registerOptions,
}: {
name: string
registerOptions?: RegisterOptions
}) {
const intl = useIntl()
const [isJoinChecked, setIsJoinChecked] = useState(false)
@@ -30,7 +36,7 @@ export default function Signup({ name }: { name: string }) {
label={intl.formatMessage({
defaultMessage: "Zip code",
})}
registerOptions={{ required: true }}
registerOptions={{ required: true, ...registerOptions }}
/>
<div className={styles.dateField}>
<header>
@@ -42,7 +48,10 @@ export default function Signup({ name }: { name: string }) {
</span>
</Caption>
</header>
<DateSelect name="dateOfBirth" registerOptions={{ required: true }} />
<DateSelect
name="dateOfBirth"
registerOptions={{ required: true, ...registerOptions }}
/>
</div>
</div>
) : (
@@ -52,6 +61,7 @@ export default function Signup({ name }: { name: string }) {
})}
name="membershipNo"
type="tel"
registerOptions={registerOptions}
/>
)
}

View File

@@ -1,13 +1,9 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback } from "react"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { useEnterDetailsStore } from "@/stores/enter-details"
import SpecialRequests from "@/components/HotelReservation/EnterDetails/Details/SpecialRequests"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
import Input from "@/components/TempDesignSystem/Form/Input"
@@ -30,24 +26,14 @@ const formID = "enter-details"
export default function Details({ user }: DetailsProps) {
const intl = useIntl()
const { canProceedToPayment, lastRoom, isMultiRoom } = useEnterDetailsStore(
(state) => ({
canProceedToPayment: state.canProceedToPayment,
lastRoom: state.lastRoom,
isMultiRoom: state.rooms.length > 1,
})
)
const {
actions: { updateDetails },
idx,
actions: { updateDetails, setIncomplete },
room,
roomNr,
} = useRoomContext()
const initialData = room.guest
const memberRate = "member" in room.roomRate ? room.roomRate.member : null
const isPaymentNext = idx === lastRoom
const showContinueButton = isMultiRoom || !user
const methods = useForm<DetailsSchema>({
criteriaMode: "all",
@@ -78,6 +64,16 @@ export default function Details({ user }: DetailsProps) {
[updateDetails]
)
const updateDetailsStore = useCallback(() => {
if (methods.formState.isValid) {
methods.handleSubmit(onSubmit)()
} else {
setIncomplete()
}
}, [methods, onSubmit, setIncomplete])
useEffect(updateDetailsStore, [methods.formState.isValid, updateDetailsStore])
return (
<FormProvider {...methods}>
<form
@@ -104,7 +100,7 @@ export default function Details({ user }: DetailsProps) {
maxLength={30}
name="firstName"
readOnly={!!user}
registerOptions={{ required: true }}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<Input
label={intl.formatMessage({
@@ -113,7 +109,7 @@ export default function Details({ user }: DetailsProps) {
maxLength={30}
name="lastName"
readOnly={!!user}
registerOptions={{ required: true }}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<CountrySelect
className={styles.fullWidth}
@@ -122,7 +118,7 @@ export default function Details({ user }: DetailsProps) {
})}
name="countryCode"
readOnly={!!user}
registerOptions={{ required: true }}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<Input
className={styles.fullWidth}
@@ -131,7 +127,7 @@ export default function Details({ user }: DetailsProps) {
})}
name="email"
readOnly={!!user}
registerOptions={{ required: true }}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<Phone
className={styles.fullWidth}
@@ -140,42 +136,20 @@ export default function Details({ user }: DetailsProps) {
})}
name="phoneNumber"
readOnly={!!user}
registerOptions={{ required: true }}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
{user ? null : (
<div className={styles.fullWidth}>
<Signup name="join" />
<Signup
name="join"
registerOptions={{ onBlur: updateDetailsStore }}
/>
</div>
)}
<SpecialRequests />
<SpecialRequests
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
</div>
{showContinueButton ? (
<footer className={styles.footer}>
<Button
isDisabled={
!(
methods.formState.isValid ||
(isPaymentNext && canProceedToPayment)
)
}
variant="Tertiary"
typography="Body/Paragraph/mdBold"
size="Medium"
type="submit"
>
{isPaymentNext
? intl.formatMessage({
defaultMessage: "Continue",
})
: intl.formatMessage(
{
defaultMessage: "Continue to room {nextRoomNumber}",
},
{ nextRoomNumber: roomNr + 1 }
)}
</Button>
</footer>
) : null}
</form>
</FormProvider>
)

View File

@@ -6,7 +6,13 @@ import TextArea from "@/components/TempDesignSystem/Form/TextArea"
import styles from "./specialRequests.module.css"
export default function SpecialRequests() {
import type { RegisterOptions } from "react-hook-form"
export default function SpecialRequests({
registerOptions,
}: {
registerOptions?: RegisterOptions
}) {
const intl = useIntl()
return (
@@ -63,6 +69,7 @@ export default function SpecialRequests() {
"Is there anything else you would like us to know before your arrival?",
})}
name="specialRequest.comment"
registerOptions={registerOptions}
/>
</div>
</div>

View File

@@ -33,6 +33,7 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import useLang from "@/hooks/useLang"
import useStickyPosition from "@/hooks/useStickyPosition"
import { trackPaymentEvent } from "@/utils/tracking"
import { trackEvent } from "@/utils/tracking/base"
import { trackGlaSaveCardAttempt } from "@/utils/tracking/myStay"
@@ -71,6 +72,7 @@ export default function PaymentClient({
const lang = useLang()
const intl = useIntl()
const searchParams = useSearchParams()
const { getTopOffset } = useStickyPosition({})
const [showPaymentAlert, setShowPaymentAlert] = useState(false)
@@ -80,8 +82,6 @@ export default function PaymentClient({
totalPrice: state.totalPrice,
}))
const allRoomsComplete = rooms.every((r) => r.isComplete)
const bookingMustBeGuaranteed = rooms.some(({ room }, idx) => {
if (idx === 0 && isUserLoggedIn && room.memberMustBeGuaranteed) {
return true
@@ -279,6 +279,30 @@ export default function PaymentClient({
const handleSubmit = useCallback(
(data: PaymentFormData) => {
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) {
return
}
const roomElementTop =
roomElement.getBoundingClientRect().top + window.scrollY
window.scrollTo({
top: roomElementTop - getTopOffset() - 20,
behavior: "smooth",
})
return
}
const paymentMethod = getPaymentMethod(data.paymentMethod)
const savedCreditCard = savedCreditCards?.find(
@@ -423,6 +447,7 @@ export default function PaymentClient({
hasOnlyFlexRates,
bookingMustBeGuaranteed,
isUserLoggedIn,
getTopOffset,
]
)
@@ -446,9 +471,7 @@ export default function PaymentClient({
})
return (
<section
className={`${styles.paymentSection} ${allRoomsComplete ? "" : styles.disabled}`}
>
<section className={styles.paymentSection}>
<header>
<Title level="h2" as="h4">
{hasOnlyFlexRates && bookingMustBeGuaranteed
@@ -573,9 +596,7 @@ export default function PaymentClient({
theme="base"
size="small"
type="submit"
disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
disabled={methods.formState.isSubmitting}
>
{intl.formatMessage({
defaultMessage: "Complete booking",

View File

@@ -19,8 +19,8 @@ import { StepEnum } from "@/types/enums/step"
export default function Multiroom() {
const intl = useIntl()
const { idx, room, roomNr, steps } = useRoomContext()
const { breakfastPackages, rooms } = useEnterDetailsStore((state) => ({
const { room, roomNr } = useRoomContext()
const { breakfastPackages } = useEnterDetailsStore((state) => ({
breakfastPackages: state.breakfastPackages,
rooms: state.rooms,
}))
@@ -28,22 +28,6 @@ export default function Multiroom() {
const showBreakfastStep =
!room.breakfastIncluded && !!breakfastPackages.length
const arePreviousRoomsValid = rooms.slice(0, idx).every((r) => r.isComplete)
const isBreakfastStepValid = showBreakfastStep
? steps[StepEnum.breakfast]?.isValid
: true
const isBreakfastDisabled = !(
arePreviousRoomsValid && steps[StepEnum.selectBed].isValid
)
const isDetailsDisabled = !(
arePreviousRoomsValid &&
steps[StepEnum.selectBed].isValid &&
isBreakfastStepValid
)
const hasChildWithExtraBed = room.childrenInRoom?.some(
(child) => Number(child.bed) === ChildBedMapEnum.IN_EXTRA_BED
)
@@ -55,7 +39,7 @@ export default function Multiroom() {
)
return (
<section>
<section id={`room-${roomNr}`}>
<Header>
<Title level="h2" as="h4">
{intl.formatMessage(
@@ -77,7 +61,6 @@ export default function Multiroom() {
label={intl.formatMessage({ defaultMessage: "Request bedtype" })}
additionalInfo={bedTypeInfoText}
step={StepEnum.selectBed}
disabled={!arePreviousRoomsValid}
>
<BedType />
</Section>
@@ -92,7 +75,6 @@ export default function Multiroom() {
defaultMessage: "Select breakfast options",
})}
step={StepEnum.breakfast}
disabled={isBreakfastDisabled}
>
<Breakfast />
</Section>
@@ -106,7 +88,6 @@ export default function Multiroom() {
label={intl.formatMessage({
defaultMessage: "Enter your details",
})}
disabled={isDetailsDisabled}
>
<Details />
</Section>

View File

@@ -20,7 +20,7 @@ import type { SafeUser } from "@/types/user"
export default function RoomOne({ user }: { user: SafeUser }) {
const intl = useIntl()
const { room, steps } = useRoomContext()
const { room } = useRoomContext()
const { breakfastPackages, isMultiroom } = useEnterDetailsStore((state) => ({
breakfastPackages: state.breakfastPackages,
isMultiroom: state.rooms.length > 1,
@@ -43,7 +43,7 @@ export default function RoomOne({ user }: { user: SafeUser }) {
!room.breakfastIncluded && !!breakfastPackages.length
return (
<section>
<section id="room-1">
{isMultiroom ? (
<Header>
<Title level="h2" as="h4">
@@ -81,7 +81,6 @@ export default function RoomOne({ user }: { user: SafeUser }) {
defaultMessage: "Select breakfast options",
})}
step={StepEnum.breakfast}
disabled={!steps[StepEnum.selectBed].isValid}
>
<Breakfast />
</Section>
@@ -95,12 +94,6 @@ export default function RoomOne({ user }: { user: SafeUser }) {
label={intl.formatMessage({
defaultMessage: "Enter your details",
})}
disabled={
!(
steps[StepEnum.selectBed].isValid &&
steps[StepEnum.breakfast]?.isValid !== false
)
}
>
<Details user={user} />
</Section>

View File

@@ -167,6 +167,13 @@ export function createDetailsStore(
return {
actions: {
setIncomplete() {
return set(
produce((state: DetailsState) => {
state.rooms[idx].isComplete = false
})
)
},
updateBedType(bedType) {
return set(
produce((state: DetailsState) => {

View File

@@ -5,6 +5,7 @@ import type { RoomState } from "@/types/stores/enter-details"
export interface RoomContextValue {
actions: {
setIncomplete: () => void
updateBedType: (data: BedTypeSchema) => void
updateBreakfast: (data: BreakfastPackage | false) => void
updateJoin: (join: boolean) => void

View File

@@ -60,6 +60,7 @@ export interface Room extends InitialRoomData {
export interface RoomState {
actions: {
setIncomplete: () => void
updateBedType: (data: BedTypeSchema) => void
updateBreakfast: (data: BreakfastPackage | false) => void
updateJoin: (join: boolean) => void