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 [value, setValue] = useState(initialValue)
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
return useMemo( 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 {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import * as Dialog from '#/components/Dialog' 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 * Steps
*/ */
import {Update} from '#/components/dialogs/EmailDialog/screens/Update' 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 {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({ export function useEmailDialogControl() {
control, return useStatefulDialogControl<Screen>()
initialScreen, }
}: {
control: Dialog.DialogControlProps export function EmailDialog({control}: {control: StatefulControl<Screen>}) {
initialScreen: Screen
}) {
const {_} = useLingui() 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 ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control.control} onClose={onClose}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Make adjustments to your account email settings`)} style={[{maxWidth: 400}]}> <Dialog.ScrollableInner
<Inner control={control} initialScreen={initialScreen} /> label={_(msg`Make adjustments to your account email settings`)}
style={[{maxWidth: 400}]}>
<Inner control={control} />
<Dialog.Close /> <Dialog.Close />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
</Dialog.Outer> </Dialog.Outer>
) )
} }
function Inner({ function Inner({control}: {control: StatefulControl<Screen>}) {
initialScreen, if (!control.value) return null
}: {
control: Dialog.DialogControlProps switch (control.value.id) {
initialScreen: Screen
}) {
switch (initialScreen.id) {
case ScreenID.Update: { case ScreenID.Update: {
return ( return <Update config={control.value} />
<Update /> }
) case ScreenID.Verify: {
return <Verify config={control.value} />
} }
default: { default: {
return null return null
@@ -3,26 +3,28 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {wait} from '#/lib/async/wait'
import {logger} from '#/logger'
import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail' 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 {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate'
import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' 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 t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -34,28 +36,10 @@ export function Update() {
const [tokenRequired, setTokenRequired] = useState(false) const [tokenRequired, setTokenRequired] = useState(false)
const [updateStatus, setUpdateStatus] = useState<'sending' | null>(null) const [updateStatus, setUpdateStatus] = useState<'sending' | null>(null)
const {mutateAsync: updateEmail, isPending: isUpdateEmailPending} = const {mutateAsync: updateEmail} = useUpdateEmail()
useUpdateEmail()
const [resendStatus, setResendStatus] = useState<
'sending' | 'success' | null
>(null)
const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate()
const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() 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) => { const handleEmailChange = (email: string) => {
setEmail(email) setEmail(email)
@@ -144,31 +128,10 @@ export function Update() {
onSubmitEditing={handleUpdateEmail} onSubmitEditing={handleUpdateEmail}
/> />
{!success && ( {!success && (
<Text <ResendEmailText
style={[ onPress={requestEmailUpdate}
a.italic, style={[a.pt_sm]}
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>
)} )}
</View> </View>
</> </>
@@ -207,7 +170,7 @@ export function Update() {
disabled={ disabled={
!email || !email ||
(tokenRequired && !token) || (tokenRequired && !token) ||
isUpdateEmailPending || updateStatus === 'sending' ||
success success
}> }>
<ButtonText> <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 EmailDialogInnerProps = EmailDialogProps & {}
export type Screen = { export type Screen =
id: ScreenID.Update | {
} | { id: ScreenID.Update
id: ScreenID.EnterCode }
/** | {
* - email was sent earlier id: ScreenID.EnterCode
* - email was _just_ sent /**
*/ * - email was sent earlier
instructions: ReactNode[] * - email was _just_ sent
} | { */
id: ScreenID.VerifyEmail instructions: ReactNode[]
/** }
* - default flow | {
* - new user blockers (x5) id: ScreenID.Verify
*/ /**
instructions: ReactNode[] * - default flow
// onCloseWithoutVerifying, * - new user blockers (x5)
// onCloseAfterVerifying, */
} instructions?: ReactNode[]
// onCloseWithoutVerifying,
// onCloseAfterVerifying,
}
export enum ScreenID { export enum ScreenID {
Update = 'Update', // normal, instructs to click link first Update = 'Update', // normal, instructs to click link first
EnterCode = 'EnterCode', // if user elects to enter a code 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 {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {ChangeEmailDialog} from '#/components/dialogs/ChangeEmailDialog' import {
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' EmailDialog,
EmailDialogScreenID,
useEmailDialogControl,
} from '#/components/dialogs/EmailDialog'
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At' import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
import {BirthdayCake_Stroke2_Corner2_Rounded as BirthdayCakeIcon} from '#/components/icons/BirthdayCake' import {BirthdayCake_Stroke2_Corner2_Rounded as BirthdayCakeIcon} from '#/components/icons/BirthdayCake'
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car' 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 {ChangeHandleDialog} from './components/ChangeHandleDialog'
import {DeactivateAccountDialog} from './components/DeactivateAccountDialog' import {DeactivateAccountDialog} from './components/DeactivateAccountDialog'
import {ExportCarDialog} from './components/ExportCarDialog' import {ExportCarDialog} from './components/ExportCarDialog'
import {EmailDialog, ScreenID} from '#/components/dialogs/EmailDialog'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'>
export function AccountSettingsScreen({}: Props) { export function AccountSettingsScreen({}: Props) {
@@ -32,8 +34,7 @@ export function AccountSettingsScreen({}: Props) {
const {_} = useLingui() const {_} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const verifyEmailControl = useDialogControl() const emailDialogControl = useEmailDialogControl()
const changeEmailControl = useDialogControl()
const birthdayControl = useDialogControl() const birthdayControl = useDialogControl()
const changeHandleControl = useDialogControl() const changeHandleControl = useDialogControl()
const exportCarControl = useDialogControl() const exportCarControl = useDialogControl()
@@ -76,7 +77,11 @@ export function AccountSettingsScreen({}: Props) {
{currentAccount && !currentAccount.emailConfirmed && ( {currentAccount && !currentAccount.emailConfirmed && (
<SettingsList.PressableItem <SettingsList.PressableItem
label={_(msg`Verify your email`)} label={_(msg`Verify your email`)}
onPress={() => verifyEmailControl.open()} onPress={() =>
emailDialogControl.open({
id: EmailDialogScreenID.Verify,
})
}
style={[ style={[
a.my_xs, a.my_xs,
a.mx_lg, a.mx_lg,
@@ -98,7 +103,11 @@ export function AccountSettingsScreen({}: Props) {
)} )}
<SettingsList.PressableItem <SettingsList.PressableItem
label={_(msg`Change email`)} label={_(msg`Change email`)}
onPress={() => changeEmailControl.open()}> onPress={() =>
emailDialogControl.open({
id: EmailDialogScreenID.Update,
})
}>
<SettingsList.ItemIcon icon={PencilIcon} /> <SettingsList.ItemIcon icon={PencilIcon} />
<SettingsList.ItemText> <SettingsList.ItemText>
<Trans>Change email</Trans> <Trans>Change email</Trans>
@@ -168,20 +177,7 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.Container> </SettingsList.Container>
</Layout.Content> </Layout.Content>
<EmailDialog <EmailDialog control={emailDialogControl} />
control={changeEmailControl}
initialScreen={{id: ScreenID.Update}}
/>
{/*
<ChangeEmailDialog
control={changeEmailControl}
verifyEmailControl={verifyEmailControl}
/>
*/}
<VerifyEmailDialog
control={verifyEmailControl}
changeEmailControl={changeEmailControl}
/>
<BirthDateSettingsDialog control={birthdayControl} /> <BirthDateSettingsDialog control={birthdayControl} />
<ChangeHandleDialog control={changeHandleControl} /> <ChangeHandleDialog control={changeHandleControl} />
<ExportCarDialog control={exportCarControl} /> <ExportCarDialog control={exportCarControl} />