about summary refs log tree commit diff
path: root/src/view/com/auth/create
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/auth/create')
-rw-r--r--src/view/com/auth/create/CreateAccount.tsx140
-rw-r--r--src/view/com/auth/create/Policies.tsx6
-rw-r--r--src/view/com/auth/create/Step1.tsx73
-rw-r--r--src/view/com/auth/create/Step2.tsx82
-rw-r--r--src/view/com/auth/create/Step3.tsx34
-rw-r--r--src/view/com/auth/create/state.ts242
6 files changed, 437 insertions, 140 deletions
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
index 1d64cc067..ab6d34584 100644
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ b/src/view/com/auth/create/CreateAccount.tsx
@@ -7,78 +7,134 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {Text} from '../../util/text/Text'
 import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
 import {s} from 'lib/styles'
-import {useStores} from 'state/index'
-import {CreateAccountModel} from 'state/models/ui/create-account'
 import {usePalette} from 'lib/hooks/usePalette'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useOnboardingDispatch} from '#/state/shell'
+import {useSessionApi} from '#/state/session'
+import {useCreateAccount, submit} from './state'
+import {useServiceQuery} from '#/state/queries/service'
+import {
+  usePreferencesSetBirthDateMutation,
+  useSetSaveFeedsMutation,
+  DEFAULT_PROD_FEEDS,
+} from '#/state/queries/preferences'
+import {IS_PROD} from '#/lib/constants'
 
 import {Step1} from './Step1'
 import {Step2} from './Step2'
 import {Step3} from './Step3'
 
-export const CreateAccount = observer(function CreateAccountImpl({
-  onPressBack,
-}: {
-  onPressBack: () => void
-}) {
+export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
   const {track, screen} = useAnalytics()
   const pal = usePalette('default')
-  const store = useStores()
-  const model = React.useMemo(() => new CreateAccountModel(store), [store])
+  const {_} = useLingui()
+  const [uiState, uiDispatch] = useCreateAccount()
+  const onboardingDispatch = useOnboardingDispatch()
+  const {createAccount} = useSessionApi()
+  const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
+  const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
 
   React.useEffect(() => {
     screen('CreateAccount')
   }, [screen])
 
+  // fetch service info
+  // =
+
+  const {
+    data: serviceInfo,
+    isFetching: serviceInfoIsFetching,
+    error: serviceInfoError,
+    refetch: refetchServiceInfo,
+  } = useServiceQuery(uiState.serviceUrl)
+
   React.useEffect(() => {
-    model.fetchServiceDescription()
-  }, [model])
+    if (serviceInfo) {
+      uiDispatch({type: 'set-service-description', value: serviceInfo})
+      uiDispatch({type: 'set-error', value: ''})
+    } else if (serviceInfoError) {
+      uiDispatch({
+        type: 'set-error',
+        value: _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      })
+    }
+  }, [_, uiDispatch, serviceInfo, serviceInfoError])
 
