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:
Bianca Widstam
2025-10-24 11:30:56 +00:00
parent 6543ca5dc3
commit c473bbc8b0
27 changed files with 692 additions and 288 deletions

View File

@@ -6,6 +6,7 @@ import { Controller, useFormContext } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption" 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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input" import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input"
@@ -35,7 +36,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
ref ref
) { ) {
const intl = useIntl() const intl = useIntl()
const { control } = useFormContext() const { control, formState } = useFormContext()
const numberAttributes: HTMLAttributes<HTMLInputElement> = {} const numberAttributes: HTMLAttributes<HTMLInputElement> = {}
if (type === "number") { if (type === "number") {
numberAttributes.onWheel = function (evt: WheelEvent<HTMLInputElement>) { numberAttributes.onWheel = function (evt: WheelEvent<HTMLInputElement>) {
@@ -87,10 +88,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
</Caption> </Caption>
) : null} ) : null}
{fieldState.error && !hideError ? ( {fieldState.error && !hideError ? (
<Caption className={styles.error} fontOnly> <ErrorMessage
<MaterialIcon icon="info" color="Icon/Feedback/Error" /> errors={formState.errors}
{getErrorMessage(intl, fieldState.error.message)} name={name}
</Caption> messageLabel={getErrorMessage(intl, fieldState.error.message)}
/>
) : null} ) : null}
</TextField> </TextField>
)} )}

View File

@@ -14,6 +14,7 @@ import {
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption" 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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input" import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input"
@@ -51,7 +52,7 @@ const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
ref ref
) { ) {
const intl = useIntl() const intl = useIntl()
const { control } = useFormContext() const { control, formState } = useFormContext()
const config = useBookingFlowConfig() const config = useBookingFlowConfig()
return ( return (
@@ -96,14 +97,15 @@ const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
</Caption> </Caption>
) : null} ) : null}
{fieldState.error && !hideError ? ( {fieldState.error && !hideError ? (
<Caption className={styles.error} fontOnly> <ErrorMessage
<MaterialIcon icon="info" color="Icon/Feedback/Error" /> errors={formState.errors}
{getErrorMessage( name={name}
messageLabel={getErrorMessage(
intl, intl,
config.variant, config.variant,
fieldState.error.message fieldState.error.message
)} )}
</Caption> />
) : null} ) : null}
</TextField> </TextField>
)} )}

View File

@@ -3,15 +3,3 @@
display: flex; display: flex;
gap: var(--Space-x05); 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;
}

View File

@@ -15,3 +15,7 @@
display: flex; display: flex;
gap: var(--Spacing-x-one-and-half); gap: var(--Spacing-x-one-and-half);
} }
.errorContainer {
width: min(696px, 100%);
}

View File

