about summary refs log tree commit diff
path: root/src/screens/Signup
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-03-19 12:47:46 -0700
committerGitHub <noreply@github.com>2024-03-19 12:47:46 -0700
commita1c4f19731878f7026d398d28e475bbeb7de824a (patch)
tree3b4b869b69f71dacda41707b786916bb46a9f3f1 /src/screens/Signup
parentb6903419a1112bae5397a398756e46a46afcf65f (diff)
downloadvoidsky-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.tsx94
-rw-r--r--src/screens/Signup/StepHandle.tsx134
-rw-r--r--src/screens/Signup/StepInfo.tsx145
-rw-r--r--src/screens/Signup/index.tsx225
-rw-r--r--src/screens/Signup/state.ts320
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,
+    ],
+  )
+}