about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/country-codes.ts256
-rw-r--r--src/view/com/auth/create/CaptchaWebView.tsx86
-rw-r--r--src/view/com/auth/create/CaptchaWebView.web.tsx61
-rw-r--r--src/view/com/auth/create/CreateAccount.tsx71
-rw-r--r--src/view/com/auth/create/Step1.tsx9
-rw-r--r--src/view/com/auth/create/Step2.tsx308
-rw-r--r--src/view/com/auth/create/Step3.tsx120
-rw-r--r--src/view/com/auth/create/StepHeader.tsx2
-rw-r--r--src/view/com/auth/create/state.ts301
9 files changed, 434 insertions, 780 deletions
diff --git a/src/lib/country-codes.ts b/src/lib/country-codes.ts
deleted file mode 100644
index 9c9da84cf..000000000
--- a/src/lib/country-codes.ts
+++ /dev/null
@@ -1,256 +0,0 @@
-import {CountryCode} from 'libphonenumber-js'
-
-// ISO 3166-1 alpha-2 codes
-
-export interface CountryCodeMap {
-  code2: CountryCode
-  name: string
-}
-
-export const COUNTRY_CODES: CountryCodeMap[] = [
-  {code2: 'AF', name: 'Afghanistan (+93)'},
-  {code2: 'AX', name: 'Åland Islands (+358)'},
-  {code2: 'AL', name: 'Albania (+355)'},
-  {code2: 'DZ', name: 'Algeria (+213)'},
-  {code2: 'AS', name: 'American Samoa (+1)'},
-  {code2: 'AD', name: 'Andorra (+376)'},
-  {code2: 'AO', name: 'Angola (+244)'},
-  {code2: 'AI', name: 'Anguilla (+1)'},
-  {code2: 'AG', name: 'Antigua and Barbuda (+1)'},
-  {code2: 'AR', name: 'Argentina (+54)'},
-  {code2: 'AM', name: 'Armenia (+374)'},
-  {code2: 'AW', name: 'Aruba (+297)'},
-  {code2: 'AU', name: 'Australia (+61)'},
-  {code2: 'AT', name: 'Austria (+43)'},
-  {code2: 'AZ', name: 'Azerbaijan (+994)'},
-  {code2: 'BS', name: 'Bahamas (+1)'},
-  {code2: 'BH', name: 'Bahrain (+973)'},
-  {code2: 'BD', name: 'Bangladesh (+880)'},
-  {code2: 'BB', name: 'Barbados (+1)'},
-  {code2: 'BY', name: 'Belarus (+375)'},
-  {code2: 'BE', name: 'Belgium (+32)'},
-  {code2: 'BZ', name: 'Belize (+501)'},
-  {code2: 'BJ', name: 'Benin (+229)'},
-  {code2: 'BM', name: 'Bermuda (+1)'},
-  {code2: 'BT', name: 'Bhutan (+975)'},
-  {code2: 'BO', name: 'Bolivia (Plurinational State of) (+591)'},
-  {code2: 'BQ', name: 'Bonaire, Sint Eustatius and Saba (+599)'},
-  {code2: 'BA', name: 'Bosnia and Herzegovina (+387)'},
-  {code2: 'BW', name: 'Botswana (+267)'},
-  {code2: 'BR', name: 'Brazil (+55)'},
-  {code2: 'IO', name: 'British Indian Ocean Territory (+246)'},
-  {code2: 'BN', name: 'Brunei Darussalam (+673)'},
-  {code2: 'BG', name: 'Bulgaria (+359)'},
-  {code2: 'BF', name: 'Burkina Faso (+226)'},
-  {code2: 'BI', name: 'Burundi (+257)'},
-  {code2: 'CV', name: 'Cabo Verde (+238)'},
-  {code2: 'KH', name: 'Cambodia (+855)'},
-  {code2: 'CM', name: 'Cameroon (+237)'},
-  {code2: 'CA', name: 'Canada (+1)'},
-  {code2: 'KY', name: 'Cayman Islands (+1)'},
-  {code2: 'CF', name: 'Central African Republic (+236)'},
-  {code2: 'TD', name: 'Chad (+235)'},
-  {code2: 'CL', name: 'Chile (+56)'},
-  {code2: 'CN', name: 'China (+86)'},
-  {code2: 'CX', name: 'Christmas Island (+61)'},
-  {code2: 'CC', name: 'Cocos (Keeling) Islands (+61)'},
-  {code2: 'CO', name: 'Colombia (+57)'},
-  {code2: 'KM', name: 'Comoros (+269)'},
-  {code2: 'CG', name: 'Congo (+242)'},
-  {code2: 'CD', name: 'Congo, Democratic Republic of the (+243)'},
-  {code2: 'CK', name: 'Cook Islands (+682)'},
-  {code2: 'CR', name: 'Costa Rica (+506)'},
-  {code2: 'CI', name: "Côte d'Ivoire (+225)"},
-  {code2: 'HR', name: 'Croatia (+385)'},
-  {code2: 'CU', name: 'Cuba (+53)'},
-  {code2: 'CW', name: 'Curaçao (+599)'},
-  {code2: 'CY', name: 'Cyprus (+357)'},
-  {code2: 'CZ', name: 'Czechia (+420)'},
-  {code2: 'DK', name: 'Denmark (+45)'},
-  {code2: 'DJ', name: 'Djibouti (+253)'},
-  {code2: 'DM', name: 'Dominica (+1)'},
-  {code2: 'DO', name: 'Dominican Republic (+1)'},
-  {code2: 'EC', name: 'Ecuador (+593)'},
-  {code2: 'EG', name: 'Egypt (+20)'},
-  {code2: 'SV', name: 'El Salvador (+503)'},
-  {code2: 'GQ', name: 'Equatorial Guinea (+240)'},
-  {code2: 'ER', name: 'Eritrea (+291)'},
-  {code2: 'EE', name: 'Estonia (+372)'},
-  {code2: 'SZ', name: 'Eswatini (+268)'},
-  {code2: 'ET', name: 'Ethiopia (+251)'},
-  {code2: 'FK', name: 'Falkland Islands (Malvinas) (+500)'},
-  {code2: 'FO', name: 'Faroe Islands (+298)'},
-  {code2: 'FJ', name: 'Fiji (+679)'},
-  {code2: 'FI', name: 'Finland (+358)'},
-  {code2: 'FR', name: 'France (+33)'},
-  {code2: 'GF', name: 'French Guiana (+594)'},
-  {code2: 'PF', name: 'French Polynesia (+689)'},
-  {code2: 'GA', name: 'Gabon (+241)'},
-  {code2: 'GM', name: 'Gambia (+220)'},
-  {code2: 'GE', name: 'Georgia (+995)'},
-  {code2: 'DE', name: 'Germany (+49)'},
-  {code2: 'GH', name: 'Ghana (+233)'},
-  {code2: 'GI', name: 'Gibraltar (+350)'},
-  {code2: 'GR', name: 'Greece (+30)'},
-  {code2: 'GL', name: 'Greenland (+299)'},
-  {code2: 'GD', name: 'Grenada (+1)'},
-  {code2: 'GP', name: 'Guadeloupe (+590)'},
-  {code2: 'GU', name: 'Guam (+1)'},
-  {code2: 'GT', name: 'Guatemala (+502)'},
-  {code2: 'GG', name: 'Guernsey (+44)'},
-  {code2: 'GN', name: 'Guinea (+224)'},
-  {code2: 'GW', name: 'Guinea-Bissau (+245)'},
-  {code2: 'GY', name: 'Guyana (+592)'},
-  {code2: 'HT', name: 'Haiti (+509)'},
-  {code2: 'VA', name: 'Holy See (+39)'},
-  {code2: 'HN', name: 'Honduras (+504)'},
-  {code2: 'HK', name: 'Hong Kong (+852)'},
-  {code2: 'HU', name: 'Hungary (+36)'},
-  {code2: 'IS', name: 'Iceland (+354)'},
-  {code2: 'IN', name: 'India (+91)'},
-  {code2: 'ID', name: 'Indonesia (+62)'},
-  {code2: 'IR', name: 'Iran (Islamic Republic of) (+98)'},
-  {code2: 'IQ', name: 'Iraq (+964)'},
-  {code2: 'IE', name: 'Ireland (+353)'},
-  {code2: 'IM', name: 'Isle of Man (+44)'},
-  {code2: 'IL', name: 'Israel (+972)'},
-  {code2: 'IT', name: 'Italy (+39)'},
-  {code2: 'JM', name: 'Jamaica (+1)'},
-  {code2: 'JP', name: 'Japan (+81)'},
-  {code2: 'JE', name: 'Jersey (+44)'},
-  {code2: 'JO', name: 'Jordan (+962)'},
-  {code2: 'KZ', name: 'Kazakhstan (+7)'},
-  {code2: 'KE', name: 'Kenya (+254)'},
-  {code2: 'KI', name: 'Kiribati (+686)'},
-  {code2: 'KP', name: "Korea (Democratic People's Republic of) (+850)"},
-  {code2: 'KR', name: 'Korea, Republic of (+82)'},
-  {code2: 'KW', name: 'Kuwait (+965)'},
-  {code2: 'KG', name: 'Kyrgyzstan (+996)'},
-  {code2: 'LA', name: "Lao People's Democratic Republic (+856)"},
-  {code2: 'LV', name: 'Latvia (+371)'},
-  {code2: 'LB', name: 'Lebanon (+961)'},
-  {code2: 'LS', name: 'Lesotho (+266)'},
-  {code2: 'LR', name: 'Liberia (+231)'},
-  {code2: 'LY', name: 'Libya (+218)'},
-  {code2: 'LI', name: 'Liechtenstein (+423)'},
-  {code2: 'LT', name: 'Lithuania (+370)'},
-  {code2: 'LU', name: 'Luxembourg (+352)'},
-  {code2: 'MO', name: 'Macao (+853)'},
-  {code2: 'MG', name: 'Madagascar (+261)'},
-  {code2: 'MW', name: 'Malawi (+265)'},
-  {code2: 'MY', name: 'Malaysia (+60)'},
-  {code2: 'MV', name: 'Maldives (+960)'},
-  {code2: 'ML', name: 'Mali (+223)'},
-  {code2: 'MT', name: 'Malta (+356)'},
-  {code2: 'MH', name: 'Marshall Islands (+692)'},
-  {code2: 'MQ', name: 'Martinique (+596)'},
-  {code2: 'MR', name: 'Mauritania (+222)'},
-  {code2: 'MU', name: 'Mauritius (+230)'},
-  {code2: 'YT', name: 'Mayotte (+262)'},
-  {code2: 'MX', name: 'Mexico (+52)'},
-  {code2: 'FM', name: 'Micronesia (Federated States of) (+691)'},
-  {code2: 'MD', name: 'Moldova, Republic of (+373)'},
-  {code2: 'MC', name: 'Monaco (+377)'},
-  {code2: 'MN', name: 'Mongolia (+976)'},
-  {code2: 'ME', name: 'Montenegro (+382)'},
-  {code2: 'MS', name: 'Montserrat (+1)'},
-  {code2: 'MA', name: 'Morocco (+212)'},
-  {code2: 'MZ', name: 'Mozambique (+258)'},
-  {code2: 'MM', name: 'Myanmar (+95)'},
-  {code2: 'NA', name: 'Namibia (+264)'},
-  {code2: 'NR', name: 'Nauru (+674)'},
-  {code2: 'NP', name: 'Nepal (+977)'},
-  {code2: 'NL', name: 'Netherlands, Kingdom of the (+31)'},
-  {code2: 'NC', name: 'New Caledonia (+687)'},
-  {code2: 'NZ', name: 'New Zealand (+64)'},
-  {code2: 'NI', name: 'Nicaragua (+505)'},
-  {code2: 'NE', name: 'Niger (+227)'},
-  {code2: 'NG', name: 'Nigeria (+234)'},
-  {code2: 'NU', name: 'Niue (+683)'},
-  {code2: 'NF', name: 'Norfolk Island (+672)'},
-  {code2: 'MK', name: 'North Macedonia (+389)'},
-  {code2: 'MP', name: 'Northern Mariana Islands (+1)'},
-  {code2: 'NO', name: 'Norway (+47)'},
-  {code2: 'OM', name: 'Oman (+968)'},
-  {code2: 'PK', name: 'Pakistan (+92)'},
-  {code2: 'PW', name: 'Palau (+680)'},
-  {code2: 'PS', name: 'Palestine, State of (+970)'},
-  {code2: 'PA', name: 'Panama (+507)'},
-  {code2: 'PG', name: 'Papua New Guinea (+675)'},
-  {code2: 'PY', name: 'Paraguay (+595)'},
-  {code2: 'PE', name: 'Peru (+51)'},
-  {code2: 'PH', name: 'Philippines (+63)'},
-  {code2: 'PL', name: 'Poland (+48)'},
-  {code2: 'PT', name: 'Portugal (+351)'},
-  {code2: 'PR', name: 'Puerto Rico (+1)'},
-  {code2: 'QA', name: 'Qatar (+974)'},
-  {code2: 'RE', name: 'Réunion (+262)'},
-  {code2: 'RO', name: 'Romania (+40)'},
-  {code2: 'RU', name: 'Russian Federation (+7)'},
-  {code2: 'RW', name: 'Rwanda (+250)'},
-  {code2: 'BL', name: 'Saint Barthélemy (+590)'},
-  {code2: 'SH', name: 'Saint Helena, Ascension and Tristan da Cunha (+290)'},
-  {code2: 'KN', name: 'Saint Kitts and Nevis (+1)'},
-  {code2: 'LC', name: 'Saint Lucia (+1)'},
-  {code2: 'MF', name: 'Saint Martin (French part) (+590)'},
-  {code2: 'PM', name: 'Saint Pierre and Miquelon (+508)'},
-  {code2: 'VC', name: 'Saint Vincent and the Grenadines (+1)'},
-  {code2: 'WS', name: 'Samoa (+685)'},
-  {code2: 'SM', name: 'San Marino (+378)'},
-  {code2: 'ST', name: 'Sao Tome and Principe (+239)'},
-  {code2: 'SA', name: 'Saudi Arabia (+966)'},
-  {code2: 'SN', name: 'Senegal (+221)'},
-  {code2: 'RS', name: 'Serbia (+381)'},
-  {code2: 'SC', name: 'Seychelles (+248)'},
-  {code2: 'SL', name: 'Sierra Leone (+232)'},
-  {code2: 'SG', name: 'Singapore (+65)'},
-  {code2: 'SX', name: 'Sint Maarten (Dutch part) (+1)'},
-  {code2: 'SK', name: 'Slovakia (+421)'},
-  {code2: 'SI', name: 'Slovenia (+386)'},
-  {code2: 'SB', name: 'Solomon Islands (+677)'},
-  {code2: 'SO', name: 'Somalia (+252)'},
-  {code2: 'ZA', name: 'South Africa (+27)'},
-  {code2: 'SS', name: 'South Sudan (+211)'},
-  {code2: 'ES', name: 'Spain (+34)'},
-  {code2: 'LK', name: 'Sri Lanka (+94)'},
-  {code2: 'SD', name: 'Sudan (+249)'},
-  {code2: 'SR', name: 'Suriname (+597)'},
-  {code2: 'SJ', name: 'Svalbard and Jan Mayen (+47)'},
-  {code2: 'SE', name: 'Sweden (+46)'},
-  {code2: 'CH', name: 'Switzerland (+41)'},
-  {code2: 'SY', name: 'Syrian Arab Republic (+963)'},
-  {code2: 'TW', name: 'Taiwan (+886)'},
-  {code2: 'TJ', name: 'Tajikistan (+992)'},
-  {code2: 'TZ', name: 'Tanzania, United Republic of (+255)'},
-  {code2: 'TH', name: 'Thailand (+66)'},
-  {code2: 'TL', name: 'Timor-Leste (+670)'},
-  {code2: 'TG', name: 'Togo (+228)'},
-  {code2: 'TK', name: 'Tokelau (+690)'},
-  {code2: 'TO', name: 'Tonga (+676)'},
-  {code2: 'TT', name: 'Trinidad and Tobago (+1)'},
-  {code2: 'TN', name: 'Tunisia (+216)'},
-  {code2: 'TR', name: 'Türkiye (+90)'},
-  {code2: 'TM', name: 'Turkmenistan (+993)'},
-  {code2: 'TC', name: 'Turks and Caicos Islands (+1)'},
-  {code2: 'TV', name: 'Tuvalu (+688)'},
-  {code2: 'UG', name: 'Uganda (+256)'},
-  {code2: 'UA', name: 'Ukraine (+380)'},
-  {code2: 'AE', name: 'United Arab Emirates (+971)'},
-  {
-    code2: 'GB',
-    name: 'United Kingdom of Great Britain and Northern Ireland (+44)',
-  },
-  {code2: 'US', name: 'United States of America (+1)'},
-  {code2: 'UY', name: 'Uruguay (+598)'},
-  {code2: 'UZ', name: 'Uzbekistan (+998)'},
-  {code2: 'VU', name: 'Vanuatu (+678)'},
-  {code2: 'VE', name: 'Venezuela (Bolivarian Republic of) (+58)'},
-  {code2: 'VN', name: 'Viet Nam (+84)'},
-  {code2: 'VG', name: 'Virgin Islands (British) (+1)'},
-  {code2: 'VI', name: 'Virgin Islands (U.S.) (+1)'},
-  {code2: 'WF', name: 'Wallis and Futuna (+681)'},
-  {code2: 'EH', name: 'Western Sahara (+212)'},
-  {code2: 'YE', name: 'Yemen (+967)'},
-  {code2: 'ZM', name: 'Zambia (+260)'},
-  {code2: 'ZW', name: 'Zimbabwe (+263)'},
-]
diff --git a/src/view/com/auth/create/CaptchaWebView.tsx b/src/view/com/auth/create/CaptchaWebView.tsx
new file mode 100644
index 000000000..b0de8b4a4
--- /dev/null
+++ b/src/view/com/auth/create/CaptchaWebView.tsx
@@ -0,0 +1,86 @@
+import React from 'react'
+import {WebView, WebViewNavigation} from 'react-native-webview'
+import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
+import {StyleSheet} from 'react-native'
+import {CreateAccountState} from 'view/com/auth/create/state'
+
+const ALLOWED_HOSTS = [
+  'bsky.social',
+  'bsky.app',
+  'staging.bsky.app',
+  'staging.bsky.dev',
+  'js.hcaptcha.com',
+  'newassets.hcaptcha.com',
+  'api2.hcaptcha.com',
+]
+
+export function CaptchaWebView({
+  url,
+  stateParam,
+  uiState,
+  onSuccess,
+  onError,
+}: {
+  url: string
+  stateParam: string
+  uiState?: CreateAccountState
+  onSuccess: (code: string) => void
+  onError: () => void
+}) {
+  const redirectHost = React.useMemo(() => {
+    if (!uiState?.serviceUrl) return 'bsky.app'
+
+    return uiState?.serviceUrl &&
+      new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
+      ? 'staging.bsky.app'
+      : 'bsky.app'
+  }, [uiState?.serviceUrl])
+
+  const wasSuccessful = React.useRef(false)
+
+  const onShouldStartLoadWithRequest = React.useCallback(
+    (event: ShouldStartLoadRequest) => {
+      const urlp = new URL(event.url)
+      return ALLOWED_HOSTS.includes(urlp.host)
+    },
+    [],
+  )
+
+  const onNavigationStateChange = React.useCallback(
+    (e: WebViewNavigation) => {
+      if (wasSuccessful.current) return
+
+      const urlp = new URL(e.url)
+      if (urlp.host !== redirectHost) return
+
+      const code = urlp.searchParams.get('code')
+      if (urlp.searchParams.get('state') !== stateParam || !code) {
+        onError()
+        return
+      }
+
+      wasSuccessful.current = true
+      onSuccess(code)
+    },
+    [redirectHost, stateParam, onSuccess, onError],
+  )
+
+  return (
+    <WebView
+      source={{uri: url}}
+      javaScriptEnabled
+      style={styles.webview}
+      onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+      onNavigationStateChange={onNavigationStateChange}
+      scrollEnabled={false}
+    />
+  )
+}
+
+const styles = StyleSheet.create({
+  webview: {
+    flex: 1,
+    backgroundColor: 'transparent',
+    borderRadius: 10,
+  },
+})
diff --git a/src/view/com/auth/create/CaptchaWebView.web.tsx b/src/view/com/auth/create/CaptchaWebView.web.tsx
new file mode 100644
index 000000000..7791a58dd
--- /dev/null
+++ b/src/view/com/auth/create/CaptchaWebView.web.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {StyleSheet} from 'react-native'
+
+// @ts-ignore web only, we will always redirect to the app on web (CORS)
+const REDIRECT_HOST = new URL(window.location.href).host
+
+export function CaptchaWebView({
+  url,
+  stateParam,
+  onSuccess,
+  onError,
+}: {
+  url: string
+  stateParam: string
+  onSuccess: (code: string) => void
+  onError: () => void
+}) {
+  const onLoad = React.useCallback(() => {
+    // @ts-ignore web
+    const frame: HTMLIFrameElement = document.getElementById(
+      'captcha-iframe',
+    ) as HTMLIFrameElement
+
+    try {
+      // @ts-ignore web
+      const href = frame?.contentWindow?.location.href
+      if (!href) return
+      const urlp = new URL(href)
+
+      // This shouldn't happen with CORS protections, but for good measure
+      if (urlp.host !== REDIRECT_HOST) return
+
+      const code = urlp.searchParams.get('code')
+      if (urlp.searchParams.get('state') !== stateParam || !code) {
+        onError()
+        return
+      }
+      onSuccess(code)
+    } catch (e) {
+      // We don't need to handle this
+    }
+  }, [stateParam, onSuccess, onError])
+
+  return (
+    <iframe
+      src={url}
+      style={styles.iframe}
+      id="captcha-iframe"
+      onLoad={onLoad}
+    />
+  )
+}
+
+const styles = StyleSheet.create({
+  iframe: {
+    flex: 1,
+    borderWidth: 0,
+    borderRadius: 10,
+    backgroundColor: 'transparent',
+  },
+})
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
index 5d452736a..8aefffa6d 100644
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ b/src/view/com/auth/create/CreateAccount.tsx
@@ -13,33 +13,25 @@ import {s} from 'lib/styles'
 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 {useCreateAccount, useSubmitCreateAccount} from './state'
 import {useServiceQuery} from '#/state/queries/service'
