Verify step, integrate stateful control

This commit is contained in:
Eric Bailey
2025-04-25 15:12:35 -05:00
parent 795bdbeb59
commit ecab279de3
8 changed files with 301 additions and 126 deletions
+3 -1
View File
@@ -48,7 +48,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
}
function useStatefulDialogControl<T>(initialValue?: T): StatefulControl<T> {
export function useStatefulDialogControl<T>(
initialValue?: T,
): StatefulControl<T> {
const [value, setValue] = useState(initialValue)
const control = Dialog.useDialogControl()
return useMemo(
@@ -0,0 +1,56 @@
import {useState} from 'react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {wait} from '#/lib/async/wait'
import {atoms as a, type TextStyleProp,useTheme} from '#/alf'
import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {createStaticClick,InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Span,Text} from '#/components/Typography'
export function ResendEmailText({
onPress,
style,
}: TextStyleProp & {
onPress: () => Promise<any>
}) {
const t = useTheme()
const {_} = useLingui()
const [status, setStatus] = useState<'sending' | 'success' | null>(null)
const handleOnPress = async () => {
setStatus('sending')
try {
await wait(1000, onPress())
setStatus('success')
} finally {
setTimeout(() => {
setStatus(null)
}, 1000)
}
}
return (
<Text
style={[a.italic, a.leading_snug, t.atoms.text_contrast_medium, style]}>
<Trans>
Don't see an email?{' '}
<InlineLinkText
label={_(msg`Resend`)}
{...createStaticClick(() => {
handleOnPress()
})}>
Click here to resend.
</InlineLinkText>
</Trans>{' '}
<Span style={{top: 1}}>
{status === 'sending' ? (
<Loader size="xs" />
) : status === 'success' ? (
<Check size="xs" fill={t.palette.positive_500} />
) : null}
</Span>
</Text>
)
}
@@ -0,0 +1,11 @@
import {useCallback} from 'react'
import {useAgent} from '#/state/session'
export function useRefreshSession() {
const agent = useAgent()
return useCallback(() => {
return agent.resumeSession(agent.session!).catch(() => {})
}, [agent])
}
+39 -23
View File
@@ -1,49 +1,65 @@
import {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as Dialog from '#/components/Dialog'
import {ScreenID, Screen} from '#/components/dialogs/EmailDialog/types'
import {
type StatefulControl,
useStatefulDialogControl,
} from '#/components/dialogs/Context'
import {useRefreshSession} from '#/components/dialogs/EmailDialog/data/useRefreshSession'
/*
* Steps
*/
import {Update} from '#/components/dialogs/EmailDialog/screens/Update'
import {Verify} from '#/components/dialogs/EmailDialog/screens/Verify'
import {type Screen,ScreenID} from '#/components/dialogs/EmailDialog/types'
export {useDialogControl} from '#/components/Dialog'
export {ScreenID, type Screen} from '#/components/dialogs/EmailDialog/types'
export {
ScreenID as EmailDialogScreenID,
type Screen,
} from '#/components/dialogs/EmailDialog/types'
export function EmailDialog({
control,
initialScreen,
}: {
control: Dialog.DialogControlProps
initialScreen: Screen
}) {
export function useEmailDialogControl() {
return useStatefulDialogControl<Screen>()
}
export function EmailDialog({control}: {control: StatefulControl<Screen>}) {
const {_} = useLingui()
const refreshSession = useRefreshSession()
const onClose = useCallback(() => {
/**
* If link in any verification email is clicked, it will open a new tab.
* When the user returns to this tab, we'll refresh their account state
* when the dialog closes.
*/
refreshSession()
}, [refreshSession])
return (
<Dialog.Outer control={control}>
<Dialog.Outer control={control.control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Make adjustments to your account email settings`)} style={[{maxWidth: 400}]}>
<Inner control={control} initialScreen={initialScreen} />
<Dialog.ScrollableInner
label={_(msg`Make adjustments to your account email settings`)}
style={[{maxWidth: 400}]}>
<Inner control={control} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Inner({
initialScreen,
}: {
control: Dialog.DialogControlProps
initialScreen: Screen
}) {
switch (initialScreen.id) {
function Inner({control}: {control: StatefulControl<Screen>}) {
if (!control.value) return null
switch (control.value.id) {
case ScreenID.Update: {
return (
<Update />
)
return <Update config={control.value} />
}
case ScreenID.Verify: {
return <Verify config={control.value} />
}
default: {
return null
@@ -3,26 +3,28 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Text, Span} from '#/components/Typography'
import {atoms as a, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
import {Loader} from '#/components/Loader'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {logger} from '#/logger'
import {Admonition} from '#/components/Admonition'
import {useSession} from '#/state/session'
import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {InlineLinkText, createStaticClick} from '#/components/Link'
import {wait} from '#/lib/async/wait'
import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText'
import {TokenField} from '#/components/dialogs/EmailDialog/components/TokenField'
import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate'
import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification'
import {TokenField} from '#/components/dialogs/EmailDialog/components/TokenField'
import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail'
import {type Screen} from '#/components/dialogs/EmailDialog/types'
import {Divider} from '#/components/Divider'
import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function Update() {
export function Update(_props: {
config: Exclude<Screen, {id: 'Verify' | 'EnterCode'}>
}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
@@ -34,28 +36,10 @@ export function Update() {
const [tokenRequired, setTokenRequired] = useState(false)
const [updateStatus, setUpdateStatus] = useState<'sending' | null>(null)
const {mutateAsync: updateEmail, isPending: isUpdateEmailPending} =
useUpdateEmail()
const [resendStatus, setResendStatus] = useState<
'sending' | 'success' | null
>(null)
const {mutateAsync: updateEmail} = useUpdateEmail()
const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate()
const {mutateAsync: requestEmailVerification} = useRequestEmailVerification()
const handleResendRequestEmailUpdate = async () => {
setResendStatus('sending')
try {
await wait(1000, requestEmailUpdate())
setResendStatus('success')
} finally {
setTimeout(() => {
setResendStatus(null)
}, 1000)
}
}
const handleEmailChange = (email: string) => {
setEmail(email)
@@ -144,31 +128,10 @@ export function Update() {
onSubmitEditing={handleUpdateEmail}
/>
{!success && (
<Text
style={[
a.italic,
a.pt_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Don't see an email?{' '}
<InlineLinkText
label={_(msg`Resend`)}
{...createStaticClick(() => {
handleResendRequestEmailUpdate()
})}>
Click here to resend.
</InlineLinkText>
</Trans>{' '}
<Span style={{top: 1}}>
{resendStatus === 'sending' ? (
<Loader size="xs" />
) : resendStatus === 'success' ? (
<Check size="xs" fill={t.palette.positive_500} />
) : null}
</Span>
</Text>
<ResendEmailText
onPress={requestEmailUpdate}
style={[a.pt_sm]}
/>
)}
</View>
</>
@@ -207,7 +170,7 @@ export function Update() {
disabled={
!email ||
(tokenRequired && !token) ||
isUpdateEmailPending ||
updateStatus === 'sending' ||
success
}>
<ButtonText>
@@ -0,0 +1,128 @@
import {useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {wait} from '#/lib/async/wait'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText'
import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification'
import {type Screen} from '#/components/dialogs/EmailDialog/types'
import {Divider} from '#/components/Divider'
import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
import {createStaticClick,InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Span,Text} from '#/components/Typography'
export function Verify(_props: {
config: Exclude<Screen, {id: 'Update' | 'EnterCode'}>
}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const [error, setError] = useState('')
const [sentEmail, setSentEmail] = useState(false)
const [sendingStatus, setSendingStatus] = useState<'sending' | null>(null)
const {mutateAsync: requestEmailVerification} = useRequestEmailVerification()
const handleRequestEmailVerification = async () => {
setError('')
setSendingStatus('sending')
try {
await wait(1000, requestEmailVerification())
setSentEmail(true)
} catch (e) {
logger.error('EmailDialog: sending verification email failed', {
safeMessage: e,
})
setError(_(msg`Failed to send email, please try again.`))
} finally {
setSendingStatus(null)
}
}
return (
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.text_xl, a.font_heavy]}>
{sentEmail ? (
<>
<Span style={{top: 1}}>
<Check size="sm" fill={t.palette.positive_500} />
</Span>{' '}
<Trans>Email sent!</Trans>
</>
) : (
<Trans>Verify email</Trans>
)}
</Text>
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
{sentEmail ? (
<Trans>
We sent an email to{' '}
<Span style={[a.font_bold, t.atoms.text]}>
{currentAccount!.email}
</Span>{' '}
containing a link. Click on it to complete the email verification
process.
</Trans>
) : (
<Trans>
We'll send you an email to{' '}
<Span style={[a.font_bold, t.atoms.text]}>
{currentAccount!.email}
</Span>{' '}
containing a link. Click on it to complete the email verification
process.
</Trans>
)}
</Text>
{sentEmail && <ResendEmailText onPress={requestEmailVerification} />}
</View>
{!sentEmail && (
<>
{error && <Admonition type="error"> {error} </Admonition>}
<Button
label={_(msg`Send verification email`)}
size="large"
variant="solid"
color={'primary'}
onPress={handleRequestEmailVerification}
disabled={sendingStatus === 'sending'}>
<ButtonText>
<Trans>Send email</Trans>
</ButtonText>
<ButtonIcon
icon={sendingStatus === 'sending' ? Loader : Envelope}
/>
</Button>
<Divider />
<Text
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Have a code?{' '}
<InlineLinkText
label={_(msg`Enter code`)}
{...createStaticClick(() => {})}>
Click here.
</InlineLinkText>
</Trans>
</Text>
</>
)}
</View>
)
}
+23 -20
View File
@@ -8,28 +8,31 @@ export type EmailDialogProps = {
export type EmailDialogInnerProps = EmailDialogProps & {}
export type Screen = {
id: ScreenID.Update
} | {
id: ScreenID.EnterCode
/**
* - email was sent earlier
* - email was _just_ sent
*/
instructions: ReactNode[]
} | {
id: ScreenID.VerifyEmail
/**
* - default flow
* - new user blockers (x5)
*/
instructions: ReactNode[]
// onCloseWithoutVerifying,
// onCloseAfterVerifying,
}
export type Screen =
| {
id: ScreenID.Update
}
| {
id: ScreenID.EnterCode
/**
* - email was sent earlier
* - email was _just_ sent
*/
instructions: ReactNode[]
}
| {
id: ScreenID.Verify
/**
* - default flow
* - new user blockers (x5)
*/
instructions?: ReactNode[]
// onCloseWithoutVerifying,
// onCloseAfterVerifying,
}
export enum ScreenID {
Update = 'Update', // normal, instructs to click link first
EnterCode = 'EnterCode', // if user elects to enter a code
VerifyEmail = 'VerifyEmail', // as a separate step, instructs to click link first
Verify = 'Verify', // as a separate step, instructs to click link first
}
+17 -21
View File
@@ -9,8 +9,11 @@ import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {ChangeEmailDialog} from '#/components/dialogs/ChangeEmailDialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {
EmailDialog,
EmailDialogScreenID,
useEmailDialogControl,
} from '#/components/dialogs/EmailDialog'
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
import {BirthdayCake_Stroke2_Corner2_Rounded as BirthdayCakeIcon} from '#/components/icons/BirthdayCake'
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
@@ -24,7 +27,6 @@ import * as Layout from '#/components/Layout'
import {ChangeHandleDialog} from './components/ChangeHandleDialog'
import {DeactivateAccountDialog} from './components/DeactivateAccountDialog'
import {ExportCarDialog} from './components/ExportCarDialog'
import {EmailDialog, ScreenID} from '#/components/dialogs/EmailDialog'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'>
export function AccountSettingsScreen({}: Props) {
@@ -32,8 +34,7 @@ export function AccountSettingsScreen({}: Props) {
const {_} = useLingui()
const {currentAccount} = useSession()
const {openModal} = useModalControls()
const verifyEmailControl = useDialogControl()
const changeEmailControl = useDialogControl()
const emailDialogControl = useEmailDialogControl()
const birthdayControl = useDialogControl()
const changeHandleControl = useDialogControl()
const exportCarControl = useDialogControl()
@@ -76,7 +77,11 @@ export function AccountSettingsScreen({}: Props) {
{currentAccount && !currentAccount.emailConfirmed && (
<SettingsList.PressableItem
label={_(msg`Verify your email`)}
onPress={() => verifyEmailControl.open()}
onPress={() =>
emailDialogControl.open({
id: EmailDialogScreenID.Verify,
})
}
style={[
a.my_xs,
a.mx_lg,
@@ -98,7 +103,11 @@ export function AccountSettingsScreen({}: Props) {
)}
<SettingsList.PressableItem
label={_(msg`Change email`)}
onPress={() => changeEmailControl.open()}>
onPress={() =>
emailDialogControl.open({
id: EmailDialogScreenID.Update,
})
}>
<SettingsList.ItemIcon icon={PencilIcon} />
<SettingsList.ItemText>
<Trans>Change email</Trans>
@@ -168,20 +177,7 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.Container>
</Layout.Content>
<EmailDialog
control={changeEmailControl}
initialScreen={{id: ScreenID.Update}}
/>
{/*
<ChangeEmailDialog
control={changeEmailControl}
verifyEmailControl={verifyEmailControl}
/>
*/}
<VerifyEmailDialog
control={verifyEmailControl}
changeEmailControl={changeEmailControl}
/>
<EmailDialog control={emailDialogControl} />
<BirthDateSettingsDialog control={birthdayControl} />
<ChangeHandleDialog control={changeHandleControl} />
<ExportCarDialog control={exportCarControl} />