@@ -1,10 +1,12 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" 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 { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import RadioCard from "@scandic-hotels/design-system/Form/RadioCard" 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 { trackBedSelection } from "@scandic-hotels/tracking/booking"
import { import {
BedTypeEnum, BedTypeEnum,
@@ -21,11 +23,20 @@ import styles from "./bedOptions.module.css"
import type { IconProps } from "@scandic-hotels/design-system/Icons" import type { IconProps } from "@scandic-hotels/design-system/Icons"
export default function BedType() { export default function BedType() {
const availableBeds = useEnterDetailsStore((state) => state.availableBeds)
const { const {
actions: { updateBedType }, actions: { updateBedType },
room: { bedType, bedTypes }, room: { bedType, bedTypes },
idx,
} = useRoomContext() } = 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 initialBedType = bedType?.roomTypeCode
const [previousBedType, setPreviousBedType] = useState("") const [previousBedType, setPreviousBedType] = useState("")
@@ -56,6 +67,17 @@ export default function BedType() {
[bedTypes, updateBedType] [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 selectedBedType = methods.watch("bedType")
const handleSubmit = methods.handleSubmit const handleSubmit = methods.handleSubmit
useEffect(() => { useEffect(() => {
@@ -73,7 +95,19 @@ export default function BedType() {
return ( return (
<FormProvider {...methods}> <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)}> <form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
{bedTypes.map((roomType) => { {bedTypes.map((roomType) => {
const width = const width =

View File

@@ -10,3 +10,7 @@
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
width: min(696px, 100%); width: min(696px, 100%);
} }
.errorContainer {
width: min(696px, 100%);
}

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" 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 { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" 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 RadioCard from "@scandic-hotels/design-system/Form/RadioCard"
import BreakfastBuffetIcon from "@scandic-hotels/design-system/Icons/BreakfastBuffetIcon" import BreakfastBuffetIcon from "@scandic-hotels/design-system/Icons/BreakfastBuffetIcon"
import NoBreakfastBuffetIcon from "@scandic-hotels/design-system/Icons/NoBreakfastBuffetIcon" 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 { trackBreakfastSelection } from "@scandic-hotels/tracking/booking"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast" import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
@@ -21,11 +22,16 @@ import styles from "./breakfast.module.css"
export default function Breakfast() { export default function Breakfast() {
const intl = useIntl() const intl = useIntl()
const formRef = useRef<HTMLDivElement>(null)
const packages = useEnterDetailsStore((state) => state.breakfastPackages) const packages = useEnterDetailsStore((state) => state.breakfastPackages)
const hotelId = useEnterDetailsStore((state) => state.booking.hotelId) const hotelId = useEnterDetailsStore((state) => state.booking.hotelId)
const { addPreSubmitCallback } = useEnterDetailsStore((state) => ({
addPreSubmitCallback: state.actions.addPreSubmitCallback,
}))
const { const {
actions: { updateBreakfast }, actions: { updateBreakfast },
room, room,
idx,
} = useRoomContext() } = useRoomContext()
const hasChildrenInRoom = !!room.childrenInRoom?.length const hasChildrenInRoom = !!room.childrenInRoom?.length
@@ -65,6 +71,19 @@ export default function Breakfast() {
[packages, hotelId, room.adults, updateBreakfast] [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 selectedBreakfast = methods.watch("breakfast")
const handleSubmit = methods.handleSubmit const handleSubmit = methods.handleSubmit
useEffect(() => { useEffect(() => {
@@ -75,7 +94,7 @@ export default function Breakfast() {
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<div className={styles.container}> <div className={styles.container} ref={formRef}>
{hasChildrenInRoom ? ( {hasChildrenInRoom ? (
<Body> <Body>
{intl.formatMessage({ {intl.formatMessage({
@@ -85,6 +104,19 @@ export default function Breakfast() {
})} })}
</Body> </Body>
) : null} ) : 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)}> <form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
{packages?.map((pkg) => ( {packages?.map((pkg) => (
<RadioCard <RadioCard

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" 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 { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
@@ -32,6 +32,7 @@ export default function Details() {
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
const config = useBookingFlowConfig() const config = useBookingFlowConfig()
const refs = useRef<Record<string, HTMLElement | null>>({})
const { addPreSubmitCallback, rooms } = useEnterDetailsStore((state) => ({ const { addPreSubmitCallback, rooms } = useEnterDetailsStore((state) => ({
addPreSubmitCallback: state.actions.addPreSubmitCallback, addPreSubmitCallback: state.actions.addPreSubmitCallback,
@@ -106,12 +107,32 @@ export default function Details() {
) )
useEffect(() => { useEffect(() => {
function callback() { async function callback() {
trigger() await trigger()
trackFormSubmit() 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}-details`, callback)
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit]) }, [addPreSubmitCallback, idx, trigger, trackFormSubmit, methods])
const updateDetailsStore = useCallback(() => { const updateDetailsStore = useCallback(() => {
if (isValid) { if (isValid) {
@@ -188,88 +209,124 @@ export default function Details() {
defaultMessage: "Guest information", defaultMessage: "Guest information",
})} })}
</Footnote> </Footnote>
<BookingFlowInput <div
label={intl.formatMessage({ ref={(el) => {
id: "common.firstName", refs.current.firstName = el
defaultMessage: "First name",
})}
maxLength={30}
name="firstName"
registerOptions={{
required: true,
deps: "lastName",
onBlur: updateDetailsStore,
}} }}
/> >
<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 <BookingFlowInput
className={styles.fullWidth}
label={intl.formatMessage({ label={intl.formatMessage({
id: "common.membershipId", id: "common.firstName",
defaultMessage: "Membership ID", defaultMessage: "First name",
})} })}
name="membershipNo" maxLength={30}
type="tel" name="firstName"
registerOptions={{ onBlur: updateDetailsStore }} 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} ) : null}
<SpecialRequests registerOptions={{ onBlur: updateDetailsStore }} /> <SpecialRequests registerOptions={{ onBlur: updateDetailsStore }} />
</div> </div>

View File

@@ -23,10 +23,12 @@ export default function Signup({
errors, errors,
name, name,
registerOptions, registerOptions,
refs,
}: { }: {
errors: FieldErrors errors: FieldErrors
name: string name: string
registerOptions?: RegisterOptions registerOptions?: RegisterOptions
refs: React.RefObject<Record<string, HTMLElement | null>>
}) { }) {
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
@@ -45,15 +47,26 @@ export default function Signup({
if (isJoinChecked) if (isJoinChecked)
return ( return (
<div className={styles.additionalFormData}> <div className={styles.additionalFormData}>
<BookingFlowInput <div
name="zipCode" ref={(el) => {
label={intl.formatMessage({ refs.current.zipCode = el
id: "common.zipCode", }}
defaultMessage: "Zip code", >
})} <BookingFlowInput
registerOptions={{ required: true, ...registerOptions }} name="zipCode"
/> label={intl.formatMessage({
<div className={styles.dateField}> id: "common.zipCode",
defaultMessage: "Zip code",
})}
registerOptions={{ required: true, ...registerOptions }}
/>
</div>
<div
className={styles.dateField}
ref={(el) => {
refs.current.dateOfBirth = el
}}
>
<header> <header>
<Caption type="bold"> <Caption type="bold">
<span className={styles.required}> <span className={styles.required}>
@@ -94,5 +107,13 @@ export default function Signup({
if (config.enterDetailsMembershipIdInputLocation === "join-card") return null if (config.enterDetailsMembershipIdInputLocation === "join-card") return null
return <MembershipNumberInput registerOptions={registerOptions} /> return (
<div
ref={(el) => {
refs.current.membershipNo = el
}}
>
<MembershipNumberInput registerOptions={registerOptions} />
</div>
)
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" 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 { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
@@ -40,6 +40,8 @@ type DetailsProps = {
const formID = "enter-details" const formID = "enter-details"
export default function Details({ user }: DetailsProps) { export default function Details({ user }: DetailsProps) {
const refs = useRef<Record<string, HTMLElement | null>>({})
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
const config = useBookingFlowConfig() const config = useBookingFlowConfig()
@@ -107,12 +109,36 @@ export default function Details({ user }: DetailsProps) {
) )
useEffect(() => { useEffect(() => {
function callback() { async function callback() {
trigger() await trigger()
trackFormSubmit() 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}-details`, callback)
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit]) }, [addPreSubmitCallback, idx, trigger, trackFormSubmit, methods])
const onSubmit = useCallback( const onSubmit = useCallback(
(values: GuestDetailsSchema) => { (values: GuestDetailsSchema) => {
@@ -133,12 +159,12 @@ export default function Details({ user }: DetailsProps) {
setIncomplete() setIncomplete()
} }
}, [ }, [
handleSubmit,
formState.isValid, formState.isValid,
handleSubmit,
onSubmit, onSubmit,
setIncomplete,
updatePartialGuestData, updatePartialGuestData,
getValues, getValues,
setIncomplete,
]) ])
useEffect(updateDetailsStore, [updateDetailsStore]) useEffect(updateDetailsStore, [updateDetailsStore])
@@ -174,83 +200,114 @@ export default function Details({ user }: DetailsProps) {
defaultMessage: "Guest information", defaultMessage: "Guest information",
})} })}
</Footnote> </Footnote>
<BookingFlowInput <div
autoComplete="given-name" ref={(el) => {
label={intl.formatMessage({ refs.current.firstName = el
id: "common.firstName", }}
defaultMessage: "First name", >
})} <BookingFlowInput
maxLength={30} autoComplete="given-name"
name="firstName" label={intl.formatMessage({
readOnly={!!user} id: "common.firstName",
registerOptions={{ required: true, onBlur: updateDetailsStore }} defaultMessage: "First name",
/> })}
<BookingFlowInput maxLength={30}
autoComplete="family-name" name="firstName"
label={intl.formatMessage({ readOnly={!!user}
id: "common.lastName", registerOptions={{ required: true, onBlur: updateDetailsStore }}
defaultMessage: "Last name", />
})} </div>
maxLength={30} <div
name="lastName" ref={(el) => {
readOnly={!!user} refs.current.lastName = el
registerOptions={{ required: true, onBlur: updateDetailsStore }} }}
/> >
<CountrySelect <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} className={styles.fullWidth}
label={intl.formatMessage({ >
id: "common.country", <CountrySelect
defaultMessage: "Country", label={intl.formatMessage({
})} id: "common.country",
lang={lang} defaultMessage: "Country",
countries={getFormattedCountryList(intl)} })}
errorMessage={getErrorMessage( lang={lang}
intl, countries={getFormattedCountryList(intl)}
config.variant, errorMessage={getErrorMessage(
formState.errors.countryCode?.message intl,
)} config.variant,
name="countryCode" formState.errors.countryCode?.message
registerOptions={{ required: true, onBlur: updateDetailsStore }} )}
disabled={!!user} name="countryCode"
/> registerOptions={{ required: true, onBlur: updateDetailsStore }}
<BookingFlowInput disabled={!!user}
autoComplete="email" />
</div>
<div
ref={(el) => {
refs.current.email = el
}}
className={styles.fullWidth} className={styles.fullWidth}
label={intl.formatMessage({ >
id: "common.emailAddress", <BookingFlowInput
defaultMessage: "Email address", autoComplete="email"
})} label={intl.formatMessage({
name="email" id: "common.emailAddress",
readOnly={!!user} defaultMessage: "Email address",
registerOptions={{ required: true, onBlur: updateDetailsStore }} })}
/> name="email"
<Phone readOnly={!!user}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
</div>
<div
ref={(el) => {
refs.current.phoneNumber = el
}}
className={styles.fullWidth} className={styles.fullWidth}
countryLabel={intl.formatMessage({ >
id: "common.countryCode", <Phone
defaultMessage: "Country code", countryLabel={intl.formatMessage({
})} id: "common.countryCode",
countriesWithTranslatedName={getFormattedCountryList(intl)} defaultMessage: "Country code",
defaultCountryCode={getDefaultCountryFromLang(lang)} })}
errorMessage={getErrorMessage( countriesWithTranslatedName={getFormattedCountryList(intl)}
intl, defaultCountryCode={getDefaultCountryFromLang(lang)}
config.variant, errorMessage={getErrorMessage(
formState.errors.phoneNumber?.message intl,
)} config.variant,
label={intl.formatMessage({ formState.errors.phoneNumber?.message
id: "common.phoneNumber", )}
defaultMessage: "Phone number", label={intl.formatMessage({
})} id: "common.phoneNumber",
name="phoneNumber" defaultMessage: "Phone number",
disabled={!!user} })}
registerOptions={{ required: true, onBlur: updateDetailsStore }} name="phoneNumber"
/> disabled={!!user}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
</div>
{user ? null : ( {user ? null : (
<div className={styles.fullWidth}> <div className={styles.fullWidth}>
<Signup <Signup
errors={formState.errors} errors={formState.errors}
name="join" name="join"
registerOptions={{ onBlur: updateDetailsStore }} registerOptions={{ onBlur: updateDetailsStore }}
refs={refs}
/> />
</div> </div>
)} )}

View File

@@ -95,15 +95,15 @@ export default function PaymentClient({
rooms, rooms,
totalPrice, totalPrice,
isSubmitting, isSubmitting,
preSubmitCallbacks,
setIsSubmitting, setIsSubmitting,
runPreSubmitCallbacks,
} = useEnterDetailsStore((state) => ({ } = useEnterDetailsStore((state) => ({
booking: state.booking, booking: state.booking,
rooms: state.rooms, rooms: state.rooms,
totalPrice: state.totalPrice, totalPrice: state.totalPrice,
preSubmitCallbacks: state.preSubmitCallbacks,
isSubmitting: state.isSubmitting, isSubmitting: state.isSubmitting,
setIsSubmitting: state.actions.setIsSubmitting, setIsSubmitting: state.actions.setIsSubmitting,
runPreSubmitCallbacks: state.actions.runPreSubmitCallbacks,
})) }))
const bookingMustBeGuaranteed = rooms.some(({ room }, idx) => { const bookingMustBeGuaranteed = rooms.some(({ room }, idx) => {
@@ -312,35 +312,39 @@ export default function PaymentClient({
[hasFlexRates] [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( const handleSubmit = useCallback(
(data: PaymentFormData) => { async (data: PaymentFormData) => {
setIsSubmitting(true) setIsSubmitting(true)
Object.values(preSubmitCallbacks).forEach((callback) => { const isRoomInvalid = await scrollToInvalidField()
callback() if (isRoomInvalid) {
})
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",
})
setIsSubmitting(false) setIsSubmitting(false)
return return
} }
@@ -502,13 +506,11 @@ export default function PaymentClient({
} }
), ),
} }
initiateBooking.mutate(payload) initiateBooking.mutate(payload)
}, },
[ [
setIsSubmitting, setIsSubmitting,
preSubmitCallbacks, scrollToInvalidField,
rooms,
getPaymentMethod, getPaymentMethod,
savedCreditCards, savedCreditCards,
lang, lang,
@@ -517,8 +519,8 @@ export default function PaymentClient({
fromDate, fromDate,
toDate, toDate,
hotelId, hotelId,
rooms,
initiateBooking, initiateBooking,
getTopOffset,
isUserLoggedIn, isUserLoggedIn,
booking.rooms, booking.rooms,
user?.data?.partnerLoyaltyNumber, user?.data?.partnerLoyaltyNumber,
@@ -534,6 +536,13 @@ export default function PaymentClient({
defaultMessage: "Select payment method", defaultMessage: "Select payment method",
}) })
const handleInvalidSubmit = async () => {
const valid = await methods.trigger()
if (!valid) {
await scrollToInvalidField()
}
}
return ( return (
<section <section
className={cx(styles.paymentSection, { className={cx(styles.paymentSection, {
@@ -549,7 +558,7 @@ export default function PaymentClient({
<FormProvider {...methods}> <FormProvider {...methods}>
<form <form
className={styles.paymentContainer} className={styles.paymentContainer}
onSubmit={methods.handleSubmit(handleSubmit)} onSubmit={methods.handleSubmit(handleSubmit, handleInvalidSubmit)}
id={formId} id={formId}
> >
{booking.searchType === SEARCH_TYPE_REDEMPTION ? ( {booking.searchType === SEARCH_TYPE_REDEMPTION ? (

View File

@@ -85,9 +85,9 @@ export function RoomSidePeekContent({ room }: RoomSidePeekContentProps) {
<ul className={styles.facilityList}> <ul className={styles.facilityList}>
{[...room.roomFacilities] {[...room.roomFacilities]
.sort((a, b) => a.sortOrder - b.sortOrder) .sort((a, b) => a.sortOrder - b.sortOrder)
.map((facility) => { .map((facility, index) => {
return ( return (
<li key={facility.name}> <li key={`${facility.name}-${index}`}>
<FacilityIcon <FacilityIcon
name={facility.icon} name={facility.icon}
size={24} size={24}

View File

@@ -99,7 +99,7 @@ export function createDetailsStore(
return total return total
}, {}) }, {})
return create<DetailsState>()((set) => ({ return create<DetailsState>()((set, get) => ({
availableBeds, availableBeds,
booking: initialState.booking, booking: initialState.booking,
roomCategories: initialState.roomCategories, 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
},
}, },
})) }))
} }

View File

@@ -96,7 +96,11 @@ export interface DetailsState {
setIsSubmitting: (isSubmitting: boolean) => void setIsSubmitting: (isSubmitting: boolean) => void
toggleSummaryOpen: () => void toggleSummaryOpen: () => void
updateSeachParamString: (searchParamString: string) => 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> availableBeds: Record<string, number>
booking: DetailsBooking booking: DetailsBooking
@@ -112,7 +116,7 @@ export interface DetailsState {
hotelName: string hotelName: string
roomCategories: RoomCategories roomCategories: RoomCategories
defaultCurrency: CurrencyEnum defaultCurrency: CurrencyEnum
preSubmitCallbacks: Record<string, () => void> preSubmitCallbacks: Record<string, () => Promise<HTMLElement | undefined>>
} }
export type PersistedState = { export type PersistedState = {

View File

@@ -38,11 +38,3 @@
.topAlign { .topAlign {
align-items: flex-start; 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;
}

View File

@@ -10,7 +10,7 @@ import {
import styles from './checkbox.module.css' import styles from './checkbox.module.css'
import { MaterialIcon } from '../../Icons/MaterialIcon' import { MaterialIcon } from '../../Icons/MaterialIcon'
import Caption from '../../Caption' import { ErrorMessage } from '../ErrorMessage'
interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> { interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
name: string name: string
@@ -36,7 +36,7 @@ const Checkbox = forwardRef<
ref ref
) { ) {
const { control } = useFormContext() const { control } = useFormContext()
const { field, fieldState } = useController({ const { field, fieldState, formState } = useController({
control, control,
name, name,
rules: registerOptions, rules: registerOptions,
@@ -48,6 +48,7 @@ const Checkbox = forwardRef<
isSelected={field.value} isSelected={field.value}
onChange={field.onChange} onChange={field.onChange}
data-testid={name} data-testid={name}
name={name}
isDisabled={registerOptions?.disabled} isDisabled={registerOptions?.disabled}
excludeFromTabOrder excludeFromTabOrder
> >
@@ -68,12 +69,15 @@ const Checkbox = forwardRef<
{children} {children}
</span> </span>
{fieldState.error && !hideError ? ( {fieldState.error && !hideError ? (
<Caption className={styles.error} fontOnly> <ErrorMessage
<MaterialIcon icon="info" color="Icon/Interactive/Accent" /> errors={formState.errors}
{(fieldState.error.message && name={name}
errorCodeMessages?.[fieldState.error.message]) || messageLabel={
fieldState.error.message} (fieldState.error.message &&
</Caption> errorCodeMessages?.[fieldState.error.message]) ||
fieldState.error.message
}
/>
) : null} ) : null}
</> </>
)} )}

View File

@@ -52,6 +52,10 @@
&[data-invalid] { &[data-invalid] {
border-color: var(--Border-Interactive-Error); border-color: var(--Border-Interactive-Error);
} }
&[data-invalid][data-focused] {
outline: 2px solid var(--Border-Interactive-Error);
}
} }
.inner { .inner {

View File

@@ -10,7 +10,12 @@ export function Error({ children }: React.PropsWithChildren) {
variant="Body/Supporting text (caption)/smRegular" variant="Body/Supporting text (caption)/smRegular"
> >
<span> <span>
<MaterialIcon icon="info" color="Icon/Feedback/Error" /> <MaterialIcon
icon="error"
color="Icon/Feedback/Error"
isFilled
size={20}
/>
{children} {children}
</span> </span>
</Typography> </Typography>

View File

@@ -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<typeof MessageBanner> = {
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<typeof MessageBanner>
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',
},
}

View File

@@ -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 (
<div className={styles.container}>
<Typography
className={textClass}
variant="Body/Supporting text (caption)/smRegular"
>
<span className={styles.content}>
<MaterialIcon size={20} icon={iconName} color={iconColor} isFilled />
{text}
</span>
</Typography>
</div>
)
}

View File

@@ -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);
}

View File

@@ -36,8 +36,8 @@ export const DinersClubIcon = (props: PaymentIconProps) => (
y2="21.6" y2="21.6"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#3479C0" /> <stop stopColor="#3479C0" />
<stop offset="1" stop-color="#133362" /> <stop offset="1" stopColor="#133362" />
</linearGradient> </linearGradient>
<clipPath id="clip0_5382_46858"> <clipPath id="clip0_5382_46858">
<rect width="48" height="32" fill="white" /> <rect width="48" height="32" fill="white" />

View File

@@ -89,10 +89,10 @@ export const DiscoverIcon = (props: PaymentIconProps) => (
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
gradientTransform="translate(28.6 17.5998) rotate(-142.431) scale(6.56048 6.47264)" gradientTransform="translate(28.6 17.5998) rotate(-142.431) scale(6.56048 6.47264)"
> >
<stop stop-color="#F59900" /> <stop stopColor="#F59900" />
<stop offset="0.210082" stop-color="#F39501" /> <stop offset="0.210082" stopColor="#F39501" />
<stop offset="0.908163" stop-color="#CE3C0B" /> <stop offset="0.908163" stopColor="#CE3C0B" />
<stop offset="1" stop-color="#A4420A" /> <stop offset="1" stopColor="#A4420A" />
</radialGradient> </radialGradient>
<clipPath id="clip0_5382_46865"> <clipPath id="clip0_5382_46865">
<rect width="48" height="32" rx="3" fill="white" /> <rect width="48" height="32" rx="3" fill="white" />

View File

@@ -49,10 +49,10 @@ export const JcbIcon = (props: PaymentIconProps) => (
y2="16.0328" y2="16.0328"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#007940" /> <stop stopColor="#007940" />
<stop offset="0.2285" stop-color="#00873F" /> <stop offset="0.2285" stopColor="#00873F" />
<stop offset="0.7433" stop-color="#40A737" /> <stop offset="0.7433" stopColor="#40A737" />
<stop offset="1" stop-color="#5CB531" /> <stop offset="1" stopColor="#5CB531" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="paint1_linear_5382_46863" id="paint1_linear_5382_46863"
@@ -62,10 +62,10 @@ export const JcbIcon = (props: PaymentIconProps) => (
y2="16.001" y2="16.001"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#007940" /> <stop stopColor="#007940" />
<stop offset="0.2285" stop-color="#00873F" /> <stop offset="0.2285" stopColor="#00873F" />
<stop offset="0.7433" stop-color="#40A737" /> <stop offset="0.7433" stopColor="#40A737" />
<stop offset="1" stop-color="#5CB531" /> <stop offset="1" stopColor="#5CB531" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="paint2_linear_5382_46863" id="paint2_linear_5382_46863"
@@ -75,10 +75,10 @@ export const JcbIcon = (props: PaymentIconProps) => (
y2="14.4771" y2="14.4771"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#007940" /> <stop stopColor="#007940" />
<stop offset="0.2285" stop-color="#00873F" /> <stop offset="0.2285" stopColor="#00873F" />
<stop offset="0.7433" stop-color="#40A737" /> <stop offset="0.7433" stopColor="#40A737" />
<stop offset="1" stop-color="#5CB531" /> <stop offset="1" stopColor="#5CB531" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="paint3_linear_5382_46863" id="paint3_linear_5382_46863"
@@ -88,11 +88,11 @@ export const JcbIcon = (props: PaymentIconProps) => (
y2="16.001" y2="16.001"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#6C2C2F" /> <stop stopColor="#6C2C2F" />
<stop offset="0.1735" stop-color="#882730" /> <stop offset="0.1735" stopColor="#882730" />
<stop offset="0.5731" stop-color="#BE1833" /> <stop offset="0.5731" stopColor="#BE1833" />
<stop offset="0.8585" stop-color="#DC0436" /> <stop offset="0.8585" stopColor="#DC0436" />
<stop offset="1" stop-color="#E60039" /> <stop offset="1" stopColor="#E60039" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="paint4_linear_5382_46863" id="paint4_linear_5382_46863"
@@ -102,10 +102,10 @@ export const JcbIcon = (props: PaymentIconProps) => (
y2="16.001" y2="16.001"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#1F286F" /> <stop stopColor="#1F286F" />
<stop offset="0.4751" stop-color="#004E94" /> <stop offset="0.4751" stopColor="#004E94" />
<stop offset="0.8261" stop-color="#0066B1" /> <stop offset="0.8261" stopColor="#0066B1" />
<stop offset="1" stop-color="#006FBC" /> <stop offset="1" stopColor="#006FBC" />
</linearGradient> </linearGradient>
<clipPath id="clip0_5382_46863"> <clipPath id="clip0_5382_46863">
<rect width="48" height="32" rx="3" fill="white" /> <rect width="48" height="32" rx="3" fill="white" />

View File

@@ -61,8 +61,8 @@ export const SwishIcon = (props: PaymentIconProps) => {
y2="13.9987" y2="13.9987"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#EF3220" /> <stop stopColor="#EF3220" />
<stop offset="1" stop-color="#FCD205" /> <stop offset="1" stopColor="#FCD205" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="paint1_linear_5382_46851" id="paint1_linear_5382_46851"
@@ -72,10 +72,10 @@ export const SwishIcon = (props: PaymentIconProps) => {
y2="21.8844" y2="21.8844"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#FCD205" /> <stop stopColor="#FCD205" />
<stop offset="0.263921" stop-color="#F47216" /> <stop offset="0.263921" stopColor="#F47216" />
<stop offset="0.560797" stop-color="#B31A93" /> <stop offset="0.560797" stopColor="#B31A93" />
<stop offset="1" stop-color="#2743A0" /> <stop offset="1" stopColor="#2743A0" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="paint2_linear_5382_46851" id="paint2_linear_5382_46851"
@@ -85,10 +85,10 @@ export const SwishIcon = (props: PaymentIconProps) => {
y2="18.0191" y2="18.0191"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#7FD3B9" /> <stop stopColor="#7FD3B9" />
<stop offset="0.265705" stop-color="#66CDE1" /> <stop offset="0.265705" stopColor="#66CDE1" />
<stop offset="0.554471" stop-color="#6D8ED1" /> <stop offset="0.554471" stopColor="#6D8ED1" />
<stop offset="1" stop-color="#2743A0" /> <stop offset="1" stopColor="#2743A0" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="paint3_linear_5382_46851" id="paint3_linear_5382_46851"
@@ -98,10 +98,10 @@ export const SwishIcon = (props: PaymentIconProps) => {
y2="10.1074" y2="10.1074"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#1E5CB2" /> <stop stopColor="#1E5CB2" />
<stop offset="0.246658" stop-color="#4DC4CE" /> <stop offset="0.246658" stopColor="#4DC4CE" />
<stop offset="0.564821" stop-color="#66C657" /> <stop offset="0.564821" stopColor="#66C657" />
<stop offset="1" stop-color="#FCD205" /> <stop offset="1" stopColor="#FCD205" />
</linearGradient> </linearGradient>
<clipPath id="clip0_5382_46851"> <clipPath id="clip0_5382_46851">
<rect width="48" height="32" fill="white" /> <rect width="48" height="32" fill="white" />

View File

@@ -52,6 +52,9 @@
&[data-invalid] { &[data-invalid] {
border-color: var(--Border-Interactive-Error); border-color: var(--Border-Interactive-Error);
} }
&[data-invalid][data-focused] {
outline: 2px solid var(--Border-Interactive-Error);
}
} }
.chevron { .chevron {

View File

@@ -147,6 +147,7 @@
"./Map/Markers/HotelMarkerByType": "./lib/components/Map/Markers/HotelMarkerByType.tsx", "./Map/Markers/HotelMarkerByType": "./lib/components/Map/Markers/HotelMarkerByType.tsx",
"./Map/Markers/PoiMarker": "./lib/components/Map/Markers/PoiMarker/index.tsx", "./Map/Markers/PoiMarker": "./lib/components/Map/Markers/PoiMarker/index.tsx",
"./Map/types": "./lib/components/Map/types.ts", "./Map/types": "./lib/components/Map/types.ts",
"./MessageBanner": "./lib/components/MessageBanner/index.tsx",
"./Modal": "./lib/components/Modal/index.tsx", "./Modal": "./lib/components/Modal/index.tsx",
"./Modal/ModalContentWithActions": "./lib/components/Modal/ModalContentWithActions/index.tsx", "./Modal/ModalContentWithActions": "./lib/components/Modal/ModalContentWithActions/index.tsx",
"./NoRateAvailableCard": "./lib/components/RateCard/NoRateAvailable/index.tsx", "./NoRateAvailableCard": "./lib/components/RateCard/NoRateAvailable/index.tsx",