-  const onPressRetryConnect = React.useCallback(
-    () => model.fetchServiceDescription(),
-    [model],
-  )
+  // event handlers
+  // =
 
   const onPressBackInner = React.useCallback(() => {
-    if (model.canBack) {
-      model.back()
+    if (uiState.canBack) {
+      uiDispatch({type: 'back'})
     } else {
       onPressBack()
     }
-  }, [model, onPressBack])
+  }, [uiState, uiDispatch, onPressBack])
 
   const onPressNext = React.useCallback(async () => {
-    if (!model.canNext) {
+    if (!uiState.canNext) {
       return
     }
-    if (model.step < 3) {
-      model.next()
+    if (uiState.step < 3) {
+      uiDispatch({type: 'next'})
     } else {
       try {
-        await model.submit()
+        await submit({
+          onboardingDispatch,
+          createAccount,
+          uiState,
+          uiDispatch,
+          _,
+        })
+        track('Create Account')
+        setBirthDate({birthDate: uiState.birthDate})
+        if (IS_PROD(uiState.serviceUrl)) {
+          setSavedFeeds(DEFAULT_PROD_FEEDS)
+        }
       } catch {
         // dont need to handle here
       } finally {
         track('Try Create Account')
       }
     }
-  }, [model, track])
+  }, [
+    uiState,
+    uiDispatch,
+    track,
+    onboardingDispatch,
+    createAccount,
+    setBirthDate,
+    setSavedFeeds,
+    _,
+  ])
+
+  // rendering
+  // =
 
   return (
     <LoggedOutLayout
-      leadin={`Step ${model.step}`}
-      title="Create Account"
-      description="We're so excited to have you join us!">
+      leadin={`Step ${uiState.step}`}
+      title={_(msg`Create Account`)}
+      description={_(msg`We're so excited to have you join us!`)}>
       <ScrollView testID="createAccount" style={pal.view}>
         <KeyboardAvoidingView behavior="padding">
           <View style={styles.stepContainer}>
-            {model.step === 1 && <Step1 model={model} />}
-            {model.step === 2 && <Step2 model={model} />}
-            {model.step === 3 && <Step3 model={model} />}
+            {uiState.step === 1 && (
+              <Step1 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
+            {uiState.step === 2 && (
+              <Step2 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
+            {uiState.step === 3 && (
+              <Step3 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
           </View>
           <View style={[s.flexRow, s.pl20, s.pr20]}>
             <TouchableOpacity
@@ -86,40 +142,40 @@ export const CreateAccount = observer(function CreateAccountImpl({
               testID="backBtn"
               accessibilityRole="button">
               <Text type="xl" style={pal.link}>
-                Back
+                <Trans>Back</Trans>
               </Text>
             </TouchableOpacity>
             <View style={s.flex1} />
-            {model.canNext ? (
+            {uiState.canNext ? (
               <TouchableOpacity
                 testID="nextBtn"
                 onPress={onPressNext}
                 accessibilityRole="button">
-                {model.isProcessing ? (
+                {uiState.isProcessing ? (
                   <ActivityIndicator />
                 ) : (
                   <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                    Next
+                    <Trans>Next</Trans>
                   </Text>
                 )}
               </TouchableOpacity>
-            ) : model.didServiceDescriptionFetchFail ? (
+            ) : serviceInfoError ? (
               <TouchableOpacity
                 testID="retryConnectBtn"
-                onPress={onPressRetryConnect}
+                onPress={() => refetchServiceInfo()}
                 accessibilityRole="button"
-                accessibilityLabel="Retry"
-                accessibilityHint="Retries account creation"
+                accessibilityLabel={_(msg`Retry`)}
+                accessibilityHint=""
                 accessibilityLiveRegion="polite">
                 <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                  Retry
+                  <Trans>Retry</Trans>
                 </Text>
               </TouchableOpacity>
-            ) : model.isFetchingServiceDescription ? (
+            ) : serviceInfoIsFetching ? (
               <>
                 <ActivityIndicator color="#fff" />
                 <Text type="xl" style={[pal.text, s.pr5]}>
-                  Connecting...
+                  <Trans>Connecting...</Trans>
                 </Text>
               </>
             ) : undefined}
@@ -129,7 +185,7 @@ export const CreateAccount = observer(function CreateAccountImpl({
       </ScrollView>
     </LoggedOutLayout>
   )
-})
+}
 
 const styles = StyleSheet.create({
   stepContainer: {
diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx
index 8eb669bcf..a52f07531 100644
--- a/src/view/com/auth/create/Policies.tsx
+++ b/src/view/com/auth/create/Policies.tsx
@@ -4,12 +4,14 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
 import {TextLink} from '../../util/Link'
 import {Text} from '../../util/text/Text'
 import {s, colors} from 'lib/styles'
-import {ServiceDescription} from 'state/models/session'
 import {usePalette} from 'lib/hooks/usePalette'
 
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
 export const Policies = ({
   serviceDescription,
   needsGuardian,
@@ -93,7 +95,7 @@ function validWebLink(url?: string): string | undefined {
 
 const styles = StyleSheet.create({
   policies: {
-    flexDirection: 'row',
+    flexDirection: 'column',
     gap: 8,
   },
   errorIcon: {
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index cdd5cb21d..c9d19e868 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -1,10 +1,8 @@
 import React from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import debounce from 'lodash.debounce'
 import {Text} from 'view/com/util/text/Text'
 import {StepHeader} from './StepHeader'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch} from './state'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
@@ -12,60 +10,49 @@ import {HelpTip} from '../util/HelpTip'
 import {TextInput} from '../util/TextInput'
 import {Button} from 'view/com/util/forms/Button'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
+import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
 import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
 
 /** STEP 1: Your hosting provider
  * @field Bluesky (default)
  * @field Other (staging, local dev, your own PDS, etc.)
  */
-export const Step1 = observer(function Step1Impl({
-  model,
+export function Step1({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
   const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
+  const {_} = useLingui()
 
   const onPressDefault = React.useCallback(() => {
     setIsDefaultSelected(true)
-    model.setServiceUrl(PROD_SERVICE)
-    model.fetchServiceDescription()
-  }, [setIsDefaultSelected, model])
+    uiDispatch({type: 'set-service-url', value: PROD_SERVICE})
+  }, [setIsDefaultSelected, uiDispatch])
 
   const onPressOther = React.useCallback(() => {
     setIsDefaultSelected(false)
-    model.setServiceUrl('https://')
-    model.setServiceDescription(undefined)
-  }, [setIsDefaultSelected, model])
-
-  const fetchServiceDescription = React.useMemo(
-    () => debounce(() => model.fetchServiceDescription(), 1e3), // debouce for 1 second (1e3 = 1000ms)
-    [model],
-  )
+    uiDispatch({type: 'set-service-url', value: 'https://'})
+  }, [setIsDefaultSelected, uiDispatch])
 
   const onChangeServiceUrl = React.useCallback(
     (v: string) => {
-      model.setServiceUrl(v)
-      fetchServiceDescription()
-    },
-    [model, fetchServiceDescription],
-  )
-
-  const onDebugChangeServiceUrl = React.useCallback(
-    (v: string) => {
-      model.setServiceUrl(v)
-      model.fetchServiceDescription()
+      uiDispatch({type: 'set-service-url', value: v})
     },
-    [model],
+    [uiDispatch],
   )
 
   return (
     <View>
-      <StepHeader step="1" title="Your hosting provider" />
+      <StepHeader step="1" title={_(msg`Your hosting provider`)} />
       <Text style={[pal.text, s.mb10]}>
-        This is the service that keeps you online.
+        <Trans>This is the service that keeps you online.</Trans>
       </Text>
       <Option
         testID="blueskyServerBtn"
@@ -81,17 +68,17 @@ export const Step1 = observer(function Step1Impl({
         onPress={onPressOther}>
         <View style={styles.otherForm}>
           <Text nativeID="addressProvider" style={[pal.text, s.mb5]}>
-            Enter the address of your provider:
+            <Trans>Enter the address of your provider:</Trans>
           </Text>
           <TextInput
             testID="customServerInput"
             icon="globe"
-            placeholder="Hosting provider address"
-            value={model.serviceUrl}
+            placeholder={_(msg`Hosting provider address`)}
+            value={uiState.serviceUrl}
             editable
             onChange={onChangeServiceUrl}
             accessibilityHint="Input hosting provider address"
-            accessibilityLabel="Hosting provider address"
+            accessibilityLabel={_(msg`Hosting provider address`)}
             accessibilityLabelledBy="addressProvider"
           />
           {LOGIN_INCLUDE_DEV_SERVERS && (
@@ -100,27 +87,27 @@ export const Step1 = observer(function Step1Impl({
                 testID="stagingServerBtn"
                 type="default"
                 style={s.mr5}
-                label="Staging"
-                onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)}
+                label={_(msg`Staging`)}
+                onPress={() => onChangeServiceUrl(STAGING_SERVICE)}
               />
               <Button
                 testID="localDevServerBtn"
                 type="default"
-                label="Dev Server"
-                onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)}
+                label={_(msg`Dev Server`)}
+                onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)}
               />
             </View>
           )}
         </View>
       </Option>
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : (
-        <HelpTip text="You can change hosting providers at any time." />
+        <HelpTip text={_(msg`You can change hosting providers at any time.`)} />
       )}
     </View>
   )
-})
+}
 
 function Option({
   children,
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index 60e197564..89fd070ad 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch, is18} from './state'
 import {Text} from 'view/com/util/text/Text'
 import {DateInput} from 'view/com/util/forms/DateInput'
 import {StepHeader} from './StepHeader'
@@ -10,8 +9,10 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {TextInput} from '../util/TextInput'
 import {Policies} from './Policies'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {useStores} from 'state/index'
 import {isWeb} from 'platform/detection'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 /** STEP 2: Your account
  * @field Invite code or waitlist
@@ -22,23 +23,26 @@ import {isWeb} from 'platform/detection'
  * @field Birth date
  * @readonly Terms of service & privacy policy
  */
-export const Step2 = observer(function Step2Impl({
-  model,
+export function Step2({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
 
   const onPressWaitlist = React.useCallback(() => {
-    store.shell.openModal({name: 'waitlist'})
-  }, [store])
+    openModal({name: 'waitlist'})
+  }, [openModal])
 
   return (
     <View>
-      <StepHeader step="2" title="Your account" />
+      <StepHeader step="2" title={_(msg`Your account`)} />
 
-      {model.isInviteCodeRequired && (
+      {uiState.isInviteCodeRequired && (
         <View style={s.pb20}>
           <Text type="md-medium" style={[pal.text, s.mb2]}>
             Invite code
@@ -46,25 +50,27 @@ export const Step2 = observer(function Step2Impl({
           <TextInput
             testID="inviteCodeInput"
             icon="ticket"
-            placeholder="Required for this provider"
-            value={model.inviteCode}
+            placeholder={_(msg`Required for this provider`)}
+            value={uiState.inviteCode}
             editable
-            onChange={model.setInviteCode}
-            accessibilityLabel="Invite code"
+            onChange={value => uiDispatch({type: 'set-invite-code', value})}
+            accessibilityLabel={_(msg`Invite code`)}
             accessibilityHint="Input invite code to proceed"
           />
         </View>
       )}
 
-      {!model.inviteCode && model.isInviteCodeRequired ? (
+      {!uiState.inviteCode && uiState.isInviteCodeRequired ? (
         <Text style={[s.alignBaseline, pal.text]}>
           Don't have an invite code?{' '}
           <TouchableWithoutFeedback
             onPress={onPressWaitlist}
-            accessibilityLabel="Join the waitlist."
+            accessibilityLabel={_(msg`Join the waitlist.`)}
             accessibilityHint="">
             <View style={styles.touchable}>
-              <Text style={pal.link}>Join the waitlist.</Text>
+              <Text style={pal.link}>
+                <Trans>Join the waitlist.</Trans>
+              </Text>
             </View>
           </TouchableWithoutFeedback>
         </Text>
@@ -72,16 +78,16 @@ export const Step2 = observer(function Step2Impl({
         <>
           <View style={s.pb20}>
             <Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email">
-              Email address
+              <Trans>Email address</Trans>
             </Text>
             <TextInput
               testID="emailInput"
               icon="envelope"
-              placeholder="Enter your email address"
-              value={model.email}
+              placeholder={_(msg`Enter your email address`)}
+              value={uiState.email}
               editable
-              onChange={model.setEmail}
-              accessibilityLabel="Email"
+              onChange={value => uiDispatch({type: 'set-email', value})}
+              accessibilityLabel={_(msg`Email`)}
               accessibilityHint="Input email for Bluesky waitlist"
               accessibilityLabelledBy="email"
             />
@@ -92,17 +98,17 @@ export const Step2 = observer(function Step2Impl({
               type="md-medium"
               style={[pal.text, s.mb2]}
               nativeID="password">
-              Password
+              <Trans>Password</Trans>
             </Text>
             <TextInput
               testID="passwordInput"
               icon="lock"
-              placeholder="Choose your password"
-              value={model.password}
+              placeholder={_(msg`Choose your password`)}
+              value={uiState.password}
               editable
               secureTextEntry
-              onChange={model.setPassword}
-              accessibilityLabel="Password"
+              onChange={value => uiDispatch({type: 'set-password', value})}
+              accessibilityLabel={_(msg`Password`)}
               accessibilityHint="Set password"
               accessibilityLabelledBy="password"
             />
@@ -113,35 +119,35 @@ export const Step2 = observer(function Step2Impl({
               type="md-medium"
               style={[pal.text, s.mb2]}
               nativeID="birthDate">
-              Your birth date
+              <Trans>Your birth date</Trans>
             </Text>
             <DateInput
               testID="birthdayInput"
-              value={model.birthDate}
-              onChange={model.setBirthDate}
+              value={uiState.birthDate}
+              onChange={value => uiDispatch({type: 'set-birth-date', value})}
               buttonType="default-light"
               buttonStyle={[pal.border, styles.dateInputButton]}
               buttonLabelType="lg"
-              accessibilityLabel="Birthday"
+              accessibilityLabel={_(msg`Birthday`)}
               accessibilityHint="Enter your birth date"
               accessibilityLabelledBy="birthDate"
             />
           </View>
 
-          {model.serviceDescription && (
+          {uiState.serviceDescription && (
             <Policies
-              serviceDescription={model.serviceDescription}
-              needsGuardian={!model.isAge18}
+              serviceDescription={uiState.serviceDescription}
+              needsGuardian={!is18(uiState)}
             />
           )}
         </>
       )}
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   error: {
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
index beb756ac1..3b628b6b6 100644
--- a/src/view/com/auth/create/Step3.tsx
+++ b/src/view/com/auth/create/Step3.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch} from './state'
 import {Text} from 'view/com/util/text/Text'
 import {StepHeader} from './StepHeader'
 import {s} from 'lib/styles'
@@ -9,44 +8,49 @@ import {TextInput} from '../util/TextInput'
 import {createFullHandle} from 'lib/strings/handles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 /** STEP 3: Your user handle
  * @field User handle
  */
-export const Step3 = observer(function Step3Impl({
-  model,
+export function Step3({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   return (
     <View>
-      <StepHeader step="3" title="Your user handle" />
+      <StepHeader step="3" title={_(msg`Your user handle`)} />
       <View style={s.pb10}>
         <TextInput
           testID="handleInput"
           icon="at"
           placeholder="e.g. alice"
-          value={model.handle}
+          value={uiState.handle}
           editable
-          onChange={model.setHandle}
+          onChange={value => uiDispatch({type: 'set-handle', value})}
           // TODO: Add explicit text label
-          accessibilityLabel="User handle"
+          accessibilityLabel={_(msg`User handle`)}
           accessibilityHint="Input your user handle"
         />
         <Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
-          Your full handle will be{' '}
-          <Text type="lg-bold" style={pal.text}>
-            @{createFullHandle(model.handle, model.userDomain)}
+          <Trans>Your full handle will be</Trans>
+          <Text type="lg-bold" style={[pal.text, s.ml5]}>
+            @{createFullHandle(uiState.handle, uiState.userDomain)}
           </Text>
         </Text>
       </View>
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   error: {
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
new file mode 100644
index 000000000..4df82f8fc
--- /dev/null
+++ b/src/view/com/auth/create/state.ts
@@ -0,0 +1,242 @@
+import {useReducer} from 'react'
+import {
+  ComAtprotoServerDescribeServer,
+  ComAtprotoServerCreateAccount,
+} from '@atproto/api'
+import {I18nContext, useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import * as EmailValidator from 'email-validator'
+import {getAge} from 'lib/strings/time'
+import {logger} from '#/logger'
+import {createFullHandle} from '#/lib/strings/handles'
+import {cleanError} from '#/lib/strings/errors'
+import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
+import {ApiContext as SessionApiContext} from '#/state/session'
+import {DEFAULT_SERVICE} from '#/lib/constants'
+
+export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
+
+export type CreateAccountAction =
+  | {type: 'set-step'; value: number}
+  | {type: 'set-error'; value: string | undefined}
+  | {type: 'set-processing'; value: boolean}
+  | {type: 'set-service-url'; value: string}
+  | {type: 'set-service-description'; value: ServiceDescription | undefined}
+  | {type: 'set-user-domain'; value: string}
+  | {type: 'set-invite-code'; value: string}
+  | {type: 'set-email'; value: string}
+  | {type: 'set-password'; value: string}
+  | {type: 'set-handle'; value: string}
+  | {type: 'set-birth-date'; value: Date}
+  | {type: 'next'}
+  | {type: 'back'}
+
+export interface CreateAccountState {
+  // state
+  step: number
+  error: string | undefined
+  isProcessing: boolean
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  userDomain: string
+  inviteCode: string
+  email: string
+  password: string
+  handle: string
+  birthDate: Date
+
+  // computed
+  canBack: boolean
+  canNext: boolean
+  isInviteCodeRequired: boolean
+}
+
+export type CreateAccountDispatch = (action: CreateAccountAction) => void
+
+export function useCreateAccount() {
+  const {_} = useLingui()
+  return useReducer(createReducer({_}), {
+    step: 1,
+    error: undefined,
+    isProcessing: false,
+    serviceUrl: DEFAULT_SERVICE,
+    serviceDescription: undefined,
+    userDomain: '',
+    inviteCode: '',
+    email: '',
+    password: '',
+    handle: '',
+    birthDate: DEFAULT_DATE,
+
+    canBack: false,
+    canNext: false,
+    isInviteCodeRequired: false,
+  })
+}
+
+export async function submit({
+  createAccount,
+  onboardingDispatch,
+  uiState,
+  uiDispatch,
+  _,
+}: {
+  createAccount: SessionApiContext['createAccount']
+  onboardingDispatch: OnboardingDispatchContext
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
+  _: I18nContext['_']
+}) {
+  if (!uiState.email) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please enter your email.`),
+    })
+  }
+  if (!EmailValidator.validate(uiState.email)) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Your email appears to be invalid.`),
+    })
+  }
+  if (!uiState.password) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please choose your password.`),
+    })
+  }
+  if (!uiState.handle) {
+    uiDispatch({type: 'set-step', value: 3})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please choose your handle.`),
+    })
+  }
+  uiDispatch({type: 'set-error', value: ''})
+  uiDispatch({type: 'set-processing', value: true})
+
+  try {
+    onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
+    await createAccount({
+      service: uiState.serviceUrl,
+      email: uiState.email,
+      handle: createFullHandle(uiState.handle, uiState.userDomain),
+      password: uiState.password,
+      inviteCode: uiState.inviteCode.trim(),
+    })
+  } catch (e: any) {
+    onboardingDispatch({type: 'skip'}) // undo starting the onboard
+    let errMsg = e.toString()
+    if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
+      errMsg = _(
+        msg`Invite code not accepted. Check that you input it correctly and try again.`,
+      )
+    }
+    logger.error('Failed to create account', {error: e})
+    uiDispatch({type: 'set-processing', value: false})
+    uiDispatch({type: 'set-error', value: cleanError(errMsg)})
+    throw e
+  }
+}
+
+export function is13(state: CreateAccountState) {
+  return getAge(state.birthDate) >= 18
+}
+
+export function is18(state: CreateAccountState) {
+  return getAge(state.birthDate) >= 18
+}
+
+function createReducer({_}: {_: I18nContext['_']}) {
+  return function reducer(
+    state: CreateAccountState,
+    action: CreateAccountAction,
+  ): CreateAccountState {
+    switch (action.type) {
+      case 'set-step': {
+        return compute({...state, step: action.value})
+      }
+      case 'set-error': {
+        return compute({...state, error: action.value})
+      }
+      case 'set-processing': {
+        return compute({...state, isProcessing: action.value})
+      }
+      case 'set-service-url': {
+        return compute({
+          ...state,
+          serviceUrl: action.value,
+          serviceDescription:
+            state.serviceUrl !== action.value
+              ? undefined
+              : state.serviceDescription,
+        })
+      }
+      case 'set-service-description': {
+        return compute({
+          ...state,
+          serviceDescription: action.value,
+          userDomain: action.value?.availableUserDomains[0] || '',
+        })
+      }
+      case 'set-user-domain': {
+        return compute({...state, userDomain: action.value})
+      }
+      case 'set-invite-code': {
+        return compute({...state, inviteCode: action.value})
+      }
+      case 'set-email': {
+        return compute({...state, email: action.value})
+      }
+      case 'set-password': {
+        return compute({...state, password: action.value})
+      }
+      case 'set-handle': {
+        return compute({...state, handle: action.value})
+      }
+      case 'set-birth-date': {
+        return compute({...state, birthDate: action.value})
+      }
+      case 'next': {
+        if (state.step === 2) {
+          if (!is13(state)) {
+            return compute({
+              ...state,
+              error: _(
+                msg`Unfortunately, you do not meet the requirements to create an account.`,
+              ),
+            })
+          }
+        }
+        return compute({...state, error: '', step: state.step + 1})
+      }
+      case 'back': {
+        return compute({...state, error: '', step: state.step - 1})
+      }
+    }
+  }
+}
+
+function compute(state: CreateAccountState): CreateAccountState {
+  let canNext = true
+  if (state.step === 1) {
+    canNext = !!state.serviceDescription
+  } else if (state.step === 2) {
+    canNext =
+      (!state.isInviteCodeRequired || !!state.inviteCode) &&
+      !!state.email &&
+      !!state.password
+  } else if (state.step === 3) {
+    canNext = !!state.handle
+  }
+  return {
+    ...state,
+    canBack: state.step > 1,
+    canNext,
+    isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
+  }
+}