diff options
author | Hailey <me@haileyok.com> | 2024-03-19 12:47:46 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-19 12:47:46 -0700 |
commit | a1c4f19731878f7026d398d28e475bbeb7de824a (patch) | |
tree | 3b4b869b69f71dacda41707b786916bb46a9f3f1 /src/screens/Signup | |
parent | b6903419a1112bae5397a398756e46a46afcf65f (diff) | |
download | voidsky-a1c4f19731878f7026d398d28e475bbeb7de824a.tar.zst |
Use ALF for signup flow, improve a11y of signup (#3151)
* Use ALF for signup flow, improve a11y of signup * adjust padding * rm log * org imports * clarify allowance of hyphens Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * fix a few accessibility items * Standardise date input across platforms (#3223) * make the date input consistent across platforms * integrate into new signup form * rm log * add transitions * show correct # of steps * use `FormError` * animate buttons * use `ScreenTransition` * fix android text overflow via flex -> flex_1 * change button color * (android) make date input the same height as others * fix deps * fix deps --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/screens/Signup')
-rw-r--r-- | src/screens/Signup/StepCaptcha.tsx | 94 | ||||
-rw-r--r-- | src/screens/Signup/StepHandle.tsx | 134 | ||||
-rw-r--r-- | src/screens/Signup/StepInfo.tsx | 145 | ||||
-rw-r--r-- | src/screens/Signup/index.tsx | 225 | ||||
-rw-r--r-- | src/screens/Signup/state.ts | 320 |
5 files changed, 918 insertions, 0 deletions
diff --git a/src/screens/Signup/StepCaptcha.tsx b/src/screens/Signup/StepCaptcha.tsx new file mode 100644 index 000000000..c4181e552 --- /dev/null +++ b/src/screens/Signup/StepCaptcha.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' +import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state' +import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView' +import {createFullHandle} from 'lib/strings/handles' +import {isWeb} from 'platform/detection' +import {atoms as a, useTheme} from '#/alf' +import {FormError} from '#/components/forms/FormError' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' + +const CAPTCHA_PATH = '/gate/signup' + +export function StepCaptcha() { + const {_} = useLingui() + const theme = useTheme() + const {state, dispatch} = useSignupContext() + const submit = useSubmitSignup({state, dispatch}) + + const [completed, setCompleted] = React.useState(false) + + const stateParam = React.useMemo(() => nanoid(15), []) + const url = React.useMemo(() => { + const newUrl = new URL(state.serviceUrl) + newUrl.pathname = CAPTCHA_PATH + newUrl.searchParams.set( + 'handle', + createFullHandle(state.handle, state.userDomain), + ) + newUrl.searchParams.set('state', stateParam) + newUrl.searchParams.set('colorScheme', theme.name) + + return newUrl.href + }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name]) + + const onSuccess = React.useCallback( + (code: string) => { + setCompleted(true) + submit(code) + }, + [submit], + ) + + const onError = React.useCallback(() => { + dispatch({ + type: 'setError', + value: _(msg`Error receiving captcha response.`), + }) + }, [_, dispatch]) + + return ( + <ScreenTransition> + <View style={[a.gap_lg]}> + <View style={[styles.container, completed && styles.center]}> + {!completed ? ( + <CaptchaWebView + url={url} + stateParam={stateParam} + state={state} + onSuccess={onSuccess} + onError={onError} + /> + ) : ( + <ActivityIndicator size="large" /> + )} + </View> + <FormError error={state.error} /> + </View> + </ScreenTransition> + ) +} + +const styles = StyleSheet.create({ + error: { + borderRadius: 6, + marginTop: 10, + }, + // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. + touchable: { + ...(isWeb && {cursor: 'pointer'}), + }, + container: { + minHeight: 500, + width: '100%', + paddingBottom: 20, + overflow: 'hidden', + }, + center: { + alignItems: 'center', + justifyContent: 'center', + }, +}) diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx new file mode 100644 index 000000000..e0a79e8fb --- /dev/null +++ b/src/screens/Signup/StepHandle.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import {View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' +import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import * as TextField from '#/components/forms/TextField' +import {useSignupContext} from '#/screens/Signup/state' +import {Text} from '#/components/Typography' +import {atoms as a, useTheme} from '#/alf' +import { + createFullHandle, + IsValidHandle, + validateHandle, +} from 'lib/strings/handles' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' + +export function StepHandle() { + const {_} = useLingui() + const t = useTheme() + const {state, dispatch} = useSignupContext() + + const [validCheck, setValidCheck] = React.useState<IsValidHandle>({ + handleChars: false, + hyphenStartOrEnd: false, + frontLength: false, + totalLength: true, + overall: false, + }) + + useFocusEffect( + React.useCallback(() => { + console.log('run') + setValidCheck(validateHandle(state.handle, state.userDomain)) + }, [state.handle, state.userDomain]), + ) + + const onHandleChange = React.useCallback( + (value: string) => { + if (state.error) { + dispatch({type: 'setError', value: ''}) + } + + dispatch({ + type: 'setHandle', + value, + }) + }, + [dispatch, state.error], + ) + + return ( + <ScreenTransition> + <View style={[a.gap_lg]}> + <View> + <TextField.Root> + <TextField.Icon icon={At} /> + <TextField.Input + onChangeText={onHandleChange} + label={_(msg`Input your user handle`)} + defaultValue={state.handle} + autoCapitalize="none" + autoCorrect={false} + autoFocus + autoComplete="off" + /> + </TextField.Root> + </View> + <Text style={[a.text_md]}> + <Trans>Your full handle will be</Trans>{' '} + <Text style={[a.text_md, a.font_bold]}> + @{createFullHandle(state.handle, state.userDomain)} + </Text> + </Text> + + <View + style={[ + a.w_full, + a.rounded_sm, + a.border, + a.p_md, + a.gap_sm, + t.atoms.border_contrast_low, + ]}> + {state.error ? ( + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> + <IsValidIcon valid={false} /> + <Text style={[a.text_md, a.flex_1]}>{state.error}</Text> + </View> + ) : undefined} + {validCheck.hyphenStartOrEnd ? ( + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> + <IsValidIcon valid={validCheck.handleChars} /> + <Text style={[a.text_md, a.flex_1]}> + <Trans>Only contains letters, numbers, and hyphens</Trans> + </Text> + </View> + ) : ( + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> + <IsValidIcon valid={validCheck.hyphenStartOrEnd} /> + <Text style={[a.text_md, a.flex_1]}> + <Trans>Doesn't begin or end with a hyphen</Trans> + </Text> + </View> + )} + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> + <IsValidIcon + valid={validCheck.frontLength && validCheck.totalLength} + /> + {!validCheck.totalLength ? ( + <Text style={[a.text_md, a.flex_1]}> + <Trans>No longer than 253 characters</Trans> + </Text> + ) : ( + <Text style={[a.text_md, a.flex_1]}> + <Trans>At least 3 characters</Trans> + </Text> + )} + </View> + </View> + </View> + </ScreenTransition> + ) +} + +function IsValidIcon({valid}: {valid: boolean}) { + const t = useTheme() + if (!valid) { + return <Times size="md" style={{color: t.palette.negative_500}} /> + } + return <Check size="md" style={{color: t.palette.positive_700}} /> +} diff --git a/src/screens/Signup/StepInfo.tsx b/src/screens/Signup/StepInfo.tsx new file mode 100644 index 000000000..30a31884a --- /dev/null +++ b/src/screens/Signup/StepInfo.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {atoms as a} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' +import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' +import {is13, is18, useSignupContext} from '#/screens/Signup/state' +import * as DateField from '#/components/forms/DateField' +import {logger} from '#/logger' +import {Loader} from '#/components/Loader' +import {Policies} from 'view/com/auth/create/Policies' +import {HostingProvider} from '#/components/forms/HostingProvider' +import {FormError} from '#/components/forms/FormError' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' + +function sanitizeDate(date: Date): Date { + if (!date || date.toString() === 'Invalid Date') { + logger.error(`Create account: handled invalid date for birthDate`, { + hasDate: !!date, + }) + return new Date() + } + return date +} + +export function StepInfo() { + const {_} = useLingui() + const {state, dispatch} = useSignupContext() + + return ( + <ScreenTransition> + <View style={[a.gap_lg]}> + <FormError error={state.error} /> + <View> + <TextField.Label> + <Trans>Hosting provider</Trans> + </TextField.Label> + <HostingProvider + serviceUrl={state.serviceUrl} + onSelectServiceUrl={v => + dispatch({type: 'setServiceUrl', value: v}) + } + /> + </View> + {state.isLoading ? ( + <View style={[a.align_center]}> + <Loader size="xl" /> + </View> + ) : state.serviceDescription ? ( + <> + {state.serviceDescription.inviteCodeRequired && ( + <View> + <TextField.Label> + <Trans>Invite code</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Ticket} /> + <TextField.Input + onChangeText={value => { + dispatch({ + type: 'setInviteCode', + value: value.trim(), + }) + }} + label={_(msg`Required for this provider`)} + defaultValue={state.inviteCode} + autoCapitalize="none" + autoComplete="email" + keyboardType="email-address" + /> + </TextField.Root> + </View> + )} + <View> + <TextField.Label> + <Trans>Email</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Envelope} /> + <TextField.Input + onChangeText={value => { + dispatch({ + type: 'setEmail', + value: value.trim(), + }) + }} + label={_(msg`Enter your email address`)} + defaultValue={state.email} + autoCapitalize="none" + autoComplete="email" + keyboardType="email-address" + /> + </TextField.Root> + </View> + <View> + <TextField.Label> + <Trans>Password</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Lock} /> + <TextField.Input + onChangeText={value => { + dispatch({ + type: 'setPassword', + value, + }) + }} + label={_(msg`Choose your password`)} + defaultValue={state.password} + secureTextEntry + autoComplete="new-password" + /> + </TextField.Root> + </View> + <View> + <DateField.Label> + <Trans>Your birth date</Trans> + </DateField.Label> + <DateField.DateField + testID="date" + value={DateField.utils.toSimpleDateString(state.dateOfBirth)} + onChangeDate={date => { + dispatch({ + type: 'setDateOfBirth', + value: sanitizeDate(new Date(date)), + }) + }} + label={_(msg`Date of birth`)} + accessibilityHint={_(msg`Select your date of birth`)} + /> + </View> + <Policies + serviceDescription={state.serviceDescription} + needsGuardian={!is18(state.dateOfBirth)} + under13={!is13(state.dateOfBirth)} + /> + </> + ) : undefined} + </View> + </ScreenTransition> + ) +} diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx new file mode 100644 index 000000000..b1acbbdf0 --- /dev/null +++ b/src/screens/Signup/index.tsx @@ -0,0 +1,225 @@ +import React from 'react' +import {ScrollView, View} from 'react-native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' +import { + initialState, + reducer, + SignupContext, + SignupStep, + useSubmitSignup, +} from '#/screens/Signup/state' +import {StepInfo} from '#/screens/Signup/StepInfo' +import {StepHandle} from '#/screens/Signup/StepHandle' +import {StepCaptcha} from '#/screens/Signup/StepCaptcha' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' +import {FEEDBACK_FORM_URL} from 'lib/constants' +import {InlineLink} from '#/components/Link' +import {useServiceQuery} from 'state/queries/service' +import {getAgent} from 'state/session' +import {createFullHandle} from 'lib/strings/handles' +import {useAnalytics} from 'lib/analytics/analytics' + +export function Signup({onPressBack}: {onPressBack: () => void}) { + const {_} = useLingui() + const t = useTheme() + const {screen} = useAnalytics() + const [state, dispatch] = React.useReducer(reducer, initialState) + const submit = useSubmitSignup({state, dispatch}) + + const { + data: serviceInfo, + isFetching, + isError, + refetch, + } = useServiceQuery(state.serviceUrl) + + React.useEffect(() => { + screen('CreateAccount') + }, [screen]) + + React.useEffect(() => { + if (isFetching) { + dispatch({type: 'setIsLoading', value: true}) + } else if (!isFetching) { + dispatch({type: 'setIsLoading', value: false}) + } + }, [isFetching]) + + React.useEffect(() => { + if (isError) { + dispatch({type: 'setServiceDescription', value: undefined}) + dispatch({ + type: 'setError', + value: _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + }) + } else if (serviceInfo) { + dispatch({type: 'setServiceDescription', value: serviceInfo}) + dispatch({type: 'setError', value: ''}) + } + }, [_, serviceInfo, isError]) + + const onNextPress = React.useCallback(async () => { + if (state.activeStep === SignupStep.HANDLE) { + try { + dispatch({type: 'setIsLoading', value: true}) + + const res = await getAgent().resolveHandle({ + handle: createFullHandle(state.handle, state.userDomain), + }) + + if (res.data.did) { + dispatch({ + type: 'setError', + value: _(msg`That handle is already taken.`), + }) + return + } + } catch (e) { + // Don't have to handle + } finally { + dispatch({type: 'setIsLoading', value: false}) + } + } + + // phoneVerificationRequired is actually whether a captcha is required + if ( + state.activeStep === SignupStep.HANDLE && + !state.serviceDescription?.phoneVerificationRequired + ) { + submit() + return + } + + dispatch({type: 'next'}) + }, [ + _, + state.activeStep, + state.handle, + state.serviceDescription?.phoneVerificationRequired, + state.userDomain, + submit, + ]) + + const onBackPress = React.useCallback(() => { + if (state.activeStep !== SignupStep.INFO) { + dispatch({type: 'prev'}) + } else { + onPressBack() + } + }, [onPressBack, state.activeStep]) + + return ( + <SignupContext.Provider value={{state, dispatch}}> + <LoggedOutLayout + leadin="" + title={_(msg`Create Account`)} + description={_(msg`We're so excited to have you join us!`)}> + <ScrollView + testID="createAccount" + keyboardShouldPersistTaps="handled" + style={a.h_full} + keyboardDismissMode="on-drag"> + <View + style={[ + a.flex_1, + a.px_xl, + a.gap_3xl, + a.pt_2xl, + {paddingBottom: 100}, + ]}> + <View style={[a.gap_sm]}> + <Text style={[a.text_lg, t.atoms.text_contrast_medium]}> + <Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '} + {state.serviceDescription && + !state.serviceDescription.phoneVerificationRequired + ? '2' + : '3'} + </Text> + <Text style={[a.text_3xl, a.font_bold]}> + {state.activeStep === SignupStep.INFO ? ( + <Trans>Your account</Trans> + ) : state.activeStep === SignupStep.HANDLE ? ( + <Trans>Your user handle</Trans> + ) : ( + <Trans>Complete the challenge</Trans> + )} + </Text> + </View> + <View> + {state.activeStep === SignupStep.INFO ? ( + <StepInfo /> + ) : state.activeStep === SignupStep.HANDLE ? ( + <StepHandle /> + ) : ( + <StepCaptcha /> + )} + </View> + + <View style={[a.flex_row, a.justify_between]}> + <Button + label="Back" + variant="solid" + color="secondary" + size="small" + onPress={onBackPress}> + Back + </Button> + {state.activeStep !== SignupStep.CAPTCHA && ( + <> + {isError ? ( + <Button + label="Retry" + variant="solid" + color="primary" + size="small" + disabled={state.isLoading} + onPress={() => refetch()}> + Retry + </Button> + ) : ( + <Button + label="Next" + variant="solid" + color={ + !state.canNext || state.isLoading + ? 'secondary' + : 'primary' + } + size="small" + disabled={!state.canNext || state.isLoading} + onPress={onNextPress}> + <ButtonText>Next</ButtonText> + </Button> + )} + </> + )} + </View> + <View + style={[ + a.w_full, + a.py_lg, + a.px_md, + a.rounded_sm, + t.atoms.bg_contrast_25, + ]}> + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + <Trans>Having trouble?</Trans>{' '} + <InlineLink + style={[a.text_md]} + to={FEEDBACK_FORM_URL({email: state.email})}> + <Trans>Contact support</Trans> + </InlineLink> + </Text> + </View> + </View> + </ScrollView> + </LoggedOutLayout> + </SignupContext.Provider> + ) +} diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts new file mode 100644 index 000000000..1ae43612e --- /dev/null +++ b/src/screens/Signup/state.ts @@ -0,0 +1,320 @@ +import React, {useCallback} from 'react' +import {LayoutAnimation} from 'react-native' +import * as EmailValidator from 'email-validator' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {cleanError} from 'lib/strings/errors' +import { + ComAtprotoServerCreateAccount, + ComAtprotoServerDescribeServer, +} from '@atproto/api' + +import {logger} from '#/logger' +import {DEFAULT_SERVICE, IS_PROD_SERVICE} from 'lib/constants' +import {createFullHandle, validateHandle} from 'lib/strings/handles' +import {getAge} from 'lib/strings/time' +import {useSessionApi} from 'state/session' +import { + DEFAULT_PROD_FEEDS, + usePreferencesSetBirthDateMutation, + useSetSaveFeedsMutation, +} from 'state/queries/preferences' +import {useOnboardingDispatch} from 'state/shell' + +export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago + +export enum SignupStep { + INFO, + HANDLE, + CAPTCHA, +} + +export type SignupState = { + hasPrev: boolean + canNext: boolean + activeStep: SignupStep + + serviceUrl: string + serviceDescription?: ServiceDescription + userDomain: string + dateOfBirth: Date + email: string + password: string + inviteCode: string + handle: string + + error: string + isLoading: boolean +} + +export type SignupAction = + | {type: 'prev'} + | {type: 'next'} + | {type: 'finish'} + | {type: 'setStep'; value: SignupStep} + | {type: 'setServiceUrl'; value: string} + | {type: 'setServiceDescription'; value: ServiceDescription | undefined} + | {type: 'setEmail'; value: string} + | {type: 'setPassword'; value: string} + | {type: 'setDateOfBirth'; value: Date} + | {type: 'setInviteCode'; value: string} + | {type: 'setHandle'; value: string} + | {type: 'setVerificationCode'; value: string} + | {type: 'setError'; value: string} + | {type: 'setCanNext'; value: boolean} + | {type: 'setIsLoading'; value: boolean} + +export const initialState: SignupState = { + hasPrev: false, + canNext: false, + activeStep: SignupStep.INFO, + + serviceUrl: DEFAULT_SERVICE, + serviceDescription: undefined, + userDomain: '', + dateOfBirth: DEFAULT_DATE, + email: '', + password: '', + handle: '', + inviteCode: '', + + error: '', + isLoading: false, +} + +export function is13(date: Date) { + return getAge(date) >= 13 +} + +export function is18(date: Date) { + return getAge(date) >= 18 +} + +export function reducer(s: SignupState, a: SignupAction): SignupState { + let next = {...s} + + switch (a.type) { + case 'prev': { + if (s.activeStep !== SignupStep.INFO) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + next.activeStep-- + next.error = '' + } + break + } + case 'next': { + if (s.activeStep !== SignupStep.CAPTCHA) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + next.activeStep++ + next.error = '' + } + break + } + case 'setStep': { + next.activeStep = a.value + break + } + case 'setServiceUrl': { + next.serviceUrl = a.value + break + } + case 'setServiceDescription': { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + + next.serviceDescription = a.value + next.userDomain = a.value?.availableUserDomains[0] ?? '' + next.isLoading = false + break + } + + case 'setEmail': { + next.email = a.value + break + } + case 'setPassword': { + next.password = a.value + break + } + case 'setDateOfBirth': { + next.dateOfBirth = a.value + break + } + case 'setInviteCode': { + next.inviteCode = a.value + break + } + case 'setHandle': { + next.handle = a.value + break + } + case 'setCanNext': { + next.canNext = a.value + break + } + case 'setIsLoading': { + next.isLoading = a.value + break + } + case 'setError': { + next.error = a.value + break + } + } + + next.hasPrev = next.activeStep !== SignupStep.INFO + + switch (next.activeStep) { + case SignupStep.INFO: { + const isValidEmail = EmailValidator.validate(next.email) + next.canNext = + !!(next.email && next.password && next.dateOfBirth) && + (!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) && + is13(next.dateOfBirth) && + isValidEmail + break + } + case SignupStep.HANDLE: { + next.canNext = + !!next.handle && validateHandle(next.handle, next.userDomain).overall + break + } + } + + logger.debug('signup', next) + + if (s.activeStep !== next.activeStep) { + logger.debug('signup: step changed', {activeStep: next.activeStep}) + } + + return next +} + +interface IContext { + state: SignupState + dispatch: React.Dispatch<SignupAction> +} +export const SignupContext = React.createContext<IContext>({} as IContext) +export const useSignupContext = () => React.useContext(SignupContext) + +export function useSubmitSignup({ + state, + dispatch, +}: { + state: SignupState + dispatch: (action: SignupAction) => void +}) { + const {_} = useLingui() + const {createAccount} = useSessionApi() + const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() + const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() + const onboardingDispatch = useOnboardingDispatch() + + return useCallback( + async (verificationCode?: string) => { + if (!state.email) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Please enter your email.`), + }) + } + if (!EmailValidator.validate(state.email)) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Your email appears to be invalid.`), + }) + } + if (!state.password) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Please choose your password.`), + }) + } + if (!state.handle) { + dispatch({type: 'setStep', value: SignupStep.HANDLE}) + return dispatch({ + type: 'setError', + value: _(msg`Please choose your handle.`), + }) + } + if ( + state.serviceDescription?.phoneVerificationRequired && + !verificationCode + ) { + dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) + return dispatch({ + type: 'setError', + value: _(msg`Please complete the verification captcha.`), + }) + } + dispatch({type: 'setError', value: ''}) + dispatch({type: 'setIsLoading', value: true}) + + try { + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view + await createAccount({ + service: state.serviceUrl, + email: state.email, + handle: createFullHandle(state.handle, state.userDomain), + password: state.password, + inviteCode: state.inviteCode.trim(), + verificationCode: verificationCode, + }) + setBirthDate({birthDate: state.dateOfBirth}) + if (IS_PROD_SERVICE(state.serviceUrl)) { + setSavedFeeds(DEFAULT_PROD_FEEDS) + } + } catch (e: any) { + onboardingDispatch({type: 'skip'}) // undo starting the onboard + let errMsg = e.toString() + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { + dispatch({ + type: 'setError', + value: _( + msg`Invite code not accepted. Check that you input it correctly and try again.`, + ), + }) + dispatch({type: 'setStep', value: SignupStep.INFO}) + return + } + + if ([400, 429].includes(e.status)) { + logger.warn('Failed to create account', {message: e}) + } else { + logger.error(`Failed to create account (${e.status} status)`, { + message: e, + }) + } + + const error = cleanError(errMsg) + const isHandleError = error.toLowerCase().includes('handle') + + dispatch({type: 'setIsLoading', value: false}) + dispatch({type: 'setError', value: cleanError(errMsg)}) + dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) + } finally { + dispatch({type: 'setIsLoading', value: false}) + } + }, + [ + state.email, + state.password, + state.handle, + state.serviceDescription?.phoneVerificationRequired, + state.serviceUrl, + state.userDomain, + state.inviteCode, + state.dateOfBirth, + dispatch, + _, + onboardingDispatch, + createAccount, + setBirthDate, + setSavedFeeds, + ], + ) +} |