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:
@@ -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<HTMLInputElement, InputProps>(function Input(
|
||||
ref
|
||||
) {
|
||||
const intl = useIntl()
|
||||
const { control } = useFormContext()
|
||||
const { control, formState } = useFormContext()
|
||||
const numberAttributes: HTMLAttributes<HTMLInputElement> = {}
|
||||
if (type === "number") {
|
||||
numberAttributes.onWheel = function (evt: WheelEvent<HTMLInputElement>) {
|
||||
@@ -87,10 +88,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
</Caption>
|
||||
) : null}
|
||||
{fieldState.error && !hideError ? (
|
||||
<Caption className={styles.error} fontOnly>
|
||||
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
|
||||
{getErrorMessage(intl, fieldState.error.message)}
|
||||
</Caption>
|
||||
<ErrorMessage
|
||||
errors={formState.errors}
|
||||
name={name}
|
||||
messageLabel={getErrorMessage(intl, fieldState.error.message)}
|
||||
/>
|
||||
) : null}
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input"
|
||||
|
||||
@@ -51,7 +52,7 @@ const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
|
||||
ref
|
||||
) {
|
||||
const intl = useIntl()
|
||||
const { control } = useFormContext()
|
||||
const { control, formState } = useFormContext()
|
||||
const config = useBookingFlowConfig()
|
||||
|
||||
return (
|
||||
@@ -96,14 +97,15 @@ const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
|
||||
</Caption>
|
||||
) : null}
|
||||
{fieldState.error && !hideError ? (
|
||||
<Caption className={styles.error} fontOnly>
|
||||
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
|
||||
{getErrorMessage(
|
||||
<ErrorMessage
|
||||
errors={formState.errors}
|
||||
name={name}
|
||||
messageLabel={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
fieldState.error.message
|
||||
)}
|
||||
</Caption>
|
||||
/>
|
||||
) : null}
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
@@ -3,15 +3,3 @@
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.error {
|
||||
align-items: center;
|
||||
color: var(--Text-Interactive-Error);
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
margin: var(--Space-x1) 0 0;
|
||||
}
|
||||
|
||||
.error svg {
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
@@ -15,3 +15,7 @@
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
width: min(696px, 100%);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import RadioCard from "@scandic-hotels/design-system/Form/RadioCard"
|
||||
import { MessageBanner } from "@scandic-hotels/design-system/MessageBanner"
|
||||
import { trackBedSelection } from "@scandic-hotels/tracking/booking"
|
||||
import {
|
||||
BedTypeEnum,
|
||||
@@ -21,11 +23,20 @@ import styles from "./bedOptions.module.css"
|
||||
import type { IconProps } from "@scandic-hotels/design-system/Icons"
|
||||
|
||||
export default function BedType() {
|
||||
const availableBeds = useEnterDetailsStore((state) => state.availableBeds)
|
||||
const {
|
||||
actions: { updateBedType },
|
||||
room: { bedType, bedTypes },
|
||||
idx,
|
||||
} = useRoomContext()
|
||||
const { addPreSubmitCallback, availableBeds } = useEnterDetailsStore(
|
||||
(state) => ({
|
||||
addPreSubmitCallback: state.actions.addPreSubmitCallback,
|
||||
availableBeds: state.availableBeds,
|
||||
})
|
||||
)
|
||||
const intl = useIntl()
|
||||
const formRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const initialBedType = bedType?.roomTypeCode
|
||||
const [previousBedType, setPreviousBedType] = useState("")
|
||||
|
||||
@@ -56,6 +67,17 @@ export default function BedType() {
|
||||
[bedTypes, updateBedType]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
async function callback() {
|
||||
const isValid = await methods.trigger()
|
||||
if (!isValid && methods.formState.errors.bedType) {
|
||||
return formRef.current ?? undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
addPreSubmitCallback(`${idx}-bedtype`, callback)
|
||||
}, [addPreSubmitCallback, methods, idx])
|
||||
|
||||
const selectedBedType = methods.watch("bedType")
|
||||
const handleSubmit = methods.handleSubmit
|
||||
useEffect(() => {
|
||||
@@ -73,7 +95,19 @@ export default function BedType() {
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.container} ref={formRef}>
|
||||
<div className={styles.errorContainer}>
|
||||
{methods.formState.errors.bedType && (
|
||||
<MessageBanner
|
||||
text={intl.formatMessage({
|
||||
id: "enterDetails.bedType.error.required",
|
||||
defaultMessage: "Bed preference is required",
|
||||
})}
|
||||
type="error"
|
||||
textColor="error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{bedTypes.map((roomType) => {
|
||||
const width =
|
||||
|
||||
@@ -10,3 +10,7 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
width: min(696px, 100%);
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
width: min(696px, 100%);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -10,6 +10,7 @@ import Body from "@scandic-hotels/design-system/Body"
|
||||
import RadioCard from "@scandic-hotels/design-system/Form/RadioCard"
|
||||
import BreakfastBuffetIcon from "@scandic-hotels/design-system/Icons/BreakfastBuffetIcon"
|
||||
import NoBreakfastBuffetIcon from "@scandic-hotels/design-system/Icons/NoBreakfastBuffetIcon"
|
||||
import { MessageBanner } from "@scandic-hotels/design-system/MessageBanner"
|
||||
import { trackBreakfastSelection } from "@scandic-hotels/tracking/booking"
|
||||
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
|
||||
@@ -21,11 +22,16 @@ import styles from "./breakfast.module.css"
|
||||
|
||||
export default function Breakfast() {
|
||||
const intl = useIntl()
|
||||
const formRef = useRef<HTMLDivElement>(null)
|
||||
const packages = useEnterDetailsStore((state) => state.breakfastPackages)
|
||||
const hotelId = useEnterDetailsStore((state) => state.booking.hotelId)
|
||||
const { addPreSubmitCallback } = useEnterDetailsStore((state) => ({
|
||||
addPreSubmitCallback: state.actions.addPreSubmitCallback,
|
||||
}))
|
||||
const {
|
||||
actions: { updateBreakfast },
|
||||
room,
|
||||
idx,
|
||||
} = useRoomContext()
|
||||
|
||||
const hasChildrenInRoom = !!room.childrenInRoom?.length
|
||||
@@ -65,6 +71,19 @@ export default function Breakfast() {
|
||||
[packages, hotelId, room.adults, updateBreakfast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
async function callback() {
|
||||
const isValid = await methods.trigger()
|
||||
if (!isValid && methods.formState.errors.breakfast) {
|
||||
return formRef.current ?? undefined
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
addPreSubmitCallback(`${idx}-breakfast`, callback)
|
||||
}, [addPreSubmitCallback, methods, idx])
|
||||
|
||||
const selectedBreakfast = methods.watch("breakfast")
|
||||
const handleSubmit = methods.handleSubmit
|
||||
useEffect(() => {
|
||||
@@ -75,7 +94,7 @@ export default function Breakfast() {
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.container} ref={formRef}>
|
||||
{hasChildrenInRoom ? (
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
@@ -85,6 +104,19 @@ export default function Breakfast() {
|
||||
})}
|
||||
</Body>
|
||||
) : null}
|
||||
{methods.formState.errors.breakfast && (
|
||||
<div className={styles.errorContainer}>
|
||||
<MessageBanner
|
||||
text={intl.formatMessage({
|
||||
id: "enterDetails.breakfast.error.required",
|
||||
defaultMessage: "Breakfast option is required",
|
||||
})}
|
||||
type="error"
|
||||
textColor="error"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{packages?.map((pkg) => (
|
||||
<RadioCard
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function Details() {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const config = useBookingFlowConfig()
|
||||
const refs = useRef<Record<string, HTMLElement | null>>({})
|
||||
|
||||
const { addPreSubmitCallback, rooms } = useEnterDetailsStore((state) => ({
|
||||
addPreSubmitCallback: state.actions.addPreSubmitCallback,
|
||||
@@ -106,12 +107,32 @@ export default function Details() {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
function callback() {
|
||||
trigger()
|
||||
async function callback() {
|
||||
await trigger()
|
||||
trackFormSubmit()
|
||||
const fieldOrder = [
|
||||
"firstName",
|
||||
"lastName",
|
||||
"countryCode",
|
||||
"email",
|
||||
"phoneNumber",
|
||||
"membershipNo",
|
||||
]
|
||||
for (const name of fieldOrder) {
|
||||
const fieldError =
|
||||
methods.formState.errors[
|
||||
name as keyof typeof methods.formState.errors
|
||||
]
|
||||
if (fieldError && refs.current[name]) {
|
||||
return refs.current[name] ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
addPreSubmitCallback(`${idx}-details`, callback)
|
||||
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit])
|
||||
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit, methods])
|
||||
|
||||
const updateDetailsStore = useCallback(() => {
|
||||
if (isValid) {
|
||||
@@ -188,88 +209,124 @@ export default function Details() {
|
||||
defaultMessage: "Guest information",
|
||||
})}
|
||||
</Footnote>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
id: "common.firstName",
|
||||
defaultMessage: "First name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "lastName",
|
||||
onBlur: updateDetailsStore,
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.firstName = el
|
||||
}}
|
||||
/>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
id: "common.lastName",
|
||||
defaultMessage: "Last name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "firstName",
|
||||
onBlur: updateDetailsStore,
|
||||
}}
|
||||
/>
|
||||
<CountrySelect
|
||||
className={styles.fullWidth}
|
||||
countries={getFormattedCountryList(intl)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
errors.countryCode?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
id: "common.country",
|
||||
defaultMessage: "Country",
|
||||
})}
|
||||
lang={lang}
|
||||
name="countryCode"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<BookingFlowInput
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
id: "common.emailAddress",
|
||||
defaultMessage: "Email address",
|
||||
})}
|
||||
name="email"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<Phone
|
||||
countryLabel={intl.formatMessage({
|
||||
id: "common.countryCode",
|
||||
defaultMessage: "Country code",
|
||||
})}
|
||||
countriesWithTranslatedName={getFormattedCountryList(intl)}
|
||||
defaultCountryCode={getDefaultCountryFromLang(lang)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
errors.phoneNumber?.message
|
||||
)}
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
id: "common.phoneNumber",
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
name="phoneNumber"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
{showMembershipIdInput ? (
|
||||
>
|
||||
<BookingFlowInput
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
id: "common.membershipId",
|
||||
defaultMessage: "Membership ID",
|
||||
id: "common.firstName",
|
||||
defaultMessage: "First name",
|
||||
})}
|
||||
name="membershipNo"
|
||||
type="tel"
|
||||
registerOptions={{ onBlur: updateDetailsStore }}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "lastName",
|
||||
onBlur: updateDetailsStore,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.lastName = el
|
||||
}}
|
||||
>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
id: "common.lastName",
|
||||
defaultMessage: "Last name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "firstName",
|
||||
onBlur: updateDetailsStore,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.countryCode = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
<CountrySelect
|
||||
countries={getFormattedCountryList(intl)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
errors.countryCode?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
id: "common.country",
|
||||
defaultMessage: "Country",
|
||||
})}
|
||||
lang={lang}
|
||||
name="countryCode"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.email = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
id: "common.emailAddress",
|
||||
defaultMessage: "Email address",
|
||||
})}
|
||||
name="email"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.phoneNumber = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
<Phone
|
||||
countryLabel={intl.formatMessage({
|
||||
id: "common.countryCode",
|
||||
defaultMessage: "Country code",
|
||||
})}
|
||||
countriesWithTranslatedName={getFormattedCountryList(intl)}
|
||||
defaultCountryCode={getDefaultCountryFromLang(lang)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
errors.phoneNumber?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
id: "common.phoneNumber",
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
name="phoneNumber"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
{showMembershipIdInput ? (
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.membershipNo = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
id: "common.membershipId",
|
||||
defaultMessage: "Membership ID",
|
||||
})}
|
||||
name="membershipNo"
|
||||
type="tel"
|
||||
registerOptions={{ onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<SpecialRequests registerOptions={{ onBlur: updateDetailsStore }} />
|
||||
</div>
|
||||
|
||||
@@ -23,10 +23,12 @@ export default function Signup({
|
||||
errors,
|
||||
name,
|
||||
registerOptions,
|
||||
refs,
|
||||
}: {
|
||||
errors: FieldErrors
|
||||
name: string
|
||||
registerOptions?: RegisterOptions
|
||||
refs: React.RefObject<Record<string, HTMLElement | null>>
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
@@ -45,15 +47,26 @@ export default function Signup({
|
||||
if (isJoinChecked)
|
||||
return (
|
||||
<div className={styles.additionalFormData}>
|
||||
<BookingFlowInput
|
||||
name="zipCode"
|
||||
label={intl.formatMessage({
|
||||
id: "common.zipCode",
|
||||
defaultMessage: "Zip code",
|
||||
})}
|
||||
registerOptions={{ required: true, ...registerOptions }}
|
||||
/>
|
||||
<div className={styles.dateField}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.zipCode = el
|
||||
}}
|
||||
>
|
||||
<BookingFlowInput
|
||||
name="zipCode"
|
||||
label={intl.formatMessage({
|
||||
id: "common.zipCode",
|
||||
defaultMessage: "Zip code",
|
||||
})}
|
||||
registerOptions={{ required: true, ...registerOptions }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={styles.dateField}
|
||||
ref={(el) => {
|
||||
refs.current.dateOfBirth = el
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<Caption type="bold">
|
||||
<span className={styles.required}>
|
||||
@@ -94,5 +107,13 @@ export default function Signup({
|
||||
|
||||
if (config.enterDetailsMembershipIdInputLocation === "join-card") return null
|
||||
|
||||
return <MembershipNumberInput registerOptions={registerOptions} />
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.membershipNo = el
|
||||
}}
|
||||
>
|
||||
<MembershipNumberInput registerOptions={registerOptions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -40,6 +40,8 @@ type DetailsProps = {
|
||||
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user }: DetailsProps) {
|
||||
const refs = useRef<Record<string, HTMLElement | null>>({})
|
||||
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const config = useBookingFlowConfig()
|
||||
@@ -107,12 +109,36 @@ export default function Details({ user }: DetailsProps) {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
function callback() {
|
||||
trigger()
|
||||
async function callback() {
|
||||
await trigger()
|
||||
trackFormSubmit()
|
||||
const baseFieldOrder = [
|
||||
"firstName",
|
||||
"lastName",
|
||||
"countryCode",
|
||||
"email",
|
||||
"phoneNumber",
|
||||
"membershipNo",
|
||||
]
|
||||
const joinChecked = methods.watch("join")
|
||||
const fieldOrder = joinChecked
|
||||
? [...baseFieldOrder, "zipCode", "dateOfBirth"]
|
||||
: baseFieldOrder
|
||||
for (const name of fieldOrder) {
|
||||
const fieldError =
|
||||
methods.formState.errors[
|
||||
name as keyof typeof methods.formState.errors
|
||||
]
|
||||
if (fieldError && refs.current[name]) {
|
||||
return refs.current[name] ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
addPreSubmitCallback(`${idx}-details`, callback)
|
||||
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit])
|
||||
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit, methods])
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: GuestDetailsSchema) => {
|
||||
@@ -133,12 +159,12 @@ export default function Details({ user }: DetailsProps) {
|
||||
setIncomplete()
|
||||
}
|
||||
}, [
|
||||
handleSubmit,
|
||||
formState.isValid,
|
||||
handleSubmit,
|
||||
onSubmit,
|
||||
setIncomplete,
|
||||
updatePartialGuestData,
|
||||
getValues,
|
||||
setIncomplete,
|
||||
])
|
||||
|
||||
useEffect(updateDetailsStore, [updateDetailsStore])
|
||||
@@ -174,83 +200,114 @@ export default function Details({ user }: DetailsProps) {
|
||||
defaultMessage: "Guest information",
|
||||
})}
|
||||
</Footnote>
|
||||
<BookingFlowInput
|
||||
autoComplete="given-name"
|
||||
label={intl.formatMessage({
|
||||
id: "common.firstName",
|
||||
defaultMessage: "First name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<BookingFlowInput
|
||||
autoComplete="family-name"
|
||||
label={intl.formatMessage({
|
||||
id: "common.lastName",
|
||||
defaultMessage: "Last name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<CountrySelect
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.firstName = el
|
||||
}}
|
||||
>
|
||||
<BookingFlowInput
|
||||
autoComplete="given-name"
|
||||
label={intl.formatMessage({
|
||||
id: "common.firstName",
|
||||
defaultMessage: "First name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.lastName = el
|
||||
}}
|
||||
>
|
||||
<BookingFlowInput
|
||||
autoComplete="family-name"
|
||||
label={intl.formatMessage({
|
||||
id: "common.lastName",
|
||||
defaultMessage: "Last name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.countryCode = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
id: "common.country",
|
||||
defaultMessage: "Country",
|
||||
})}
|
||||
lang={lang}
|
||||
countries={getFormattedCountryList(intl)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
formState.errors.countryCode?.message
|
||||
)}
|
||||
name="countryCode"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
disabled={!!user}
|
||||
/>
|
||||
<BookingFlowInput
|
||||
autoComplete="email"
|
||||
>
|
||||
<CountrySelect
|
||||
label={intl.formatMessage({
|
||||
id: "common.country",
|
||||
defaultMessage: "Country",
|
||||
})}
|
||||
lang={lang}
|
||||
countries={getFormattedCountryList(intl)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
formState.errors.countryCode?.message
|
||||
)}
|
||||
name="countryCode"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
disabled={!!user}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.email = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
id: "common.emailAddress",
|
||||
defaultMessage: "Email address",
|
||||
})}
|
||||
name="email"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<Phone
|
||||
>
|
||||
<BookingFlowInput
|
||||
autoComplete="email"
|
||||
label={intl.formatMessage({
|
||||
id: "common.emailAddress",
|
||||
defaultMessage: "Email address",
|
||||
})}
|
||||
name="email"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(el) => {
|
||||
refs.current.phoneNumber = el
|
||||
}}
|
||||
className={styles.fullWidth}
|
||||
countryLabel={intl.formatMessage({
|
||||
id: "common.countryCode",
|
||||
defaultMessage: "Country code",
|
||||
})}
|
||||
countriesWithTranslatedName={getFormattedCountryList(intl)}
|
||||
defaultCountryCode={getDefaultCountryFromLang(lang)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
formState.errors.phoneNumber?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
id: "common.phoneNumber",
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
name="phoneNumber"
|
||||
disabled={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
>
|
||||
<Phone
|
||||
countryLabel={intl.formatMessage({
|
||||
id: "common.countryCode",
|
||||
defaultMessage: "Country code",
|
||||
})}
|
||||
countriesWithTranslatedName={getFormattedCountryList(intl)}
|
||||
defaultCountryCode={getDefaultCountryFromLang(lang)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
config.variant,
|
||||
formState.errors.phoneNumber?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
id: "common.phoneNumber",
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
name="phoneNumber"
|
||||
disabled={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
{user ? null : (
|
||||
<div className={styles.fullWidth}>
|
||||
<Signup
|
||||
errors={formState.errors}
|
||||
name="join"
|
||||
registerOptions={{ onBlur: updateDetailsStore }}
|
||||
refs={refs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,15 +95,15 @@ export default function PaymentClient({
|
||||
rooms,
|
||||
totalPrice,
|
||||
isSubmitting,
|
||||
preSubmitCallbacks,
|
||||
setIsSubmitting,
|
||||
runPreSubmitCallbacks,
|
||||
} = useEnterDetailsStore((state) => ({
|
||||
booking: state.booking,
|
||||
rooms: state.rooms,
|
||||
totalPrice: state.totalPrice,
|
||||
preSubmitCallbacks: state.preSubmitCallbacks,
|
||||
isSubmitting: state.isSubmitting,
|
||||
setIsSubmitting: state.actions.setIsSubmitting,
|
||||
runPreSubmitCallbacks: state.actions.runPreSubmitCallbacks,
|
||||
}))
|
||||
|
||||
const bookingMustBeGuaranteed = rooms.some(({ room }, idx) => {
|
||||
@@ -312,35 +312,39 @@ export default function PaymentClient({
|
||||
[hasFlexRates]
|
||||
)
|
||||
|
||||
const scrollToInvalidField = useCallback(async (): Promise<boolean> => {
|
||||
// If any room is not complete/valid, scroll to the first invalid field, this is needed as rooms and other fields are in separate forms
|
||||
|
||||
const invalidField = await runPreSubmitCallbacks()
|
||||
const errorNames = Object.keys(methods.formState.errors)
|
||||
const firstIncompleteRoomIndex = rooms.findIndex((room) => !room.isComplete)
|
||||
|
||||
const scrollToElement = (el: HTMLElement) => {
|
||||
const offset = getTopOffset()
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - offset - 20
|
||||
window.scrollTo({ top, behavior: "smooth" })
|
||||
const input = el.querySelector<HTMLElement>("input")
|
||||
input?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
if (invalidField) {
|
||||
scrollToElement(invalidField)
|
||||
} else if (errorNames.length > 0) {
|
||||
const firstErrorEl = document.querySelector(`[name="${errorNames[0]}"]`)
|
||||
if (firstErrorEl) {
|
||||
scrollToElement(firstErrorEl as HTMLElement)
|
||||
}
|
||||
}
|
||||
|
||||
return firstIncompleteRoomIndex !== -1
|
||||
}, [runPreSubmitCallbacks, rooms, methods.formState.errors, getTopOffset])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: PaymentFormData) => {
|
||||
async (data: PaymentFormData) => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
Object.values(preSubmitCallbacks).forEach((callback) => {
|
||||
callback()
|
||||
})
|
||||
const firstIncompleteRoomIndex = rooms.findIndex(
|
||||
(room) => !room.isComplete
|
||||
)
|
||||
|
||||
// If any room is not complete/valid, scroll to it
|
||||
if (firstIncompleteRoomIndex !== -1) {
|
||||
const roomElement = document.getElementById(
|
||||
`room-${firstIncompleteRoomIndex + 1}`
|
||||
)
|
||||
|
||||
if (!roomElement) {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
const roomElementTop =
|
||||
roomElement.getBoundingClientRect().top + window.scrollY
|
||||
|
||||
window.scrollTo({
|
||||
top: roomElementTop - getTopOffset() - 20,
|
||||
behavior: "smooth",
|
||||
})
|
||||
|
||||
const isRoomInvalid = await scrollToInvalidField()
|
||||
if (isRoomInvalid) {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -502,13 +506,11 @@ export default function PaymentClient({
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
initiateBooking.mutate(payload)
|
||||
},
|
||||
[
|
||||
setIsSubmitting,
|
||||
preSubmitCallbacks,
|
||||
rooms,
|
||||
scrollToInvalidField,
|
||||
getPaymentMethod,
|
||||
savedCreditCards,
|
||||
lang,
|
||||
@@ -517,8 +519,8 @@ export default function PaymentClient({
|
||||
fromDate,
|
||||
toDate,
|
||||
hotelId,
|
||||
rooms,
|
||||
initiateBooking,
|
||||
getTopOffset,
|
||||
isUserLoggedIn,
|
||||
booking.rooms,
|
||||
user?.data?.partnerLoyaltyNumber,
|
||||
@@ -534,6 +536,13 @@ export default function PaymentClient({
|
||||
defaultMessage: "Select payment method",
|
||||
})
|
||||
|
||||
const handleInvalidSubmit = async () => {
|
||||
const valid = await methods.trigger()
|
||||
if (!valid) {
|
||||
await scrollToInvalidField()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cx(styles.paymentSection, {
|
||||
@@ -549,7 +558,7 @@ export default function PaymentClient({
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
onSubmit={methods.handleSubmit(handleSubmit, handleInvalidSubmit)}
|
||||
id={formId}
|
||||
>
|
||||
{booking.searchType === SEARCH_TYPE_REDEMPTION ? (
|
||||
|
||||
@@ -85,9 +85,9 @@ export function RoomSidePeekContent({ room }: RoomSidePeekContentProps) {
|
||||
<ul className={styles.facilityList}>
|
||||
{[...room.roomFacilities]
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((facility) => {
|
||||
.map((facility, index) => {
|
||||
return (
|
||||
<li key={facility.name}>
|
||||
<li key={`${facility.name}-${index}`}>
|
||||
<FacilityIcon
|
||||
name={facility.icon}
|
||||
size={24}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function createDetailsStore(
|
||||
return total
|
||||
}, {})
|
||||
|
||||
return create<DetailsState>()((set) => ({
|
||||
return create<DetailsState>()((set, get) => ({
|
||||
availableBeds,
|
||||
booking: initialState.booking,
|
||||
roomCategories: initialState.roomCategories,
|
||||
@@ -404,6 +404,47 @@ export function createDetailsStore(
|
||||
})
|
||||
)
|
||||
},
|
||||
|
||||
async runPreSubmitCallbacks(): Promise<HTMLElement | undefined> {
|
||||
const callbacks = get().preSubmitCallbacks
|
||||
const stepOrder = ["bedType", "breakfast", "details"]
|
||||
|
||||
const sortedKeys = Object.keys(callbacks).sort((a, b) => {
|
||||
const [aIdx, aStep] = a.split("-")
|
||||
const [bIdx, bStep] = b.split("-")
|
||||
if (aIdx !== bIdx) return Number(aIdx) - Number(bIdx)
|
||||
return stepOrder.indexOf(aStep) - stepOrder.indexOf(bStep)
|
||||
})
|
||||
|
||||
const roomsMap = new Map<string, string[]>()
|
||||
for (const key of sortedKeys) {
|
||||
const [roomIdx] = key.split("-")
|
||||
if (!roomsMap.has(roomIdx)) {
|
||||
roomsMap.set(roomIdx, [])
|
||||
}
|
||||
roomsMap.get(roomIdx)?.push(key)
|
||||
}
|
||||
|
||||
let firstInvalidElement: HTMLElement | undefined = undefined
|
||||
|
||||
for (const roomIdx of Array.from(roomsMap.keys()).sort(
|
||||
(a, b) => Number(a) - Number(b)
|
||||
)) {
|
||||
const roomKeys = roomsMap.get(roomIdx)!
|
||||
const invalidElementsInRoom: HTMLElement[] = []
|
||||
|
||||
for (const key of roomKeys) {
|
||||
const el = await callbacks[key]()
|
||||
if (el) invalidElementsInRoom.push(el)
|
||||
}
|
||||
|
||||
if (!firstInvalidElement && invalidElementsInRoom.length > 0) {
|
||||
firstInvalidElement = invalidElementsInRoom.at(0)
|
||||
}
|
||||
}
|
||||
|
||||
return firstInvalidElement
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -96,7 +96,11 @@ export interface DetailsState {
|
||||
setIsSubmitting: (isSubmitting: boolean) => void
|
||||
toggleSummaryOpen: () => void
|
||||
updateSeachParamString: (searchParamString: string) => void
|
||||
addPreSubmitCallback: (name: string, callback: () => void) => void
|
||||
addPreSubmitCallback: (
|
||||
name: string,
|
||||
callback: () => Promise<HTMLElement | undefined>
|
||||
) => void
|
||||
runPreSubmitCallbacks: () => Promise<HTMLElement | undefined>
|
||||
}
|
||||
availableBeds: Record<string, number>
|
||||
booking: DetailsBooking
|
||||
@@ -112,7 +116,7 @@ export interface DetailsState {
|
||||
hotelName: string
|
||||
roomCategories: RoomCategories
|
||||
defaultCurrency: CurrencyEnum
|
||||
preSubmitCallbacks: Record<string, () => void>
|
||||
preSubmitCallbacks: Record<string, () => Promise<HTMLElement | undefined>>
|
||||
}
|
||||
|
||||
export type PersistedState = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLInputElement> {
|
||||
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}
|
||||
</span>
|
||||
{fieldState.error && !hideError ? (
|
||||
<Caption className={styles.error} fontOnly>
|
||||
<MaterialIcon icon="info" color="Icon/Interactive/Accent" />
|
||||
{(fieldState.error.message &&
|
||||
errorCodeMessages?.[fieldState.error.message]) ||
|
||||
fieldState.error.message}
|
||||
</Caption>
|
||||
<ErrorMessage
|
||||
errors={formState.errors}
|
||||
name={name}
|
||||
messageLabel={
|
||||
(fieldState.error.message &&
|
||||
errorCodeMessages?.[fieldState.error.message]) ||
|
||||
fieldState.error.message
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
&[data-invalid] {
|
||||
border-color: var(--Border-Interactive-Error);
|
||||
}
|
||||
|
||||
&[data-invalid][data-focused] {
|
||||
outline: 2px solid var(--Border-Interactive-Error);
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
|
||||
@@ -10,7 +10,12 @@ export function Error({ children }: React.PropsWithChildren) {
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
|
||||
<MaterialIcon
|
||||
icon="error"
|
||||
color="Icon/Feedback/Error"
|
||||
isFilled
|
||||
size={20}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
</Typography>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -36,8 +36,8 @@ export const DinersClubIcon = (props: PaymentIconProps) => (
|
||||
y2="21.6"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#3479C0" />
|
||||
<stop offset="1" stop-color="#133362" />
|
||||
<stop stopColor="#3479C0" />
|
||||
<stop offset="1" stopColor="#133362" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_5382_46858">
|
||||
<rect width="48" height="32" fill="white" />
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
<stop stop-color="#F59900" />
|
||||
<stop offset="0.210082" stop-color="#F39501" />
|
||||
<stop offset="0.908163" stop-color="#CE3C0B" />
|
||||
<stop offset="1" stop-color="#A4420A" />
|
||||
<stop stopColor="#F59900" />
|
||||
<stop offset="0.210082" stopColor="#F39501" />
|
||||
<stop offset="0.908163" stopColor="#CE3C0B" />
|
||||
<stop offset="1" stopColor="#A4420A" />
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_5382_46865">
|
||||
<rect width="48" height="32" rx="3" fill="white" />
|
||||
|
||||
@@ -49,10 +49,10 @@ export const JcbIcon = (props: PaymentIconProps) => (
|
||||
y2="16.0328"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#007940" />
|
||||
<stop offset="0.2285" stop-color="#00873F" />
|
||||
<stop offset="0.7433" stop-color="#40A737" />
|
||||
<stop offset="1" stop-color="#5CB531" />
|
||||
<stop stopColor="#007940" />
|
||||
<stop offset="0.2285" stopColor="#00873F" />
|
||||
<stop offset="0.7433" stopColor="#40A737" />
|
||||
<stop offset="1" stopColor="#5CB531" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_5382_46863"
|
||||
@@ -62,10 +62,10 @@ export const JcbIcon = (props: PaymentIconProps) => (
|
||||
y2="16.001"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#007940" />
|
||||
<stop offset="0.2285" stop-color="#00873F" />
|
||||
<stop offset="0.7433" stop-color="#40A737" />
|
||||
<stop offset="1" stop-color="#5CB531" />
|
||||
<stop stopColor="#007940" />
|
||||
<stop offset="0.2285" stopColor="#00873F" />
|
||||
<stop offset="0.7433" stopColor="#40A737" />
|
||||
<stop offset="1" stopColor="#5CB531" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_5382_46863"
|
||||
@@ -75,10 +75,10 @@ export const JcbIcon = (props: PaymentIconProps) => (
|
||||
y2="14.4771"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#007940" />
|
||||
<stop offset="0.2285" stop-color="#00873F" />
|
||||
<stop offset="0.7433" stop-color="#40A737" />
|
||||
<stop offset="1" stop-color="#5CB531" />
|
||||
<stop stopColor="#007940" />
|
||||
<stop offset="0.2285" stopColor="#00873F" />
|
||||
<stop offset="0.7433" stopColor="#40A737" />
|
||||
<stop offset="1" stopColor="#5CB531" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_5382_46863"
|
||||
@@ -88,11 +88,11 @@ export const JcbIcon = (props: PaymentIconProps) => (
|
||||
y2="16.001"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#6C2C2F" />
|
||||
<stop offset="0.1735" stop-color="#882730" />
|
||||
<stop offset="0.5731" stop-color="#BE1833" />
|
||||
<stop offset="0.8585" stop-color="#DC0436" />
|
||||
<stop offset="1" stop-color="#E60039" />
|
||||
<stop stopColor="#6C2C2F" />
|
||||
<stop offset="0.1735" stopColor="#882730" />
|
||||
<stop offset="0.5731" stopColor="#BE1833" />
|
||||
<stop offset="0.8585" stopColor="#DC0436" />
|
||||
<stop offset="1" stopColor="#E60039" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_5382_46863"
|
||||
@@ -102,10 +102,10 @@ export const JcbIcon = (props: PaymentIconProps) => (
|
||||
y2="16.001"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#1F286F" />
|
||||
<stop offset="0.4751" stop-color="#004E94" />
|
||||
<stop offset="0.8261" stop-color="#0066B1" />
|
||||
<stop offset="1" stop-color="#006FBC" />
|
||||
<stop stopColor="#1F286F" />
|
||||
<stop offset="0.4751" stopColor="#004E94" />
|
||||
<stop offset="0.8261" stopColor="#0066B1" />
|
||||
<stop offset="1" stopColor="#006FBC" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_5382_46863">
|
||||
<rect width="48" height="32" rx="3" fill="white" />
|
||||
|
||||
@@ -61,8 +61,8 @@ export const SwishIcon = (props: PaymentIconProps) => {
|
||||
y2="13.9987"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#EF3220" />
|
||||
<stop offset="1" stop-color="#FCD205" />
|
||||
<stop stopColor="#EF3220" />
|
||||
<stop offset="1" stopColor="#FCD205" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_5382_46851"
|
||||
@@ -72,10 +72,10 @@ export const SwishIcon = (props: PaymentIconProps) => {
|
||||
y2="21.8844"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FCD205" />
|
||||
<stop offset="0.263921" stop-color="#F47216" />
|
||||
<stop offset="0.560797" stop-color="#B31A93" />
|
||||
<stop offset="1" stop-color="#2743A0" />
|
||||
<stop stopColor="#FCD205" />
|
||||
<stop offset="0.263921" stopColor="#F47216" />
|
||||
<stop offset="0.560797" stopColor="#B31A93" />
|
||||
<stop offset="1" stopColor="#2743A0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_5382_46851"
|
||||
@@ -85,10 +85,10 @@ export const SwishIcon = (props: PaymentIconProps) => {
|
||||
y2="18.0191"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#7FD3B9" />
|
||||
<stop offset="0.265705" stop-color="#66CDE1" />
|
||||
<stop offset="0.554471" stop-color="#6D8ED1" />
|
||||
<stop offset="1" stop-color="#2743A0" />
|
||||
<stop stopColor="#7FD3B9" />
|
||||
<stop offset="0.265705" stopColor="#66CDE1" />
|
||||
<stop offset="0.554471" stopColor="#6D8ED1" />
|
||||
<stop offset="1" stopColor="#2743A0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_5382_46851"
|
||||
@@ -98,10 +98,10 @@ export const SwishIcon = (props: PaymentIconProps) => {
|
||||
y2="10.1074"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#1E5CB2" />
|
||||
<stop offset="0.246658" stop-color="#4DC4CE" />
|
||||
<stop offset="0.564821" stop-color="#66C657" />
|
||||
<stop offset="1" stop-color="#FCD205" />
|
||||
<stop stopColor="#1E5CB2" />
|
||||
<stop offset="0.246658" stopColor="#4DC4CE" />
|
||||
<stop offset="0.564821" stopColor="#66C657" />
|
||||
<stop offset="1" stopColor="#FCD205" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_5382_46851">
|
||||
<rect width="48" height="32" fill="white" />
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
&[data-invalid] {
|
||||
border-color: var(--Border-Interactive-Error);
|
||||
}
|
||||
&[data-invalid][data-focused] {
|
||||
outline: 2px solid var(--Border-Interactive-Error);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user