From c473bbc8b011aa3acecf71d4e14febad01f777f0 Mon Sep 17 00:00:00 2001 From: Bianca Widstam Date: Fri, 24 Oct 2025 11:30:56 +0000 Subject: [PATCH] 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 --- .../TempDesignSystem/Form/Input/index.tsx | 12 +- .../lib/components/BookingFlowInput/index.tsx | 12 +- .../BookingFlowInput/input.module.css | 12 - .../BedType/bedOptions.module.css | 4 + .../components/EnterDetails/BedType/index.tsx | 40 +++- .../Breakfast/breakfast.module.css | 4 + .../EnterDetails/Breakfast/index.tsx | 36 ++- .../EnterDetails/Details/Multiroom/index.tsx | 219 +++++++++++------- .../Details/RoomOne/Signup/index.tsx | 41 +++- .../EnterDetails/Details/RoomOne/index.tsx | 205 ++++++++++------ .../EnterDetails/Payment/PaymentClient.tsx | 75 +++--- .../RoomSidePeekContent/index.tsx | 4 +- .../lib/stores/enter-details/index.ts | 43 +++- .../lib/stores/enter-details/types.ts | 8 +- .../Form/Checkbox/checkbox.module.css | 8 - .../lib/components/Form/Checkbox/index.tsx | 20 +- .../Form/Country/country.module.css | 4 + .../components/Form/ErrorMessage/Error.tsx | 7 +- .../MessageBanner/MessageBanner.stories.tsx | 64 +++++ .../lib/components/MessageBanner/index.tsx | 55 +++++ .../MessageBanner/messageBanner.module.css | 21 ++ .../Payment/Icons/DinersClubIcon.tsx | 4 +- .../components/Payment/Icons/DiscoverIcon.tsx | 8 +- .../lib/components/Payment/Icons/JcbIcon.tsx | 42 ++-- .../components/Payment/Icons/SwishIcon.tsx | 28 +-- .../lib/components/Select/select.module.css | 3 + packages/design-system/package.json | 1 + 27 files changed, 692 insertions(+), 288 deletions(-) create mode 100644 packages/design-system/lib/components/MessageBanner/MessageBanner.stories.tsx create mode 100644 packages/design-system/lib/components/MessageBanner/index.tsx create mode 100644 packages/design-system/lib/components/MessageBanner/messageBanner.module.css diff --git a/apps/scandic-web/components/TempDesignSystem/Form/Input/index.tsx b/apps/scandic-web/components/TempDesignSystem/Form/Input/index.tsx index 99406f8da..edf8f0c4b 100644 --- a/apps/scandic-web/components/TempDesignSystem/Form/Input/index.tsx +++ b/apps/scandic-web/components/TempDesignSystem/Form/Input/index.tsx @@ -6,6 +6,7 @@ import { Controller, useFormContext } from "react-hook-form" 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" @@ -35,7 +36,7 @@ const Input = forwardRef(function Input( ref ) { const intl = useIntl() - const { control } = useFormContext() + const { control, formState } = useFormContext() const numberAttributes: HTMLAttributes = {} if (type === "number") { numberAttributes.onWheel = function (evt: WheelEvent) { @@ -87,10 +88,11 @@ const Input = forwardRef(function Input( ) : null} {fieldState.error && !hideError ? ( - - - {getErrorMessage(intl, fieldState.error.message)} - + ) : null} )} diff --git a/packages/booking-flow/lib/components/BookingFlowInput/index.tsx b/packages/booking-flow/lib/components/BookingFlowInput/index.tsx index 92b72dbd1..919378f34 100644 --- a/packages/booking-flow/lib/components/BookingFlowInput/index.tsx +++ b/packages/booking-flow/lib/components/BookingFlowInput/index.tsx @@ -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( ref ) { const intl = useIntl() - const { control } = useFormContext() + const { control, formState } = useFormContext() const config = useBookingFlowConfig() return ( @@ -96,14 +97,15 @@ const BookingFlowInput = forwardRef( ) : null} {fieldState.error && !hideError ? ( - - - {getErrorMessage( + + /> ) : null} )} diff --git a/packages/booking-flow/lib/components/BookingFlowInput/input.module.css b/packages/booking-flow/lib/components/BookingFlowInput/input.module.css index 1917df96f..a9a10d994 100644 --- a/packages/booking-flow/lib/components/BookingFlowInput/input.module.css +++ b/packages/booking-flow/lib/components/BookingFlowInput/input.module.css @@ -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; -} diff --git a/packages/booking-flow/lib/components/EnterDetails/BedType/bedOptions.module.css b/packages/booking-flow/lib/components/EnterDetails/BedType/bedOptions.module.css index 55782c956..8a077c5d7 100644 --- a/packages/booking-flow/lib/components/EnterDetails/BedType/bedOptions.module.css +++ b/packages/booking-flow/lib/components/EnterDetails/BedType/bedOptions.module.css @@ -15,3 +15,7 @@ display: flex; gap: var(--Spacing-x-one-and-half); } + +.errorContainer { + width: min(696px, 100%); +} diff --git a/packages/booking-flow/lib/components/EnterDetails/BedType/index.tsx b/packages/booking-flow/lib/components/EnterDetails/BedType/index.tsx index 6e8f80b75..309f24b51 100644 --- a/packages/booking-flow/lib/components/EnterDetails/BedType/index.tsx +++ b/packages/booking-flow/lib/components/EnterDetails/BedType/index.tsx @@ -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(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 ( -
+
+
+ {methods.formState.errors.bedType && ( + + )} +
{bedTypes.map((roomType) => { const width = diff --git a/packages/booking-flow/lib/components/EnterDetails/Breakfast/breakfast.module.css b/packages/booking-flow/lib/components/EnterDetails/Breakfast/breakfast.module.css index fe73b9458..ed8762e97 100644 --- a/packages/booking-flow/lib/components/EnterDetails/Breakfast/breakfast.module.css +++ b/packages/booking-flow/lib/components/EnterDetails/Breakfast/breakfast.module.css @@ -10,3 +10,7 @@ grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); width: min(696px, 100%); } + +.errorContainer { + width: min(696px, 100%); +} diff --git a/packages/booking-flow/lib/components/EnterDetails/Breakfast/index.tsx b/packages/booking-flow/lib/components/EnterDetails/Breakfast/index.tsx index fbdd4858c..85c094fdd 100644 --- a/packages/booking-flow/lib/components/EnterDetails/Breakfast/index.tsx +++ b/packages/booking-flow/lib/components/EnterDetails/Breakfast/index.tsx @@ -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(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 ( -
+
{hasChildrenInRoom ? ( {intl.formatMessage({ @@ -85,6 +104,19 @@ export default function Breakfast() { })} ) : null} + {methods.formState.errors.breakfast && ( +
+ +
+ )} + {packages?.map((pkg) => ( >({}) 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", })} - { + refs.current.firstName = el }} - /> - - - - - {showMembershipIdInput ? ( + > +
+
{ + refs.current.lastName = el + }} + > + +
+
{ + refs.current.countryCode = el + }} + className={styles.fullWidth} + > + +
+
{ + refs.current.email = el + }} + className={styles.fullWidth} + > + +
+
{ + refs.current.phoneNumber = el + }} + className={styles.fullWidth} + > + +
+ {showMembershipIdInput ? ( +
{ + refs.current.membershipNo = el + }} + className={styles.fullWidth} + > + +
) : null}
diff --git a/packages/booking-flow/lib/components/EnterDetails/Details/RoomOne/Signup/index.tsx b/packages/booking-flow/lib/components/EnterDetails/Details/RoomOne/Signup/index.tsx index 6833bbdf3..618dd42ea 100644 --- a/packages/booking-flow/lib/components/EnterDetails/Details/RoomOne/Signup/index.tsx +++ b/packages/booking-flow/lib/components/EnterDetails/Details/RoomOne/Signup/index.tsx @@ -23,10 +23,12 @@ export default function Signup({ errors, name, registerOptions, + refs, }: { errors: FieldErrors name: string registerOptions?: RegisterOptions + refs: React.RefObject> }) { const intl = useIntl() const lang = useLang() @@ -45,15 +47,26 @@ export default function Signup({ if (isJoinChecked) return (
- -
+
{ + refs.current.zipCode = el + }} + > + +
+
{ + refs.current.dateOfBirth = el + }} + >
@@ -94,5 +107,13 @@ export default function Signup({ if (config.enterDetailsMembershipIdInputLocation === "join-card") return null - return + return ( +
{ + refs.current.membershipNo = el + }} + > + +
+ ) } diff --git a/packages/booking-flow/lib/components/EnterDetails/Details/RoomOne/index.tsx b/packages/booking-flow/lib/components/EnterDetails/Details/RoomOne/index.tsx index c048b2497..dc5259d63 100644 --- a/packages/booking-flow/lib/components/EnterDetails/Details/RoomOne/index.tsx +++ b/packages/booking-flow/lib/components/EnterDetails/Details/RoomOne/index.tsx @@ -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>({}) + 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", })} - - - { + refs.current.firstName = el + }} + > + +
+
{ + refs.current.lastName = 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} - /> - + +
+
{ + 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 }} - /> - + +
+
{ + 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 }} - /> + > + +
{user ? null : (
)} diff --git a/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx b/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx index ddbdb948f..822b774e5 100644 --- a/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx +++ b/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx @@ -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 => { + // 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("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 (
{booking.searchType === SEARCH_TYPE_REDEMPTION ? ( diff --git a/packages/booking-flow/lib/components/RoomDetailsSidePeek/RoomSidePeekContent/index.tsx b/packages/booking-flow/lib/components/RoomDetailsSidePeek/RoomSidePeekContent/index.tsx index 17aa739b7..5e754d8ae 100644 --- a/packages/booking-flow/lib/components/RoomDetailsSidePeek/RoomSidePeekContent/index.tsx +++ b/packages/booking-flow/lib/components/RoomDetailsSidePeek/RoomSidePeekContent/index.tsx @@ -85,9 +85,9 @@ export function RoomSidePeekContent({ room }: RoomSidePeekContentProps) {
    {[...room.roomFacilities] .sort((a, b) => a.sortOrder - b.sortOrder) - .map((facility) => { + .map((facility, index) => { return ( -
  • +
  • ()((set) => ({ + return create()((set, get) => ({ availableBeds, booking: initialState.booking, roomCategories: initialState.roomCategories, @@ -404,6 +404,47 @@ export function createDetailsStore( }) ) }, + + async runPreSubmitCallbacks(): Promise { + 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() + 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 + }, }, })) } diff --git a/packages/booking-flow/lib/stores/enter-details/types.ts b/packages/booking-flow/lib/stores/enter-details/types.ts index dc75c1e83..3a252e674 100644 --- a/packages/booking-flow/lib/stores/enter-details/types.ts +++ b/packages/booking-flow/lib/stores/enter-details/types.ts @@ -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 + ) => void + runPreSubmitCallbacks: () => Promise } availableBeds: Record booking: DetailsBooking @@ -112,7 +116,7 @@ export interface DetailsState { hotelName: string roomCategories: RoomCategories defaultCurrency: CurrencyEnum - preSubmitCallbacks: Record void> + preSubmitCallbacks: Record Promise> } export type PersistedState = { diff --git a/packages/design-system/lib/components/Form/Checkbox/checkbox.module.css b/packages/design-system/lib/components/Form/Checkbox/checkbox.module.css index 8a49bc487..bafe6b46f 100644 --- a/packages/design-system/lib/components/Form/Checkbox/checkbox.module.css +++ b/packages/design-system/lib/components/Form/Checkbox/checkbox.module.css @@ -38,11 +38,3 @@ .topAlign { align-items: flex-start; } - -.error { - align-items: center; - color: var(--Scandic-Red-60); - display: flex; - gap: var(--Spacing-x-half); - margin: var(--Spacing-x1) 0 0; -} diff --git a/packages/design-system/lib/components/Form/Checkbox/index.tsx b/packages/design-system/lib/components/Form/Checkbox/index.tsx index c9f99adc9..6b50779d0 100644 --- a/packages/design-system/lib/components/Form/Checkbox/index.tsx +++ b/packages/design-system/lib/components/Form/Checkbox/index.tsx @@ -10,7 +10,7 @@ import { import styles from './checkbox.module.css' import { MaterialIcon } from '../../Icons/MaterialIcon' -import Caption from '../../Caption' +import { ErrorMessage } from '../ErrorMessage' interface CheckboxProps extends React.InputHTMLAttributes { name: string @@ -36,7 +36,7 @@ const Checkbox = forwardRef< ref ) { const { control } = useFormContext() - const { field, fieldState } = useController({ + const { field, fieldState, formState } = useController({ control, name, rules: registerOptions, @@ -48,6 +48,7 @@ const Checkbox = forwardRef< isSelected={field.value} onChange={field.onChange} data-testid={name} + name={name} isDisabled={registerOptions?.disabled} excludeFromTabOrder > @@ -68,12 +69,15 @@ const Checkbox = forwardRef< {children} {fieldState.error && !hideError ? ( - - - {(fieldState.error.message && - errorCodeMessages?.[fieldState.error.message]) || - fieldState.error.message} - + ) : null} )} diff --git a/packages/design-system/lib/components/Form/Country/country.module.css b/packages/design-system/lib/components/Form/Country/country.module.css index 5152f0be4..33a60f066 100644 --- a/packages/design-system/lib/components/Form/Country/country.module.css +++ b/packages/design-system/lib/components/Form/Country/country.module.css @@ -52,6 +52,10 @@ &[data-invalid] { border-color: var(--Border-Interactive-Error); } + + &[data-invalid][data-focused] { + outline: 2px solid var(--Border-Interactive-Error); + } } .inner { diff --git a/packages/design-system/lib/components/Form/ErrorMessage/Error.tsx b/packages/design-system/lib/components/Form/ErrorMessage/Error.tsx index 74de0b748..1fb4fc8cf 100644 --- a/packages/design-system/lib/components/Form/ErrorMessage/Error.tsx +++ b/packages/design-system/lib/components/Form/ErrorMessage/Error.tsx @@ -10,7 +10,12 @@ export function Error({ children }: React.PropsWithChildren) { variant="Body/Supporting text (caption)/smRegular" > - + {children} diff --git a/packages/design-system/lib/components/MessageBanner/MessageBanner.stories.tsx b/packages/design-system/lib/components/MessageBanner/MessageBanner.stories.tsx new file mode 100644 index 000000000..ce83901ed --- /dev/null +++ b/packages/design-system/lib/components/MessageBanner/MessageBanner.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { MessageBanner } from './index' + +type MessageBannerType = 'default' | 'error' | 'info' +type TextColor = 'default' | 'error' + +const meta: Meta = { + title: 'Components/MessageBanner', + component: MessageBanner, + argTypes: { + type: { + control: { type: 'select' }, + options: ['default', 'error', 'info'] as MessageBannerType[], + }, + textColor: { + control: { type: 'select' }, + options: ['default', 'error'] as TextColor[], + }, + text: { control: 'text' }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + type: 'default', + textColor: 'default', + text: 'This is a default message', + }, +} + +export const Warning: Story = { + args: { + type: 'error', + textColor: 'default', + text: 'This is a warning message', + }, +} + +export const WarningErrorText: Story = { + args: { + type: 'error', + textColor: 'error', + text: 'Warning with error text color', + }, +} + +export const Info: Story = { + args: { + type: 'info', + textColor: 'default', + text: 'This is an info message', + }, +} + +export const InfoErrorText: Story = { + args: { + type: 'info', + textColor: 'error', + text: 'Info with error text color', + }, +} diff --git a/packages/design-system/lib/components/MessageBanner/index.tsx b/packages/design-system/lib/components/MessageBanner/index.tsx new file mode 100644 index 000000000..7b1425fa6 --- /dev/null +++ b/packages/design-system/lib/components/MessageBanner/index.tsx @@ -0,0 +1,55 @@ +import { cva } from 'class-variance-authority' +import styles from './messageBanner.module.css' +import { Typography } from '../Typography' +import { MaterialIcon } from '../Icons/MaterialIcon' + +type MessageBannerType = 'default' | 'error' | 'info' +type TextColor = 'default' | 'error' + +const textVariants = cva('', { + variants: { + textColor: { + default: styles.textDefault, + error: styles.textError, + }, + }, + defaultVariants: { + textColor: 'default', + }, +}) + +type MessageBannerProps = { + type?: MessageBannerType + textColor?: TextColor + text: string +} + +export function MessageBanner({ + type = 'default', + textColor = 'default', + text, +}: MessageBannerProps) { + const textClass = textVariants({ textColor }) + + const iconName = type === 'error' ? 'error' : 'info' + const iconColor = + type === 'error' + ? 'Icon/Feedback/Error' + : type === 'info' + ? 'Icon/Feedback/Information' + : 'Icon/Default' + + return ( +
    + + + + {text} + + +
    + ) +} diff --git a/packages/design-system/lib/components/MessageBanner/messageBanner.module.css b/packages/design-system/lib/components/MessageBanner/messageBanner.module.css new file mode 100644 index 000000000..416a7cb7f --- /dev/null +++ b/packages/design-system/lib/components/MessageBanner/messageBanner.module.css @@ -0,0 +1,21 @@ +.container { + display: flex; + padding: var(--Space-x15); + background-color: var(--Surface-Primary-Default); + border-radius: var(--Corner-radius-md); + border: 1px solid var(--Border-Default); +} + +.content { + display: flex; + align-items: center; + gap: var(--Space-x1); +} + +.textDefault { + color: var(--Text-Default); +} + +.textError { + color: var(--Text-Feedback-Error-Accent); +} diff --git a/packages/design-system/lib/components/Payment/Icons/DinersClubIcon.tsx b/packages/design-system/lib/components/Payment/Icons/DinersClubIcon.tsx index 6dfd2ac99..c0a346069 100644 --- a/packages/design-system/lib/components/Payment/Icons/DinersClubIcon.tsx +++ b/packages/design-system/lib/components/Payment/Icons/DinersClubIcon.tsx @@ -36,8 +36,8 @@ export const DinersClubIcon = (props: PaymentIconProps) => ( y2="21.6" gradientUnits="userSpaceOnUse" > - - + + diff --git a/packages/design-system/lib/components/Payment/Icons/DiscoverIcon.tsx b/packages/design-system/lib/components/Payment/Icons/DiscoverIcon.tsx index ab97bd650..98ed668a6 100644 --- a/packages/design-system/lib/components/Payment/Icons/DiscoverIcon.tsx +++ b/packages/design-system/lib/components/Payment/Icons/DiscoverIcon.tsx @@ -89,10 +89,10 @@ export const DiscoverIcon = (props: PaymentIconProps) => ( gradientUnits="userSpaceOnUse" gradientTransform="translate(28.6 17.5998) rotate(-142.431) scale(6.56048 6.47264)" > - - - - + + + + diff --git a/packages/design-system/lib/components/Payment/Icons/JcbIcon.tsx b/packages/design-system/lib/components/Payment/Icons/JcbIcon.tsx index b07691a02..cdca7cd30 100644 --- a/packages/design-system/lib/components/Payment/Icons/JcbIcon.tsx +++ b/packages/design-system/lib/components/Payment/Icons/JcbIcon.tsx @@ -49,10 +49,10 @@ export const JcbIcon = (props: PaymentIconProps) => ( y2="16.0328" gradientUnits="userSpaceOnUse" > - - - - + + + + ( y2="16.001" gradientUnits="userSpaceOnUse" > - - - - + + + + ( y2="14.4771" gradientUnits="userSpaceOnUse" > - - - - + + + + ( y2="16.001" gradientUnits="userSpaceOnUse" > - - - - - + + + + + ( y2="16.001" gradientUnits="userSpaceOnUse" > - - - - + + + + diff --git a/packages/design-system/lib/components/Payment/Icons/SwishIcon.tsx b/packages/design-system/lib/components/Payment/Icons/SwishIcon.tsx index f22d798df..e868b6e2d 100644 --- a/packages/design-system/lib/components/Payment/Icons/SwishIcon.tsx +++ b/packages/design-system/lib/components/Payment/Icons/SwishIcon.tsx @@ -61,8 +61,8 @@ export const SwishIcon = (props: PaymentIconProps) => { y2="13.9987" gradientUnits="userSpaceOnUse" > - - + + { y2="21.8844" gradientUnits="userSpaceOnUse" > - - - - + + + + { y2="18.0191" gradientUnits="userSpaceOnUse" > - - - - + + + + { y2="10.1074" gradientUnits="userSpaceOnUse" > - - - - + + + + diff --git a/packages/design-system/lib/components/Select/select.module.css b/packages/design-system/lib/components/Select/select.module.css index 36135afbb..865c01e9a 100644 --- a/packages/design-system/lib/components/Select/select.module.css +++ b/packages/design-system/lib/components/Select/select.module.css @@ -52,6 +52,9 @@ &[data-invalid] { border-color: var(--Border-Interactive-Error); } + &[data-invalid][data-focused] { + outline: 2px solid var(--Border-Interactive-Error); + } } .chevron { diff --git a/packages/design-system/package.json b/packages/design-system/package.json index a8e4eb662..a2d22d71f 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -147,6 +147,7 @@ "./Map/Markers/HotelMarkerByType": "./lib/components/Map/Markers/HotelMarkerByType.tsx", "./Map/Markers/PoiMarker": "./lib/components/Map/Markers/PoiMarker/index.tsx", "./Map/types": "./lib/components/Map/types.ts", + "./MessageBanner": "./lib/components/MessageBanner/index.tsx", "./Modal": "./lib/components/Modal/index.tsx", "./Modal/ModalContentWithActions": "./lib/components/Modal/ModalContentWithActions/index.tsx", "./NoRateAvailableCard": "./lib/components/RateCard/NoRateAvailable/index.tsx",