-import {
-  usePreferencesSetBirthDateMutation,
-  useSetSaveFeedsMutation,
-  DEFAULT_PROD_FEEDS,
-} from '#/state/queries/preferences'
-import {FEEDBACK_FORM_URL, HITSLOP_10, IS_PROD} from '#/lib/constants'
+import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants'
 
 import {Step1} from './Step1'
 import {Step2} from './Step2'
 import {Step3} from './Step3'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {TextLink} from '../../util/Link'
+import {getAgent} from 'state/session'
+import {createFullHandle} from 'lib/strings/handles'
 
 export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
   const {screen} = useAnalytics()
   const pal = usePalette('default')
   const {_} = useLingui()
   const [uiState, uiDispatch] = useCreateAccount()
-  const onboardingDispatch = useOnboardingDispatch()
-  const {createAccount} = useSessionApi()
-  const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
-  const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
   const {isTabletOrDesktop} = useWebMediaQueries()
+  const submit = useSubmitCreateAccount(uiState, uiDispatch)
 
   React.useEffect(() => {
     screen('CreateAccount')
@@ -84,33 +76,48 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
     if (!uiState.canNext) {
       return
     }
-    if (uiState.step < 3) {
-      uiDispatch({type: 'next'})
-    } else {
+
+    if (uiState.step === 2) {
+      uiDispatch({type: 'set-processing', value: true})
       try {
-        await submit({
-          onboardingDispatch,
-          createAccount,
-          uiState,
-          uiDispatch,
-          _,
+        const res = await getAgent().resolveHandle({
+          handle: createFullHandle(uiState.handle, uiState.userDomain),
         })
-        setBirthDate({birthDate: uiState.birthDate})
-        if (IS_PROD(uiState.serviceUrl)) {
-          setSavedFeeds(DEFAULT_PROD_FEEDS)
+
+        if (res.data.did) {
+          uiDispatch({
+            type: 'set-error',
+            value: _(msg`That handle is already taken.`),
+          })
+          return
         }
-      } catch {
-        // dont need to handle here
+      } catch (e) {
+        // Don't need to handle
+      } finally {
+        uiDispatch({type: 'set-processing', value: false})
+      }
+
+      if (!uiState.isCaptchaRequired) {
+        try {
+          await submit()
+        } catch {
+          // dont need to handle here
+        }
+        // We don't need to go to the next page if there wasn't a captcha required
+        return
       }
     }
+
+    uiDispatch({type: 'next'})
   }, [
-    uiState,
+    uiState.canNext,
+    uiState.step,
+    uiState.isCaptchaRequired,
+    uiState.handle,
+    uiState.userDomain,
     uiDispatch,
-    onboardingDispatch,
-    createAccount,
-    setBirthDate,
-    setSavedFeeds,
     _,
+    submit,
   ])
 
   // rendering
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index a7abbfaa8..4c7018485 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -73,6 +73,10 @@ export function Step1({
       />
       <StepHeader uiState={uiState} title={_(msg`Your account`)} />
 
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
+      ) : undefined}
+
       <View style={s.pb20}>
         <Text type="md-medium" style={[pal.text, s.mb2]}>
           <Trans>Hosting provider</Trans>
@@ -259,9 +263,6 @@ export function Step1({
           )}
         </>
       )}
-      {uiState.error ? (
-        <ErrorMessage message={uiState.error} style={styles.error} />
-      ) : undefined}
     </View>
   )
 }
