diff --git a/.changeset/lemon-rockets-explode.md b/.changeset/lemon-rockets-explode.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/lemon-rockets-explode.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/clerk-js/src/ui.retheme/components/OrganizationProfile/VerifyDomainPage.tsx b/packages/clerk-js/src/ui.retheme/components/OrganizationProfile/VerifyDomainPage.tsx index 2ccd4399f9..f94b205255 100644 --- a/packages/clerk-js/src/ui.retheme/components/OrganizationProfile/VerifyDomainPage.tsx +++ b/packages/clerk-js/src/ui.retheme/components/OrganizationProfile/VerifyDomainPage.tsx @@ -10,13 +10,12 @@ import { FormButtonContainer, FormButtons, useCardState, - useCodeControl, + useFieldOTP, withCardStateProvider, } from '../../elements'; -import { CodeForm } from '../../elements/CodeForm'; -import { useFetch, useLoadingStatus } from '../../hooks'; +import { useFetch } from '../../hooks'; import { useRouter } from '../../router'; -import { handleError, sleep, useFormControl } from '../../utils'; +import { handleError, useFormControl } from '../../utils'; import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar'; export const VerifyDomainPage = withCardStateProvider(() => { @@ -25,8 +24,6 @@ export const VerifyDomainPage = withCardStateProvider(() => { const { organization } = useCoreOrganization(); const { params, navigate } = useRouter(); - const [success, setSuccess] = React.useState(false); - const { data: domain, status: domainStatus } = useFetch(organization?.getDomain, { domainId: params.id, }); @@ -36,12 +33,6 @@ export const VerifyDomainPage = withCardStateProvider(() => { }); const breadcrumbTitle = localizationKeys('organizationProfile.profilePage.domainSection.title'); - - const status = useLoadingStatus(); - - const codeControlState = useFormControl('code', ''); - const codeControl = useCodeControl(codeControlState); - const wizard = useWizard({ onNextStep: () => card.setError(undefined) }); const emailField = useFormControl('affiliationEmailAddress', '', { @@ -49,6 +40,7 @@ export const VerifyDomainPage = withCardStateProvider(() => { label: localizationKeys('formFieldLabel__organizationDomainEmailAddress'), placeholder: localizationKeys('formFieldInputPlaceholder__organizationDomainEmailAddress'), infoText: localizationKeys('formFieldLabel__organizationDomainEmailAddressDescription'), + isRequired: true, }); const affiliationEmailAddressRef = useRef(); @@ -60,11 +52,6 @@ export const VerifyDomainPage = withCardStateProvider(() => { }, ); - const resolve = async () => { - setSuccess(true); - await sleep(750); - }; - const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => { domain ?.attemptAffiliationVerification?.({ code }) @@ -78,30 +65,21 @@ export const VerifyDomainPage = withCardStateProvider(() => { .catch(err => reject(err)); }; - const reject = async (err: any) => { - handleError(err, [codeControlState], card.setError); - status.setIdle(); - await sleep(750); - codeControl.reset(); - }; - - codeControl.onCodeEntryFinished(code => { - status.setLoading(); - codeControlState.setError(undefined); - action(code, resolve, reject); + const otp = useFieldOTP({ + onCodeEntryFinished: (code, resolve, reject) => { + action(code, resolve, reject); + }, + onResendCodeClicked: () => { + domain?.prepareAffiliationVerification({ affiliationEmailAddress: emailField.value }).catch(err => { + handleError(err, [emailField], card.setError); + }); + }, }); if (!organization || !organizationSettings) { return null; } - const handleResend = () => { - codeControl.reset(); - domain?.prepareAffiliationVerification({ affiliationEmailAddress: emailField.value }).catch(err => { - handleError(err, [emailField], card.setError); - }); - }; - const dataChanged = organization.name !== emailField.value; const canSubmit = dataChanged; const emailDomainSuffix = `@${domain?.name}`; @@ -155,7 +133,6 @@ export const VerifyDomainPage = withCardStateProvider(() => { {...emailField.props} autoFocus groupSuffix={emailDomainSuffix} - isRequired /> @@ -168,14 +145,11 @@ export const VerifyDomainPage = withCardStateProvider(() => { headerSubtitle={subtitleVerificationCodeScreen} Breadcrumbs={OrganizationProfileBreadcrumbs} > - @@ -185,10 +159,10 @@ export const VerifyDomainPage = withCardStateProvider(() => { variant='ghost' textVariant='buttonExtraSmallBold' type='reset' - isDisabled={status.isLoading || success} + isDisabled={otp.isLoading || otp.otpControl.otpInputProps.feedbackType === 'success'} onClick={() => { - codeControlState.clearFeedback(); - codeControl.reset(); + otp.otpControl.otpInputProps.clearFeedback(); + otp.otpControl.reset(); wizard.prevStep(); }} localizationKey={localizationKeys('userProfile.formButtonReset')} diff --git a/packages/clerk-js/src/ui.retheme/components/SignIn/SignInFactorTwoBackupCodeCard.tsx b/packages/clerk-js/src/ui.retheme/components/SignIn/SignInFactorTwoBackupCodeCard.tsx index e368b6288e..086fcc37aa 100644 --- a/packages/clerk-js/src/ui.retheme/components/SignIn/SignInFactorTwoBackupCodeCard.tsx +++ b/packages/clerk-js/src/ui.retheme/components/SignIn/SignInFactorTwoBackupCodeCard.tsx @@ -83,7 +83,7 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa > - (null); - const switchToNextIdentifier = () => { setIdentifierAttribute( i => identifierAttributes[(identifierAttributes.indexOf(i) + 1) % identifierAttributes.length], @@ -273,6 +271,17 @@ export function _SignInStart(): JSX.Element { return signInWithFields(identifierField, instantPasswordField); }; + const DynamicField = useMemo(() => { + const components = { + tel: Form.PhoneInput, + password: Form.PasswordInput, + text: Form.PlainInput, + email: Form.PlainInput, + }; + + return components[identifierField.type as keyof typeof components]; + }, [identifierField.type]); + if (status.isLoading) { return ; } @@ -300,8 +309,7 @@ export function _SignInStart(): JSX.Element { {standardFormAttributes.length ? ( - { )} {shouldShow('phoneNumber') && ( - handleEmailPhoneToggle('emailAddress') : undefined} /> @@ -83,11 +81,9 @@ export const SignUpForm = (props: SignUpFormProps) => { {shouldShow('password') && ( )} diff --git a/packages/clerk-js/src/ui.retheme/components/UserProfile/PhonePage.tsx b/packages/clerk-js/src/ui.retheme/components/UserProfile/PhonePage.tsx index be65f1a621..8ede7518f0 100644 --- a/packages/clerk-js/src/ui.retheme/components/UserProfile/PhonePage.tsx +++ b/packages/clerk-js/src/ui.retheme/components/UserProfile/PhonePage.tsx @@ -80,9 +80,8 @@ export const AddPhone = (props: AddPhoneProps) => { > - diff --git a/packages/clerk-js/src/ui.retheme/components/UserProfile/VerifyTOTP.tsx b/packages/clerk-js/src/ui.retheme/components/UserProfile/VerifyTOTP.tsx index f611c84cbf..5a3b8dad27 100644 --- a/packages/clerk-js/src/ui.retheme/components/UserProfile/VerifyTOTP.tsx +++ b/packages/clerk-js/src/ui.retheme/components/UserProfile/VerifyTOTP.tsx @@ -3,16 +3,7 @@ import React from 'react'; import { useCoreUser } from '../../contexts'; import { Col, descriptors, localizationKeys } from '../../customizables'; -import { - ContentPage, - FormButtonContainer, - NavigateToFlowStartButton, - useCardState, - useCodeControl, -} from '../../elements'; -import { CodeForm } from '../../elements/CodeForm'; -import { useLoadingStatus } from '../../hooks'; -import { handleError, sleep, useFormControl } from '../../utils'; +import { ContentPage, Form, FormButtonContainer, NavigateToFlowStartButton, useFieldOTP } from '../../elements'; import { UserProfileBreadcrumbs } from './UserProfileNavbar'; type VerifyTOTPProps = { @@ -22,34 +13,19 @@ type VerifyTOTPProps = { export const VerifyTOTP = (props: VerifyTOTPProps) => { const { onVerified, resourceRef } = props; - const card = useCardState(); const user = useCoreUser(); - const status = useLoadingStatus(); - const [success, setSuccess] = React.useState(false); - const codeControlState = useFormControl('code', ''); - const codeControl = useCodeControl(codeControlState); - const resolve = async (totp: TOTPResource) => { - setSuccess(true); - resourceRef.current = totp; - await sleep(750); - onVerified(); - }; - - const reject = async (err: any) => { - handleError(err, [codeControlState], card.setError); - status.setIdle(); - await sleep(750); - codeControl.reset(); - }; - - codeControl.onCodeEntryFinished(code => { - status.setLoading(); - codeControlState.setError(undefined); - return user - .verifyTOTP({ code }) - .then((totp: TOTPResource) => resolve(totp)) - .catch(reject); + const otp = useFieldOTP({ + onCodeEntryFinished: (code, resolve, reject) => { + user + .verifyTOTP({ code }) + .then((totp: TOTPResource) => resolve(totp)) + .catch(reject); + }, + onResolve: a => { + resourceRef.current = a; + onVerified(); + }, }); return ( @@ -58,12 +34,10 @@ export const VerifyTOTP = (props: VerifyTOTPProps) => { Breadcrumbs={UserProfileBreadcrumbs} > - diff --git a/packages/clerk-js/src/ui.retheme/components/UserProfile/VerifyWithCode.tsx b/packages/clerk-js/src/ui.retheme/components/UserProfile/VerifyWithCode.tsx index 173ae5ecf8..8dc2cb2ae8 100644 --- a/packages/clerk-js/src/ui.retheme/components/UserProfile/VerifyWithCode.tsx +++ b/packages/clerk-js/src/ui.retheme/components/UserProfile/VerifyWithCode.tsx @@ -2,10 +2,8 @@ import type { EmailAddressResource, PhoneNumberResource } from '@clerk/types'; import React from 'react'; import { descriptors, localizationKeys } from '../../customizables'; -import { FormButtonContainer, NavigateToFlowStartButton, useCardState, useCodeControl } from '../../elements'; -import { CodeForm } from '../../elements/CodeForm'; -import { useLoadingStatus } from '../../hooks'; -import { handleError, sleep, useFormControl } from '../../utils'; +import { Form, FormButtonContainer, NavigateToFlowStartButton, useCardState, useFieldOTP } from '../../elements'; +import { handleError } from '../../utils'; type VerifyWithCodeProps = { nextStep: () => void; @@ -17,51 +15,33 @@ type VerifyWithCodeProps = { export const VerifyWithCode = (props: VerifyWithCodeProps) => { const card = useCardState(); const { nextStep, identification, identifier, prepareVerification } = props; - const [success, setSuccess] = React.useState(false); - const status = useLoadingStatus(); - const codeControlState = useFormControl('code', ''); - const codeControl = useCodeControl(codeControlState); - - React.useEffect(() => { - void prepare(); - }, []); const prepare = () => { return prepareVerification?.()?.catch(err => handleError(err, [], card.setError)); }; - const resolve = async () => { - setSuccess(true); - await sleep(750); - nextStep(); - }; - - const reject = async (err: any) => { - handleError(err, [codeControlState], card.setError); - status.setIdle(); - await sleep(750); - codeControl.reset(); - }; - - codeControl.onCodeEntryFinished(code => { - status.setLoading(); - codeControlState.setError(undefined); - return identification - ?.attemptVerification({ code: code }) - .then(() => resolve()) - .catch(reject); + const otp = useFieldOTP({ + onCodeEntryFinished: (code, resolve, reject) => { + identification + ?.attemptVerification({ code: code }) + .then(() => resolve()) + .catch(reject); + }, + onResendCodeClicked: prepare, + onResolve: nextStep, }); + React.useEffect(() => { + void prepare(); + }, []); + return ( <> - unknown; type onCodeEntryFinished = (cb: onCodeEntryFinishedCallback) => void; -type UseCodeControlReturn = ReturnType; +type onCodeEntryFinishedActionCallback = ( + code: string, + resolve: (params?: R) => Promise, + reject: (err: unknown) => Promise, +) => void; -export const useCodeControl = (formControl: FormControlState, options?: UseCodeInputOptions) => { +type UseFieldOTP = (params: { + id?: 'code'; + onCodeEntryFinished: onCodeEntryFinishedActionCallback; + onResendCodeClicked?: React.MouseEventHandler; + onResolve?: (a?: R) => Promise | void; +}) => { + isLoading: boolean; + otpControl: ReturnType; + onResendCode: React.MouseEventHandler | undefined; +}; + +export const useFieldOTP: UseFieldOTP = params => { + const card = useCardState(); + const { + id = 'code', + onCodeEntryFinished: paramsOnCodeEntryFinished, + onResendCodeClicked: paramsOnResendCodeClicked, + onResolve: paramsOnResolve, + } = params; + const codeControlState = useFormControl(id, ''); + const codeControl = useCodeControl(codeControlState); + const status = useLoadingStatus(); + + const resolve = async (param: any) => { + // TODO: Localize this + codeControlState.setSuccess('success'); + await sleep(750); + await paramsOnResolve?.(param); + }; + + const reject = async (err: any) => { + handleError(err, [codeControlState], card.setError); + status.setIdle(); + await sleep(750); + codeControl.reset(); + }; + + codeControl.onCodeEntryFinished(code => { + status.setLoading(); + codeControlState.setError(undefined); + paramsOnCodeEntryFinished(code, resolve, reject); + }); + + const onResendCode = useCallback>( + e => { + codeControl.reset(); + paramsOnResendCodeClicked?.(e); + }, + [codeControl, paramsOnResendCodeClicked], + ); + + return { + isLoading: status.isLoading, + otpControl: codeControl, + onResendCode: paramsOnResendCodeClicked ? onResendCode : undefined, + }; +}; + +const useCodeControl = (formControl: FormControlState, options?: UseCodeInputOptions) => { const otpControlRef = React.useRef(); const userOnCodeEnteredCallback = React.useRef(); const defaultValue = formControl.value; - const { feedback, feedbackType, onChange } = formControl; + const { feedback, feedbackType, onChange, clearFeedback } = formControl; const { length = 6 } = options || {}; const [values, setValues] = React.useState(() => defaultValue ? defaultValue.split('').slice(0, length) : Array.from({ length }, () => ''), @@ -40,22 +108,76 @@ export const useCodeControl = (formControl: FormControlState, options?: UseCodeI } }, [values.toString()]); - const otpInputProps = { length, values, setValues, feedback, feedbackType, ref: otpControlRef }; + const otpInputProps = { length, values, setValues, feedback, feedbackType, clearFeedback, ref: otpControlRef }; return { otpInputProps, onCodeEntryFinished, reset: () => otpControlRef.current?.reset() }; }; -type CodeControlProps = UseCodeControlReturn['otpInputProps'] & { +export type OTPInputProps = { + label: string | LocalizationKey; + description: string | LocalizationKey; + resendButton?: LocalizationKey; + isLoading: boolean; isDisabled?: boolean; - errorText?: string; - isSuccessfullyFilled?: boolean; - isLoading?: boolean; + onResendCode?: React.MouseEventHandler; + otpControl: ReturnType['otpControl']; }; -export const CodeControl = React.forwardRef<{ reset: any }, CodeControlProps>((props, ref) => { +const [OTPInputContext, useOTPInputContext] = createContextAndHook('OTPInputContext'); + +export const OTPRoot = ({ children, ...props }: PropsWithChildren) => { + return {children}; +}; + +export const OTPInputLabel = () => { + const { label } = useOTPInputContext(); + return ( + + ); +}; + +export const OTPInputDescription = () => { + const { description } = useOTPInputContext(); + return ( + + ); +}; + +export const OTPResendButton = () => { + const { resendButton, onResendCode, isLoading, otpControl } = useOTPInputContext(); + + if (!onResendCode) { + return null; + } + + return ( + ({ marginTop: theme.space.$6 })} + localizationKey={resendButton} + /> + ); +}; + +export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { const [disabled, setDisabled] = React.useState(false); const refs = React.useRef>([]); const firstClickRef = React.useRef(false); - const { values, setValues, isDisabled, feedback, feedbackType, isSuccessfullyFilled, isLoading, length } = props; + + const { otpControl, isLoading, isDisabled } = useOTPInputContext(); + const { feedback, values, setValues, feedbackType, length } = otpControl.otpInputProps; React.useImperativeHandle(ref, () => ({ reset: () => { @@ -166,55 +288,41 @@ export const CodeControl = React.forwardRef<{ reset: any }, CodeControlProps>((p return ( - - {values.map((value, index: number) => ( - (refs.current[index] = node)} - autoFocus={index === 0 || undefined} - autoComplete='one-time-code' - aria-label={`${index === 0 ? 'Enter verification code. ' : ''} Digit ${index + 1}`} - isDisabled={isDisabled || isLoading || disabled || isSuccessfullyFilled} - hasError={feedbackType === 'error'} - isSuccessfullyFilled={isSuccessfullyFilled} - type='text' - inputMode='numeric' - name={`codeInput-${index}`} - /> - ))} - {isLoading && ( - ({ marginLeft: theme.space.$2 })} - /> - )} - - + {values.map((value, index: number) => ( + (refs.current[index] = node)} + autoFocus={index === 0 || undefined} + autoComplete='one-time-code' + aria-label={`${index === 0 ? 'Enter verification code. ' : ''} Digit ${index + 1}`} + isDisabled={isDisabled || isLoading || disabled || feedbackType === 'success'} + hasError={feedbackType === 'error'} + isSuccessfullyFilled={feedbackType === 'success'} + type='text' + inputMode='numeric' + name={`codeInput-${index}`} + /> + ))} + {isLoading && ( + ({ marginLeft: theme.space.$2 })} + /> + )} ); }); diff --git a/packages/clerk-js/src/ui.retheme/elements/CodeForm.tsx b/packages/clerk-js/src/ui.retheme/elements/CodeForm.tsx deleted file mode 100644 index 597a5045a1..0000000000 --- a/packages/clerk-js/src/ui.retheme/elements/CodeForm.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; - -import type { LocalizationKey } from '../customizables'; -import { Col, descriptors, Text } from '../customizables'; -import type { useCodeControl } from './CodeControl'; -import { CodeControl } from './CodeControl'; -import { TimerButton } from './TimerButton'; - -type CodeFormProps = { - title: LocalizationKey; - subtitle: LocalizationKey; - resendButton?: LocalizationKey; - isLoading: boolean; - success: boolean; - onResendCodeClicked?: React.MouseEventHandler; - codeControl: ReturnType; -}; - -export const CodeForm = (props: CodeFormProps) => { - const { subtitle, title, isLoading, success, codeControl, onResendCodeClicked, resendButton } = props; - - return ( - - - - - {onResendCodeClicked && ( - ({ marginTop: theme.space.$6 })} - localizationKey={resendButton} - /> - )} - - ); -}; diff --git a/packages/clerk-js/src/ui.retheme/elements/FieldControl.tsx b/packages/clerk-js/src/ui.retheme/elements/FieldControl.tsx index 8ffecae983..4a3d842879 100644 --- a/packages/clerk-js/src/ui.retheme/elements/FieldControl.tsx +++ b/packages/clerk-js/src/ui.retheme/elements/FieldControl.tsx @@ -19,11 +19,13 @@ import { FormFieldContextProvider, sanitizeInputProps, useFormField } from '../p import type { PropsOfComponent } from '../styledSystem'; import type { useFormControl as useFormControlUtil } from '../utils'; import { useFormControlFeedback } from '../utils'; +import { OTPCodeControl, OTPInputDescription, OTPInputLabel, OTPResendButton, OTPRoot } from './CodeControl'; import { useCardState } from './contexts'; import type { FormFeedbackProps } from './FormControl'; import { FormFeedback } from './FormControl'; import { InputGroup } from './InputGroup'; import { PasswordInput } from './PasswordInput'; +import { PhoneInput } from './PhoneInput'; import { RadioItem, RadioLabel } from './RadioGroup'; type FormControlProps = Omit, 'label' | 'placeholder' | 'disabled' | 'required'> & @@ -199,6 +201,22 @@ const FieldFeedback = (props: Pick) => ); }; +const PhoneInputElement = forwardRef((_, ref) => { + const { t } = useLocalizations(); + const formField = useFormField(); + const { placeholder, ...inputProps } = sanitizeInputProps(formField); + + return ( + + ); +}); + const PasswordInputElement = forwardRef((_, ref) => { const { t } = useLocalizations(); const formField = useFormField(); @@ -304,6 +322,7 @@ export const Field = { LabelRow: FieldLabelRow, Input: InputElement, PasswordInput: PasswordInputElement, + PhoneInput: PhoneInputElement, InputGroup: InputGroupElement, RadioItem: RadioItem, CheckboxIndicator: CheckboxIndicator, @@ -312,4 +331,9 @@ export const Field = { AsOptional: FieldOptionalLabel, LabelIcon: FieldLabelIcon, Feedback: FieldFeedback, + OTPRoot, + OTPInputLabel, + OTPInputDescription, + OTPCodeControl, + OTPResendButton, }; diff --git a/packages/clerk-js/src/ui.retheme/elements/Form.tsx b/packages/clerk-js/src/ui.retheme/elements/Form.tsx index 5286d3150b..19c15e0bd4 100644 --- a/packages/clerk-js/src/ui.retheme/elements/Form.tsx +++ b/packages/clerk-js/src/ui.retheme/elements/Form.tsx @@ -1,12 +1,13 @@ import { createContextAndHook } from '@clerk/shared/react'; import type { FieldId } from '@clerk/types'; import type { PropsWithChildren } from 'react'; -import React, { useState } from 'react'; +import React, { forwardRef, useState } from 'react'; import type { LocalizationKey } from '../customizables'; import { Button, Col, descriptors, Flex, Form as FormPrim, localizationKeys } from '../customizables'; import { useLoadingStatus } from '../hooks'; import type { PropsOfComponent } from '../styledSystem'; +import type { OTPInputProps } from './CodeControl'; import { useCardState } from './contexts'; import { Field } from './FieldControl'; import { FormControl } from './FormControl'; @@ -157,10 +158,18 @@ const PlainInput = (props: CommonInputProps) => { ); }; -const PasswordInput = (props: CommonInputProps) => { +const PasswordInput = forwardRef((props, ref) => { return ( - + + + ); +}); + +const PhoneInput = (props: CommonInputProps) => { + return ( + + ); }; @@ -219,6 +228,39 @@ const RadioGroup = ( ); }; +const OTPInput = (props: OTPInputProps) => { + const { ref, ...restInputProps } = props.otpControl.otpInputProps; + return ( + // Use Field.Root in order to pass feedback down to Field.Feedback + // @ts-ignore + + + + + + + + + + + + + + ); +}; + export const Form = { Root: FormRoot, ControlRow: FormControlRow, @@ -228,6 +270,8 @@ export const Form = { Control: FormControl, PlainInput, PasswordInput, + PhoneInput, + OTPInput, InputGroup, RadioGroup, Checkbox, diff --git a/packages/clerk-js/src/ui.retheme/elements/VerificationCodeCard.tsx b/packages/clerk-js/src/ui.retheme/elements/VerificationCodeCard.tsx index be4f1eeeed..032a4def89 100644 --- a/packages/clerk-js/src/ui.retheme/elements/VerificationCodeCard.tsx +++ b/packages/clerk-js/src/ui.retheme/elements/VerificationCodeCard.tsx @@ -2,15 +2,13 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; import { Col, descriptors, localizationKeys } from '../customizables'; -import { useLoadingStatus } from '../hooks'; import type { LocalizationKey } from '../localization'; -import { handleError, sleep, useFormControl } from '../utils'; import { CardAlert } from './Alert'; import { Card } from './Card'; -import { useCodeControl } from './CodeControl'; -import { CodeForm } from './CodeForm'; +import { useFieldOTP } from './CodeControl'; import { useCardState } from './contexts'; import { Footer } from './Footer'; +import { Form } from './Form'; import { Header } from './Header'; import { IdentityPreview } from './IdentityPreview'; @@ -36,37 +34,15 @@ export type VerificationCodeCardProps = { export const VerificationCodeCard = (props: PropsWithChildren) => { const { showAlternativeMethods = true, children } = props; - const [success, setSuccess] = React.useState(false); - const status = useLoadingStatus(); - const codeControlState = useFormControl('code', ''); - const codeControl = useCodeControl(codeControlState); const card = useCardState(); - const resolve = async () => { - setSuccess(true); - await sleep(750); - }; - - const reject = async (err: any) => { - handleError(err, [codeControlState], card.setError); - status.setIdle(); - await sleep(750); - codeControl.reset(); - }; - - codeControl.onCodeEntryFinished(code => { - status.setLoading(); - codeControlState.setError(undefined); - props.onCodeEntryFinishedAction(code, resolve, reject); + const otp = useFieldOTP({ + onCodeEntryFinished: (code, resolve, reject) => { + props.onCodeEntryFinishedAction(code, resolve, reject); + }, + onResendCodeClicked: props.onResendCodeClicked, }); - const handleResend = props.onResendCodeClicked - ? (e: React.MouseEvent) => { - codeControl.reset(); - props.onResendCodeClicked?.(e); - } - : undefined; - return ( {card.error} @@ -85,14 +61,11 @@ export const VerificationCodeCard = (props: PropsWithChildren - diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx index 2ccd4399f9..f94b205255 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx @@ -10,13 +10,12 @@ import { FormButtonContainer, FormButtons, useCardState, - useCodeControl, + useFieldOTP, withCardStateProvider, } from '../../elements'; -import { CodeForm } from '../../elements/CodeForm'; -import { useFetch, useLoadingStatus } from '../../hooks'; +import { useFetch } from '../../hooks'; import { useRouter } from '../../router'; -import { handleError, sleep, useFormControl } from '../../utils'; +import { handleError, useFormControl } from '../../utils'; import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar'; export const VerifyDomainPage = withCardStateProvider(() => { @@ -25,8 +24,6 @@ export const VerifyDomainPage = withCardStateProvider(() => { const { organization } = useCoreOrganization(); const { params, navigate } = useRouter(); - const [success, setSuccess] = React.useState(false); - const { data: domain, status: domainStatus } = useFetch(organization?.getDomain, { domainId: params.id, }); @@ -36,12 +33,6 @@ export const VerifyDomainPage = withCardStateProvider(() => { }); const breadcrumbTitle = localizationKeys('organizationProfile.profilePage.domainSection.title'); - - const status = useLoadingStatus(); - - const codeControlState = useFormControl('code', ''); - const codeControl = useCodeControl(codeControlState); - const wizard = useWizard({ onNextStep: () => card.setError(undefined) }); const emailField = useFormControl('affiliationEmailAddress', '', { @@ -49,6 +40,7 @@ export const VerifyDomainPage = withCardStateProvider(() => { label: localizationKeys('formFieldLabel__organizationDomainEmailAddress'), placeholder: localizationKeys('formFieldInputPlaceholder__organizationDomainEmailAddress'), infoText: localizationKeys('formFieldLabel__organizationDomainEmailAddressDescription'), + isRequired: true, }); const affiliationEmailAddressRef = useRef(); @@ -60,11 +52,6 @@ export const VerifyDomainPage = withCardStateProvider(() => { }, ); - const resolve = async () => { - setSuccess(true); - await sleep(750); - }; - const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => { domain ?.attemptAffiliationVerification?.({ code }) @@ -78,30 +65,21 @@ export const VerifyDomainPage = withCardStateProvider(() => { .catch(err => reject(err)); }; - const reject = async (err: any) => { - handleError(err, [codeControlState], card.setError); - status.setIdle(); - await sleep(750); - codeControl.reset(); - }; - - codeControl.onCodeEntryFinished(code => { - status.setLoading(); - codeControlState.setError(undefined); - action(code, resolve, reject); + const otp = useFieldOTP({ + onCodeEntryFinished: (code, resolve, reject) => { + action(code, resolve, reject); + }, + onResendCodeClicked: () => { + domain?.prepareAffiliationVerification({ affiliationEmailAddress: emailField.value }).catch(err => { + handleError(err, [emailField], card.setError); + }); + }, }); if (!organization || !organizationSettings) { return null; } - const handleResend = () => { - codeControl.reset(); - domain?.prepareAffiliationVerification({ affiliationEmailAddress: emailField.value }).catch(err => { - handleError(err, [emailField], card.setError); - }); - }; - const dataChanged = organization.name !== emailField.value; const canSubmit = dataChanged; const emailDomainSuffix = `@${domain?.name}`; @@ -155,7 +133,6 @@ export const VerifyDomainPage = withCardStateProvider(() => { {...emailField.props} autoFocus groupSuffix={emailDomainSuffix} - isRequired /> @@ -168,14 +145,11 @@ export const VerifyDomainPage = withCardStateProvider(() => { headerSubtitle={subtitleVerificationCodeScreen} Breadcrumbs={OrganizationProfileBreadcrumbs} > - @@ -185,10 +159,10 @@ export const VerifyDomainPage = withCardStateProvider(() => { variant='ghost' textVariant='buttonExtraSmallBold' type='reset' - isDisabled={status.isLoading || success} + isDisabled={otp.isLoading || otp.otpControl.otpInputProps.feedbackType === 'success'} onClick={() => { - codeControlState.clearFeedback(); - codeControl.reset(); + otp.otpControl.otpInputProps.clearFeedback(); + otp.otpControl.reset(); wizard.prevStep(); }} localizationKey={localizationKeys('userProfile.formButtonReset')} diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx index e368b6288e..086fcc37aa 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx @@ -83,7 +83,7 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa > - (null); - const switchToNextIdentifier = () => { setIdentifierAttribute( i => identifierAttributes[(identifierAttributes.indexOf(i) + 1) % identifierAttributes.length], @@ -273,6 +271,17 @@ export function _SignInStart(): JSX.Element { return signInWithFields(identifierField, instantPasswordField); }; + const DynamicField = useMemo(() => { + const components = { + tel: Form.PhoneInput, + password: Form.PasswordInput, + text: Form.PlainInput, + email: Form.PlainInput, + }; + + return components[identifierField.type as keyof typeof components]; + }, [identifierField.type]); + if (status.isLoading) { return ; } @@ -300,8 +309,7 @@ export function _SignInStart(): JSX.Element { {standardFormAttributes.length ? ( - { )} {shouldShow('phoneNumber') && ( - handleEmailPhoneToggle('emailAddress') : undefined} /> @@ -83,11 +81,9 @@ export const SignUpForm = (props: SignUpFormProps) => { {shouldShow('password') && ( )} diff --git a/packages/clerk-js/src/ui/components/UserProfile/PhonePage.tsx b/packages/clerk-js/src/ui/components/UserProfile/PhonePage.tsx index be65f1a621..8ede7518f0 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PhonePage.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PhonePage.tsx @@ -80,9 +80,8 @@ export const AddPhone = (props: AddPhoneProps) => { > - diff --git a/packages/clerk-js/src/ui/components/UserProfile/VerifyTOTP.tsx b/packages/clerk-js/src/ui/components/UserProfile/VerifyTOTP.tsx index f611c84cbf..5a3b8dad27 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/VerifyTOTP.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/VerifyTOTP.tsx @@ -3,16 +3,7 @@ import React from 'react'; import { useCoreUser } from '../../contexts'; import { Col, descriptors, localizationKeys } from '../../customizables'; -import { - ContentPage, - FormButtonContainer, - NavigateToFlowStartButton, - useCardState, - useCodeControl, -} from '../../elements'; -import { CodeForm } from '../../elements/CodeForm'; -import { useLoadingStatus } from '../../hooks'; -import { handleError, sleep, useFormControl } from '../../utils'; +import { ContentPage, Form, FormButtonContainer, NavigateToFlowStartButton, useFieldOTP } from '../../elements'; import { UserProfileBreadcrumbs } from './UserProfileNavbar'; type VerifyTOTPProps = { @@ -22,34 +13,19 @@ type VerifyTOTPProps = { export const VerifyTOTP = (props: VerifyTOTPProps) => { const { onVerified, resourceRef } = props; - const card = useCardState(); const user = useCoreUser(); - const status = useLoadingStatus(); - const [success, setSuccess] = React.useState(false); - const codeControlState = useFormControl('code', ''); - const codeControl = useCodeControl(codeControlState); - const resolve = async (totp: TOTPResource) => { - setSuccess(true); - resourceRef.current = totp; - await sleep(750); - onVerified(); - }; - - const reject = async (err: any) => { - handleError(err, [codeControlState], card.setError); - status.setIdle(); - await sleep(750); - codeControl.reset(); - }; - - codeControl.onCodeEntryFinished(code => { - status.setLoading(); - codeControlState.setError(undefined); - return user - .verifyTOTP({ code }) - .then((totp: TOTPResource) => resolve(totp)) - .catch(reject); + const otp = useFieldOTP({ + onCodeEntryFinished: (code, resolve, reject) => { + user + .verifyTOTP({ code }) + .then((totp: TOTPResource) => resolve(totp)) + .catch(reject); + }, + onResolve: a => { + resourceRef.current = a; + onVerified(); + }, }); return ( @@ -58,12 +34,10 @@ export const VerifyTOTP = (props: VerifyTOTPProps) => { Breadcrumbs={UserProfileBreadcrumbs} > - diff --git a/packages/clerk-js/src/ui/components/UserProfile/VerifyWithCode.tsx b/packages/clerk-js/src/ui/components/UserProfile/VerifyWithCode.tsx index 173ae5ecf8..8dc2cb2ae8 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/VerifyWithCode.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/VerifyWithCode.tsx @@ -2,10 +2,8 @@ import type { EmailAddressResource, PhoneNumberResource } from '@clerk/types'; import React from 'react'; import { descriptors, localizationKeys } from '../../customizables'; -import { FormButtonContainer, NavigateToFlowStartButton, useCardState, useCodeControl } from '../../elements'; -import { CodeForm } from '../../elements/CodeForm'; -import { useLoadingStatus } from '../../hooks'; -import { handleError, sleep, useFormControl } from '../../utils'; +import { Form, FormButtonContainer, NavigateToFlowStartButton, useCardState, useFieldOTP } from '../../elements'; +import { handleError } from '../../utils'; type VerifyWithCodeProps = { nextStep: () => void; @@ -17,51 +15,33 @@ type VerifyWithCodeProps = { export const VerifyWithCode = (props: VerifyWithCodeProps) => { const card = useCardState(); const { nextStep, identification, identifier, prepareVerification } = props; - const [success, setSuccess] = React.useState(false); - const status = useLoadingStatus(); - const codeControlState = useFormControl('code', ''); - const codeControl = useCodeControl(codeControlState); - - React.useEffect(() => { - void prepare(); - }, []); const prepare = () => { return prepareVerification?.()?.catch(err => handleError(err, [], card.setError)); }; - const resolve = async () => { - setSuccess(true); - await sleep(750); - nextStep(); - }; - - const reject = async (err: any) => { - handleError(err, [codeControlState], card.setError); - status.setIdle(); - await sleep(750); - codeControl.reset(); - }; - - codeControl.onCodeEntryFinished(code => { - status.setLoading(); - codeControlState.setError(undefined); - return identification - ?.attemptVerification({ code: code }) - .then(() => resolve()) - .catch(reject); + const otp = useFieldOTP({ + onCodeEntryFinished: (code, resolve, reject) => { + identification + ?.attemptVerification({ code: code }) + .then(() => resolve()) + .catch(reject); + }, + onResendCodeClicked: prepare, + onResolve: nextStep, }); + React.useEffect(() => { + void prepare(); + }, []); + return ( <> - unknown; type onCodeEntryFinished = (cb: onCodeEntryFinishedCallback) => void; -type UseCodeControlReturn = ReturnType; +type onCodeEntryFinishedActionCallback = ( + code: string, + resolve: (params?: R) => Promise, + reject: (err: unknown) => Promise, +) => void; -export const useCodeControl = (formControl: FormControlState, options?: UseCodeInputOptions) => { +type UseFieldOTP = (params: { + id?: 'code'; + onCodeEntryFinished: onCodeEntryFinishedActionCallback; + onResendCodeClicked?: React.MouseEventHandler; + onResolve?: (a?: R) => Promise | void; +}) => { + isLoading: boolean; + otpControl: ReturnType; + onResendCode: React.MouseEventHandler | undefined; +}; + +export const useFieldOTP: UseFieldOTP = params => { + const card = useCardState(); + const { + id = 'code', + onCodeEntryFinished: paramsOnCodeEntryFinished, + onResendCodeClicked: paramsOnResendCodeClicked, + onResolve: paramsOnResolve, + } = params; + const codeControlState = useFormControl(id, ''); + const codeControl = useCodeControl(codeControlState); + const status = useLoadingStatus(); + + const resolve = async (param: any) => { + // TODO: Localize this + codeControlState.setSuccess('success'); + await sleep(750); + await paramsOnResolve?.(param); + }; + + const reject = async (err: any) => { + handleError(err, [codeControlState], card.setError); + status.setIdle(); + await sleep(750); + codeControl.reset(); + }; + + codeControl.onCodeEntryFinished(code => { + status.setLoading(); + codeControlState.setError(undefined); + paramsOnCodeEntryFinished(code, resolve, reject); + }); + + const onResendCode = useCallback>( + e => { + codeControl.reset(); + paramsOnResendCodeClicked?.(e); + }, + [codeControl, paramsOnResendCodeClicked], + ); + + return { + isLoading: status.isLoading, + otpControl: codeControl, + onResendCode: paramsOnResendCodeClicked ? onResendCode : undefined, + }; +}; + +const useCodeControl = (formControl: FormControlState, options?: UseCodeInputOptions) => { const otpControlRef = React.useRef(); const userOnCodeEnteredCallback = React.useRef(); const defaultValue = formControl.value; - const { feedback, feedbackType, onChange } = formControl; + const { feedback, feedbackType, onChange, clearFeedback } = formControl; const { length = 6 } = options || {}; const [values, setValues] = React.useState(() => defaultValue ? defaultValue.split('').slice(0, length) : Array.from({ length }, () => ''), @@ -40,22 +108,76 @@ export const useCodeControl = (formControl: FormControlState, options?: UseCodeI } }, [values.toString()]); - const otpInputProps = { length, values, setValues, feedback, feedbackType, ref: otpControlRef }; + const otpInputProps = { length, values, setValues, feedback, feedbackType, clearFeedback, ref: otpControlRef }; return { otpInputProps, onCodeEntryFinished, reset: () => otpControlRef.current?.reset() }; }; -type CodeControlProps = UseCodeControlReturn['otpInputProps'] & { +export type OTPInputProps = { + label: string | LocalizationKey; + description: string | LocalizationKey; + resendButton?: LocalizationKey; + isLoading: boolean; isDisabled?: boolean; - errorText?: string; - isSuccessfullyFilled?: boolean; - isLoading?: boolean; + onResendCode?: React.MouseEventHandler; + otpControl: ReturnType['otpControl']; }; -export const CodeControl = React.forwardRef<{ reset: any }, CodeControlProps>((props, ref) => { +const [OTPInputContext, useOTPInputContext] = createContextAndHook('OTPInputContext'); + +export const OTPRoot = ({ children, ...props }: PropsWithChildren) => { + return {children}; +}; + +export const OTPInputLabel = () => { + const { label } = useOTPInputContext(); + return ( + + ); +}; + +export const OTPInputDescription = () => { + const { description } = useOTPInputContext(); + return ( + + ); +}; + +export const OTPResendButton = () => { + const { resendButton, onResendCode, isLoading, otpControl } = useOTPInputContext(); + + if (!onResendCode) { + return null; + } + + return ( + ({ marginTop: theme.space.$6 })} + localizationKey={resendButton} + /> + ); +}; + +export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { const [disabled, setDisabled] = React.useState(false); const refs = React.useRef>([]); const firstClickRef = React.useRef(false); - const { values, setValues, isDisabled, feedback, feedbackType, isSuccessfullyFilled, isLoading, length } = props; + + const { otpControl, isLoading, isDisabled } = useOTPInputContext(); + const { feedback, values, setValues, feedbackType, length } = otpControl.otpInputProps; React.useImperativeHandle(ref, () => ({ reset: () => { @@ -166,55 +288,41 @@ export const CodeControl = React.forwardRef<{ reset: any }, CodeControlProps>((p return ( - - {values.map((value, index: number) => ( - (refs.current[index] = node)} - autoFocus={index === 0 || undefined} - autoComplete='one-time-code' - aria-label={`${index === 0 ? 'Enter verification code. ' : ''} Digit ${index + 1}`} - isDisabled={isDisabled || isLoading || disabled || isSuccessfullyFilled} - hasError={feedbackType === 'error'} - isSuccessfullyFilled={isSuccessfullyFilled} - type='text' - inputMode='numeric' - name={`codeInput-${index}`} - /> - ))} - {isLoading && ( - ({ marginLeft: theme.space.$2 })} - /> - )} - - + {values.map((value, index: number) => ( + (refs.current[index] = node)} + autoFocus={index === 0 || undefined} + autoComplete='one-time-code' + aria-label={`${index === 0 ? 'Enter verification code. ' : ''} Digit ${index + 1}`} + isDisabled={isDisabled || isLoading || disabled || feedbackType === 'success'} + hasError={feedbackType === 'error'} + isSuccessfullyFilled={feedbackType === 'success'} + type='text' + inputMode='numeric' + name={`codeInput-${index}`} + /> + ))} + {isLoading && ( + ({ marginLeft: theme.space.$2 })} + /> + )} ); }); diff --git a/packages/clerk-js/src/ui/elements/CodeForm.tsx b/packages/clerk-js/src/ui/elements/CodeForm.tsx deleted file mode 100644 index 597a5045a1..0000000000 --- a/packages/clerk-js/src/ui/elements/CodeForm.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; - -import type { LocalizationKey } from '../customizables'; -import { Col, descriptors, Text } from '../customizables'; -import type { useCodeControl } from './CodeControl'; -import { CodeControl } from './CodeControl'; -import { TimerButton } from './TimerButton'; - -type CodeFormProps = { - title: LocalizationKey; - subtitle: LocalizationKey; - resendButton?: LocalizationKey; - isLoading: boolean; - success: boolean; - onResendCodeClicked?: React.MouseEventHandler; - codeControl: ReturnType; -}; - -export const CodeForm = (props: CodeFormProps) => { - const { subtitle, title, isLoading, success, codeControl, onResendCodeClicked, resendButton } = props; - - return ( - - - - - {onResendCodeClicked && ( - ({ marginTop: theme.space.$6 })} - localizationKey={resendButton} - /> - )} - - ); -}; diff --git a/packages/clerk-js/src/ui/elements/FieldControl.tsx b/packages/clerk-js/src/ui/elements/FieldControl.tsx index 8ffecae983..4a3d842879 100644 --- a/packages/clerk-js/src/ui/elements/FieldControl.tsx +++ b/packages/clerk-js/src/ui/elements/FieldControl.tsx @@ -19,11 +19,13 @@ import { FormFieldContextProvider, sanitizeInputProps, useFormField } from '../p import type { PropsOfComponent } from '../styledSystem'; import type { useFormControl as useFormControlUtil } from '../utils'; import { useFormControlFeedback } from '../utils'; +import { OTPCodeControl, OTPInputDescription, OTPInputLabel, OTPResendButton, OTPRoot } from './CodeControl'; import { useCardState } from './contexts'; import type { FormFeedbackProps } from './FormControl'; import { FormFeedback } from './FormControl'; import { InputGroup } from './InputGroup'; import { PasswordInput } from './PasswordInput'; +import { PhoneInput } from './PhoneInput'; import { RadioItem, RadioLabel } from './RadioGroup'; type FormControlProps = Omit, 'label' | 'placeholder' | 'disabled' | 'required'> & @@ -199,6 +201,22 @@ const FieldFeedback = (props: Pick) => ); }; +const PhoneInputElement = forwardRef((_, ref) => { + const { t } = useLocalizations(); + const formField = useFormField(); + const { placeholder, ...inputProps } = sanitizeInputProps(formField); + + return ( + + ); +}); + const PasswordInputElement = forwardRef((_, ref) => { const { t } = useLocalizations(); const formField = useFormField(); @@ -304,6 +322,7 @@ export const Field = { LabelRow: FieldLabelRow, Input: InputElement, PasswordInput: PasswordInputElement, + PhoneInput: PhoneInputElement, InputGroup: InputGroupElement, RadioItem: RadioItem, CheckboxIndicator: CheckboxIndicator, @@ -312,4 +331,9 @@ export const Field = { AsOptional: FieldOptionalLabel, LabelIcon: FieldLabelIcon, Feedback: FieldFeedback, + OTPRoot, + OTPInputLabel, + OTPInputDescription, + OTPCodeControl, + OTPResendButton, }; diff --git a/packages/clerk-js/src/ui/elements/Form.tsx b/packages/clerk-js/src/ui/elements/Form.tsx index caa4bf0198..bba4ead638 100644 --- a/packages/clerk-js/src/ui/elements/Form.tsx +++ b/packages/clerk-js/src/ui/elements/Form.tsx @@ -1,12 +1,13 @@ import { createContextAndHook } from '@clerk/shared/react'; import type { FieldId } from '@clerk/types'; import type { PropsWithChildren } from 'react'; -import React, { useState } from 'react'; +import React, { forwardRef, useState } from 'react'; import type { LocalizationKey } from '../customizables'; import { Button, Col, descriptors, Flex, Form as FormPrim, localizationKeys } from '../customizables'; import { useLoadingStatus } from '../hooks'; import type { PropsOfComponent } from '../styledSystem'; +import type { OTPInputProps } from './CodeControl'; import { useCardState } from './contexts'; import { Field } from './FieldControl'; import { FormControl } from './FormControl'; @@ -156,10 +157,18 @@ const PlainInput = (props: CommonInputProps) => { ); }; -const PasswordInput = (props: CommonInputProps) => { +const PasswordInput = forwardRef((props, ref) => { return ( - + + + ); +}); + +const PhoneInput = (props: CommonInputProps) => { + return ( + + ); }; @@ -218,6 +227,39 @@ const RadioGroup = ( ); }; +const OTPInput = (props: OTPInputProps) => { + const { ref, ...restInputProps } = props.otpControl.otpInputProps; + return ( + // Use Field.Root in order to pass feedback down to Field.Feedback + // @ts-ignore + + + + + + + + + + + + + + ); +}; + export const Form = { Root: FormRoot, ControlRow: FormControlRow, @@ -227,6 +269,8 @@ export const Form = { Control: FormControl, PlainInput, PasswordInput, + PhoneInput, + OTPInput, InputGroup, RadioGroup, Checkbox, diff --git a/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx b/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx index be4f1eeeed..032a4def89 100644 --- a/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx +++ b/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx @@ -2,15 +2,13 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; import { Col, descriptors, localizationKeys } from '../customizables'; -import { useLoadingStatus } from '../hooks'; import type { LocalizationKey } from '../localization'; -import { handleError, sleep, useFormControl } from '../utils'; import { CardAlert } from './Alert'; import { Card } from './Card'; -import { useCodeControl } from './CodeControl'; -import { CodeForm } from './CodeForm'; +import { useFieldOTP } from './CodeControl'; import { useCardState } from './contexts'; import { Footer } from './Footer'; +import { Form } from './Form'; import { Header } from './Header'; import { IdentityPreview } from './IdentityPreview'; @@ -36,37 +34,15 @@ export type VerificationCodeCardProps = { export const VerificationCodeCard = (props: PropsWithChildren) => { const { showAlternativeMethods = true, children } = props; - const [success, setSuccess] = React.useState(false); - const status = useLoadingStatus(); - const codeControlState = useFormControl('code', ''); - const codeControl = useCodeControl(codeControlState); const card = useCardState(); - const resolve = async () => { - setSuccess(true); - await sleep(750); - }; - - const reject = async (err: any) => { - handleError(err, [codeControlState], card.setError); - status.setIdle(); - await sleep(750); - codeControl.reset(); - }; - - codeControl.onCodeEntryFinished(code => { - status.setLoading(); - codeControlState.setError(undefined); - props.onCodeEntryFinishedAction(code, resolve, reject); + const otp = useFieldOTP({ + onCodeEntryFinished: (code, resolve, reject) => { + props.onCodeEntryFinishedAction(code, resolve, reject); + }, + onResendCodeClicked: props.onResendCodeClicked, }); - const handleResend = props.onResendCodeClicked - ? (e: React.MouseEvent) => { - codeControl.reset(); - props.onResendCodeClicked?.(e); - } - : undefined; - return ( {card.error} @@ -85,14 +61,11 @@ export const VerificationCodeCard = (props: PropsWithChildren -