@@ -269,7 +270,7 @@ export function Step1({
 const styles = StyleSheet.create({
   error: {
     borderRadius: 6,
-    marginTop: 10,
+    marginBottom: 10,
   },
   dateInputButton: {
     borderWidth: 1,
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index 2e16b13bb..87d414bb9 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -1,35 +1,19 @@
 import React from 'react'
-import {
-  ActivityIndicator,
-  StyleSheet,
-  TouchableWithoutFeedback,
-  View,
-} from 'react-native'
-import RNPickerSelect from 'react-native-picker-select'
-import {
-  CreateAccountState,
-  CreateAccountDispatch,
-  requestVerificationCode,
-} from './state'
+import {StyleSheet, View} from 'react-native'
+import {CreateAccountState, CreateAccountDispatch} from './state'
 import {Text} from 'view/com/util/text/Text'
 import {StepHeader} from './StepHeader'
 import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
 import {TextInput} from '../util/TextInput'
-import {Button} from '../../util/forms/Button'
+import {createFullHandle} from 'lib/strings/handles'
+import {usePalette} from 'lib/hooks/usePalette'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {isAndroid, isWeb} from 'platform/detection'
-import {Trans, msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import parsePhoneNumber from 'libphonenumber-js'
-import {COUNTRY_CODES} from '#/lib/country-codes'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {HITSLOP_10} from '#/lib/constants'
 
+/** STEP 3: Your user handle
+ * @field User handle
+ */
 export function Step2({
   uiState,
   uiDispatch,
@@ -39,258 +23,34 @@ export function Step2({
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
-  const {isMobile} = useWebMediaQueries()
-
-  const onPressRequest = React.useCallback(() => {
-    const phoneNumber = parsePhoneNumber(
-      uiState.verificationPhone,
-      uiState.phoneCountry,
-    )
-    if (phoneNumber && phoneNumber.isValid()) {
-      requestVerificationCode({uiState, uiDispatch, _})
-    } else {
-      uiDispatch({
-        type: 'set-error',
-        value: _(
-          msg`There's something wrong with this number. Please choose your country and enter your full phone number!`,
-        ),
-      })
-    }
-  }, [uiState, uiDispatch, _])
-
-  const onPressRetry = React.useCallback(() => {
-    uiDispatch({type: 'set-has-requested-verification-code', value: false})
-  }, [uiDispatch])
-
-  const phoneNumberFormatted = React.useMemo(
-    () =>
-      uiState.hasRequestedVerificationCode
-        ? parsePhoneNumber(
-            uiState.verificationPhone,
-            uiState.phoneCountry,
-          )?.formatInternational()
-        : '',
-    [
-      uiState.hasRequestedVerificationCode,
-      uiState.verificationPhone,
-      uiState.phoneCountry,
-    ],
-  )
-
   return (
     <View>
-      <StepHeader uiState={uiState} title={_(msg`SMS verification`)} />
-
-      {!uiState.hasRequestedVerificationCode ? (
-        <>
-          <View style={s.pb10}>
-            <Text
-              type="md-medium"
-              style={[pal.text, s.mb2]}
-              nativeID="phoneCountry">
-              <Trans>Country</Trans>
-            </Text>
-            <View
-              style={[
-                {position: 'relative'},
-                isAndroid && {
-                  borderWidth: 1,
-                  borderColor: pal.border.borderColor,
-                  borderRadius: 4,
-                },
-              ]}>
-              <RNPickerSelect
-                placeholder={{}}
-                value={uiState.phoneCountry}
-                onValueChange={value =>
-                  uiDispatch({type: 'set-phone-country', value})
-                }
-                items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({
-                  label: l.name,
-                  value: l.code2,
-                  key: l.code2,
-                }))}
-                style={{
-                  inputAndroid: {
-                    backgroundColor: pal.view.backgroundColor,
-                    color: pal.text.color,
-                    fontSize: 21,
-                    letterSpacing: 0.5,
-                    fontWeight: '500',
-                    paddingHorizontal: 14,
-                    paddingVertical: 8,
-                    borderRadius: 4,
-                  },
-                  inputIOS: {
-                    backgroundColor: pal.view.backgroundColor,
-                    color: pal.text.color,
-                    fontSize: 14,
-                    letterSpacing: 0.5,
-                    fontWeight: '500',
-                    paddingHorizontal: 14,
-                    paddingVertical: 8,
-                    borderWidth: 1,
-                    borderColor: pal.border.borderColor,
-                    borderRadius: 4,
-                  },
-                  inputWeb: {
-                    // @ts-ignore web only
-                    cursor: 'pointer',
-                    '-moz-appearance': 'none',
-                    '-webkit-appearance': 'none',
-                    appearance: 'none',
-                    outline: 0,
-                    borderWidth: 1,
-                    borderColor: pal.border.borderColor,
-                    backgroundColor: pal.view.backgroundColor,
-                    color: pal.text.color,
-                    fontSize: 14,
-                    letterSpacing: 0.5,
-                    fontWeight: '500',
-                    paddingHorizontal: 14,
-                    paddingVertical: 8,
-                    borderRadius: 4,
-                  },
-                }}
-                accessibilityLabel={_(msg`Select your phone's country`)}
-                accessibilityHint=""
-                accessibilityLabelledBy="phoneCountry"
-              />
-              <View
-                style={{
-                  position: 'absolute',
-                  top: 1,
-                  right: 1,
-                  bottom: 1,
-                  width: 40,
-                  pointerEvents: 'none',
-                  alignItems: 'center',
-                  justifyContent: 'center',
-                }}>
-                <FontAwesomeIcon
-                  icon="chevron-down"
-                  style={pal.text as FontAwesomeIconStyle}
-                />
-              </View>
-            </View>
-          </View>
-
-          <View style={s.pb20}>
-            <Text
-              type="md-medium"
-              style={[pal.text, s.mb2]}
-              nativeID="phoneNumber">
-              <Trans>Phone number</Trans>
-            </Text>
-            <TextInput
-              testID="phoneInput"
-              icon="phone"
-              placeholder={_(msg`Enter your phone number`)}
-              value={uiState.verificationPhone}
-              editable
-              onChange={value =>
-                uiDispatch({type: 'set-verification-phone', value})
-              }
-              accessibilityLabel={_(msg`Email`)}
-              accessibilityHint={_(
-                msg`Input phone number for SMS verification`,
-              )}
-              accessibilityLabelledBy="phoneNumber"
-              keyboardType="phone-pad"
-              autoCapitalize="none"
-              autoComplete="tel"
-              autoCorrect={false}
-              autoFocus={true}
-            />
-            <Text type="sm" style={[pal.textLight, s.mt5]}>
-              <Trans>
-                Please enter a phone number that can receive SMS text messages.
-              </Trans>
-            </Text>
-          </View>
-
-          <View style={isMobile ? {} : {flexDirection: 'row'}}>
-            {uiState.isProcessing ? (
-              <ActivityIndicator />
-            ) : (
-              <Button
-                testID="requestCodeBtn"
-                type="primary"
-                label={_(msg`Request code`)}
-                labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []}
-                style={
-                  isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {}
-                }
-                onPress={onPressRequest}
-              />
-            )}
-          </View>
-        </>
-      ) : (
-        <>
-          <View style={s.pb20}>
-            <View
-              style={[
-                s.flexRow,
-                s.mb5,
-                s.alignCenter,
-                {justifyContent: 'space-between'},
-              ]}>
-              <Text
-                type="md-medium"
-                style={pal.text}
-                nativeID="verificationCode">
-                <Trans>Verification code</Trans>{' '}
-              </Text>
-              <TouchableWithoutFeedback
-                onPress={onPressRetry}
-                accessibilityLabel={_(msg`Retry.`)}
-                accessibilityHint=""
-                hitSlop={HITSLOP_10}>
-                <View style={styles.touchable}>
-                  <Text
-                    type="md-medium"
-                    style={pal.link}
-                    nativeID="verificationCode">
-                    <Trans>Retry</Trans>
-                  </Text>
-                </View>
-              </TouchableWithoutFeedback>
-            </View>
-            <TextInput
-              testID="codeInput"
-              icon="hashtag"
-              placeholder={_(msg`XXXXXX`)}
-              value={uiState.verificationCode}
-              editable
-              onChange={value =>
-                uiDispatch({type: 'set-verification-code', value})
-              }
-              accessibilityLabel={_(msg`Email`)}
-              accessibilityHint={_(
-                msg`Input the verification code we have texted to you`,
-              )}
-              accessibilityLabelledBy="verificationCode"
-              keyboardType="phone-pad"
-              autoCapitalize="none"
-              autoComplete="one-time-code"
-              textContentType="oneTimeCode"
-              autoCorrect={false}
-              autoFocus={true}
-            />
-            <Text type="sm" style={[pal.textLight, s.mt5]}>
-              <Trans>
-                Please enter the verification code sent to{' '}
-                {phoneNumberFormatted}.
-              </Trans>
-            </Text>
-          </View>
-        </>
-      )}
-
+      <StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
       {uiState.error ? (
         <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
+      <View style={s.pb10}>
+        <TextInput
+          testID="handleInput"
+          icon="at"
+          placeholder="e.g. alice"
+          value={uiState.handle}
+          editable
+          autoFocus
+          autoComplete="off"
+          autoCorrect={false}
+          onChange={value => uiDispatch({type: 'set-handle', value})}
+          // TODO: Add explicit text label
+          accessibilityLabel={_(msg`User handle`)}
+          accessibilityHint={_(msg`Input your user handle`)}
+        />
+        <Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
+          <Trans>Your full handle will be</Trans>{' '}
+          <Text type="lg-bold" style={pal.text}>
+            @{createFullHandle(uiState.handle, uiState.userDomain)}
+          </Text>
+        </Text>
+      </View>
     </View>
   )
 }
@@ -298,10 +58,6 @@ export function Step2({
 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'}),
+    marginBottom: 10,
   },
 })
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
index 3a52abf80..53fdfdde8 100644
--- a/src/view/com/auth/create/Step3.tsx
+++ b/src/view/com/auth/create/Step3.tsx
@@ -1,19 +1,23 @@
 import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {CreateAccountState, CreateAccountDispatch} from './state'
-import {Text} from 'view/com/util/text/Text'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
+import {
+  CreateAccountState,
+  CreateAccountDispatch,
+  useSubmitCreateAccount,
+} from './state'
 import {StepHeader} from './StepHeader'
-import {s} from 'lib/styles'
-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 {isWeb} from 'platform/detection'
+import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-/** STEP 3: Your user handle
- * @field User handle
- */
+import {nanoid} from 'nanoid/non-secure'
+import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
+import {useTheme} from 'lib/ThemeContext'
+import {createFullHandle} from 'lib/strings/handles'
+
+const CAPTCHA_PATH = '/gate/signup'
+
 export function Step3({
   uiState,
   uiDispatch,
@@ -21,33 +25,66 @@ export function Step3({
   uiState: CreateAccountState
   uiDispatch: CreateAccountDispatch
 }) {
-  const pal = usePalette('default')
   const {_} = useLingui()
+  const theme = useTheme()
+  const submit = useSubmitCreateAccount(uiState, uiDispatch)
+
+  const [completed, setCompleted] = React.useState(false)
+
+  const stateParam = React.useMemo(() => nanoid(15), [])
+  const url = React.useMemo(() => {
+    const newUrl = new URL(uiState.serviceUrl)
+    newUrl.pathname = CAPTCHA_PATH
+    newUrl.searchParams.set(
+      'handle',
+      createFullHandle(uiState.handle, uiState.userDomain),
+    )
+    newUrl.searchParams.set('state', stateParam)
+    newUrl.searchParams.set('colorScheme', theme.colorScheme)
+
+    console.log(newUrl)
+
+    return newUrl.href
+  }, [
+    uiState.serviceUrl,
+    uiState.handle,
+    uiState.userDomain,
+    stateParam,
+    theme.colorScheme,
+  ])
+
+  const onSuccess = React.useCallback(
+    (code: string) => {
+      setCompleted(true)
+      submit(code)
+    },
+    [submit],
+  )
+
+  const onError = React.useCallback(() => {
+    uiDispatch({
+      type: 'set-error',
+      value: _(msg`Error receiving captcha response.`),
+    })
+  }, [_, uiDispatch])
+
   return (
     <View>
-      <StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
-      <View style={s.pb10}>
-        <TextInput
-          testID="handleInput"
-          icon="at"
-          placeholder="e.g. alice"
-          value={uiState.handle}
-          editable
-          autoFocus
-          autoComplete="off"
-          autoCorrect={false}
-          onChange={value => uiDispatch({type: 'set-handle', value})}
-          // TODO: Add explicit text label
-          accessibilityLabel={_(msg`User handle`)}
-          accessibilityHint={_(msg`Input your user handle`)}
-        />
-        <Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
-          <Trans>Your full handle will be</Trans>{' '}
-          <Text type="lg-bold" style={pal.text}>
-            @{createFullHandle(uiState.handle, uiState.userDomain)}
-          </Text>
-        </Text>
+      <StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} />
+      <View style={[styles.container, completed && styles.center]}>
+        {!completed ? (
+          <CaptchaWebView
+            url={url}
+            stateParam={stateParam}
+            uiState={uiState}
+            onSuccess={onSuccess}
+            onError={onError}
+          />
+        ) : (
+          <ActivityIndicator size="large" />
+        )}
       </View>
+
       {uiState.error ? (
         <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
@@ -58,5 +95,20 @@ export function Step3({
 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/view/com/auth/create/StepHeader.tsx b/src/view/com/auth/create/StepHeader.tsx
index af6bf5478..a98b392d8 100644
--- a/src/view/com/auth/create/StepHeader.tsx
+++ b/src/view/com/auth/create/StepHeader.tsx
@@ -11,7 +11,7 @@ export function StepHeader({
   children,
 }: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
   const pal = usePalette('default')
-  const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2
+  const numSteps = 3
   return (
     <View style={styles.container}>
       <View>
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
index e8a7cd4ed..276eaf924 100644
--- a/src/view/com/auth/create/state.ts
+++ b/src/view/com/auth/create/state.ts
@@ -1,8 +1,7 @@
-import {useReducer} from 'react'
+import {useCallback, useReducer} from 'react'
 import {
   ComAtprotoServerDescribeServer,
   ComAtprotoServerCreateAccount,
-  BskyAgent,
 } from '@atproto/api'
 import {I18nContext, useLingui} from '@lingui/react'
 import {msg} from '@lingui/macro'
@@ -11,10 +10,14 @@ 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'
-import parsePhoneNumber, {CountryCode} from 'libphonenumber-js'
+import {useOnboardingDispatch} from '#/state/shell/onboarding'
+import {useSessionApi} from '#/state/session'
+import {DEFAULT_SERVICE, IS_PROD} from '#/lib/constants'
+import {
+  DEFAULT_PROD_FEEDS,
+  usePreferencesSetBirthDateMutation,
+  useSetSaveFeedsMutation,
+} from 'state/queries/preferences'
 
 export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
 const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
@@ -29,10 +32,6 @@ export type CreateAccountAction =
   | {type: 'set-invite-code'; value: string}
   | {type: 'set-email'; value: string}
   | {type: 'set-password'; value: string}
-  | {type: 'set-phone-country'; value: CountryCode}
-  | {type: 'set-verification-phone'; value: string}
-  | {type: 'set-verification-code'; value: string}
-  | {type: 'set-has-requested-verification-code'; value: boolean}
   | {type: 'set-handle'; value: string}
   | {type: 'set-birth-date'; value: Date}
   | {type: 'next'}
@@ -49,10 +48,6 @@ export interface CreateAccountState {
   inviteCode: string
   email: string
   password: string
-  phoneCountry: CountryCode
-  verificationPhone: string
-  verificationCode: string
-  hasRequestedVerificationCode: boolean
   handle: string
   birthDate: Date
 
@@ -60,13 +55,14 @@ export interface CreateAccountState {
   canBack: boolean
   canNext: boolean
   isInviteCodeRequired: boolean
-  isPhoneVerificationRequired: boolean
+  isCaptchaRequired: boolean
 }
 
 export type CreateAccountDispatch = (action: CreateAccountAction) => void
 
 export function useCreateAccount() {
   const {_} = useLingui()
+
   return useReducer(createReducer({_}), {
     step: 1,
     error: undefined,
@@ -77,144 +73,126 @@ export function useCreateAccount() {
     inviteCode: '',
     email: '',
     password: '',
-    phoneCountry: 'US',
-    verificationPhone: '',
-    verificationCode: '',
-    hasRequestedVerificationCode: false,
     handle: '',
     birthDate: DEFAULT_DATE,
 
     canBack: false,
     canNext: false,
     isInviteCodeRequired: false,
-    isPhoneVerificationRequired: false,
+    isCaptchaRequired: false,
   })
 }
 
-export async function requestVerificationCode({
-  uiState,
-  uiDispatch,
-  _,
-}: {
-  uiState: CreateAccountState
-  uiDispatch: CreateAccountDispatch
-  _: I18nContext['_']
-}) {
-  const phoneNumber = parsePhoneNumber(
-    uiState.verificationPhone,
-    uiState.phoneCountry,
-  )?.number
-  if (!phoneNumber) {
-    return
-  }
-  uiDispatch({type: 'set-error', value: ''})
-  uiDispatch({type: 'set-processing', value: true})
-  uiDispatch({type: 'set-verification-phone', value: phoneNumber})
-  try {
-    const agent = new BskyAgent({service: uiState.serviceUrl})
-    await agent.com.atproto.temp.requestPhoneVerification({
-      phoneNumber,
-    })
-    uiDispatch({type: 'set-has-requested-verification-code', value: true})
-  } catch (e: any) {
-    logger.error(
-      `Failed to request sms verification code (${e.status} status)`,
-      {message: e},
-    )
-    uiDispatch({type: 'set-error', value: cleanError(e.toString())})
-  }
-  uiDispatch({type: 'set-processing', value: false})
-}
+export function useSubmitCreateAccount(
+  uiState: CreateAccountState,
+  uiDispatch: CreateAccountDispatch,
+) {
+  const {_} = useLingui()
+  const {createAccount} = useSessionApi()
+  const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
+  const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
+  const onboardingDispatch = useOnboardingDispatch()
 
-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: 1})
-    return uiDispatch({
-      type: 'set-error',
-      value: _(msg`Please enter your email.`),
-    })
-  }
-  if (!EmailValidator.validate(uiState.email)) {
-    uiDispatch({type: 'set-step', value: 1})
-    return uiDispatch({
-      type: 'set-error',
-      value: _(msg`Your email appears to be invalid.`),
-    })
-  }
-  if (!uiState.password) {
-    uiDispatch({type: 'set-step', value: 1})
-    return uiDispatch({
-      type: 'set-error',
-      value: _(msg`Please choose your password.`),
-    })
-  }
-  if (
-    uiState.isPhoneVerificationRequired &&
-    (!uiState.verificationPhone || !uiState.verificationCode)
-  ) {
-    uiDispatch({type: 'set-step', value: 2})
-    return uiDispatch({
-      type: 'set-error',
-      value: _(msg`Please enter the code you received by SMS.`),
-    })
-  }
-  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})
+  return useCallback(
+    async (verificationCode?: string) => {
+      if (!uiState.email) {
+        uiDispatch({type: 'set-step', value: 1})
+        console.log('no email?')
+        return uiDispatch({
+          type: 'set-error',
+          value: _(msg`Please enter your email.`),
+        })
+      }
+      if (!EmailValidator.validate(uiState.email)) {
+        uiDispatch({type: 'set-step', value: 1})
+        return uiDispatch({
+          type: 'set-error',
+          value: _(msg`Your email appears to be invalid.`),
+        })
+      }
+      if (!uiState.password) {
+        uiDispatch({type: 'set-step', value: 1})
+        return uiDispatch({
+          type: 'set-error',
+          value: _(msg`Please choose your password.`),
+        })
+      }
+      if (!uiState.handle) {
+        uiDispatch({type: 'set-step', value: 2})
+        return uiDispatch({
+          type: 'set-error',
+          value: _(msg`Please choose your handle.`),
+        })
+      }
+      if (uiState.isCaptchaRequired && !verificationCode) {
+        uiDispatch({type: 'set-step', value: 3})
+        return uiDispatch({
+          type: 'set-error',
+          value: _(msg`Please complete the verification captcha.`),
+        })
+      }
+      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(),
-      verificationPhone: uiState.verificationPhone.trim(),
-      verificationCode: uiState.verificationCode.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.`,
-      )
-      uiDispatch({type: 'set-step', value: 1})
-    } else if (e.error === 'InvalidPhoneVerification') {
-      uiDispatch({type: 'set-step', value: 2})
-    }
+      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(),
+          verificationCode: uiState.isCaptchaRequired
+            ? verificationCode
+            : undefined,
+        })
+        setBirthDate({birthDate: uiState.birthDate})
+        if (IS_PROD(uiState.serviceUrl)) {
+          setSavedFeeds(DEFAULT_PROD_FEEDS)
+        }
+      } 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.`,
+          )
+          uiDispatch({type: 'set-step', value: 1})
+        }
 
-    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,
-      })
-    }
+        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,
+          })
+        }
 
-    uiDispatch({type: 'set-processing', value: false})
-    uiDispatch({type: 'set-error', value: cleanError(errMsg)})
-    throw e
-  }
+        const error = cleanError(errMsg)
+        const isHandleError = error.toLowerCase().includes('handle')
+
+        uiDispatch({type: 'set-processing', value: false})
+        uiDispatch({type: 'set-error', value: cleanError(errMsg)})
+        uiDispatch({type: 'set-step', value: isHandleError ? 2 : 1})
+      }
+    },
+    [
+      uiState.email,
+      uiState.password,
+      uiState.handle,
+      uiState.isCaptchaRequired,
+      uiState.serviceUrl,
+      uiState.userDomain,
+      uiState.inviteCode,
+      uiState.birthDate,
+      uiDispatch,
+      _,
+      onboardingDispatch,
+      createAccount,
+      setBirthDate,
+      setSavedFeeds,
+    ],
+  )
 }
 
 export function is13(state: CreateAccountState) {
@@ -269,22 +247,6 @@ function createReducer({_}: {_: I18nContext['_']}) {
       case 'set-password': {
         return compute({...state, password: action.value})
       }
-      case 'set-phone-country': {
-        return compute({...state, phoneCountry: action.value})
-      }
-      case 'set-verification-phone': {
-        return compute({
-          ...state,
-          verificationPhone: action.value,
-          hasRequestedVerificationCode: false,
-        })
-      }
-      case 'set-verification-code': {
-        return compute({...state, verificationCode: action.value.trim()})
-      }
-      case 'set-has-requested-verification-code': {
-        return compute({...state, hasRequestedVerificationCode: action.value})
-      }
       case 'set-handle': {
         return compute({...state, handle: action.value})
       }
@@ -302,18 +264,10 @@ function createReducer({_}: {_: I18nContext['_']}) {
             })
           }
         }
-        let increment = 1
-        if (state.step === 1 && !state.isPhoneVerificationRequired) {
-          increment = 2
-        }
-        return compute({...state, error: '', step: state.step + increment})
+        return compute({...state, error: '', step: state.step + 1})
       }
       case 'back': {
-        let decrement = 1
-        if (state.step === 3 && !state.isPhoneVerificationRequired) {
-          decrement = 2
-        }
-        return compute({...state, error: '', step: state.step - decrement})
+        return compute({...state, error: '', step: state.step - 1})
       }
     }
   }
@@ -328,23 +282,16 @@ function compute(state: CreateAccountState): CreateAccountState {
       !!state.email &&
       !!state.password
   } else if (state.step === 2) {
-    canNext =
-      !state.isPhoneVerificationRequired ||
-      (!!state.verificationPhone &&
-        isValidVerificationCode(state.verificationCode))
-  } else if (state.step === 3) {
     canNext = !!state.handle
+  } else if (state.step === 3) {
+    // Step 3 will automatically redirect as soon as the captcha completes
+    canNext = false
   }
   return {
     ...state,
     canBack: state.step > 1,
     canNext,
     isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
-    isPhoneVerificationRequired:
-      !!state.serviceDescription?.phoneVerificationRequired,
+    isCaptchaRequired: !!state.serviceDescription?.phoneVerificationRequired,
   }
 }
-
-function isValidVerificationCode(str: string): boolean {
-  return /[0-9]{6}/.test(str)
-}