Improve account login UX
All checks were successful
Docs / build (push) Successful in 2m17s

This commit is contained in:
Полина 2025-01-19 23:05:17 +03:00
parent 4463626232
commit 686e62d0e0
2 changed files with 105 additions and 74 deletions

View file

@ -1,9 +1,9 @@
import type { mtcute, TelegramAccount } from 'mtcute-repl-worker/client' import type { mtcute, TelegramAccount } from 'mtcute-repl-worker/client'
import type { Setter } from 'solid-js' import type { Setter } from 'solid-js'
import { unknownToError } from '@fuman/utils' import { unknownToError } from '@fuman/utils'
import { LucideChevronRight, MessageSquareMore } from 'lucide-solid' import { LucideChevronRight, LucideLockKeyhole, MessageSquareMore } from 'lucide-solid'
import { workerInvoke, workerOn } from 'mtcute-repl-worker/client' import { workerInvoke, workerOn } from 'mtcute-repl-worker/client'
import { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from 'solid-js' import { createSignal, For, Match, onCleanup, onMount, Show, Switch } from 'solid-js'
import { Button } from '../../../lib/components/ui/button.tsx' import { Button } from '../../../lib/components/ui/button.tsx'
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot } from '../../../lib/components/ui/otp-field.tsx' import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot } from '../../../lib/components/ui/otp-field.tsx'
import { Spinner } from '../../../lib/components/ui/spinner.tsx' import { Spinner } from '../../../lib/components/ui/spinner.tsx'
@ -81,7 +81,7 @@ function QrLoginStep(props: StepProps<'qr'>) {
/> />
) : <Spinner indeterminate class="size-10" />} ) : <Spinner indeterminate class="size-10" />}
</div> </div>
<ol class="mt-4 list-inside list-decimal text-sm text-muted-foreground"> <ol class="text-muted-foreground mt-4 list-inside list-decimal text-sm">
<li>Open Telegram on your phone</li> <li>Open Telegram on your phone</li>
<li> <li>
Go to Go to
@ -147,7 +147,7 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
<h2 class="mt-4 text-xl font-bold"> <h2 class="mt-4 text-xl font-bold">
Log in with phone number Log in with phone number
</h2> </h2>
<div class="mt-2 text-center text-sm text-muted-foreground"> <div class="text-muted-foreground mt-2 text-center text-sm">
Please confirm your country code Please confirm your country code
<br /> <br />
and enter your phone number and enter your phone number
@ -177,7 +177,7 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage> <TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
</TextFieldRoot> </TextFieldRoot>
<div class="flex-1" /> <div class="flex-1" />
<div class="text-center text-sm text-muted-foreground"> <div class="text-muted-foreground text-center text-sm">
or, or,
{' '} {' '}
<a <a
@ -197,7 +197,6 @@ function OtpStep(props: StepProps<'otp'>) {
const [error, setError] = createSignal<string | undefined>() const [error, setError] = createSignal<string | undefined>()
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [countdown, setCountdown] = createSignal(0) const [countdown, setCountdown] = createSignal(0)
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
const abortController = new AbortController() const abortController = new AbortController()
const handleSubmit = async () => { const handleSubmit = async () => {
@ -269,9 +268,9 @@ function OtpStep(props: StepProps<'otp'>) {
setCountdown(0) setCountdown(0)
} }
}, 1000) }, 1000)
onCleanup(() => clearInterval(interval)) onCleanup(() => clearInterval(interval))
}) })
createEffect(() => inputRef()?.focus())
const description = () => { const description = () => {
switch (props.ctx.code.type) { switch (props.ctx.code.type) {
@ -297,19 +296,6 @@ function OtpStep(props: StepProps<'otp'>) {
} }
} }
const [innerInputRef, setInnerInputRef] = createSignal<HTMLInputElement | undefined>()
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key >= '0' && e.key <= '9') {
innerInputRef()?.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
onCleanup(() => window.removeEventListener('keydown', handleKeyDown))
})
return ( return (
<div class="flex flex-col items-center justify-center"> <div class="flex flex-col items-center justify-center">
<MessageSquareMore class="size-16 pb-2" strokeWidth={1.5} /> <MessageSquareMore class="size-16 pb-2" strokeWidth={1.5} />
@ -323,7 +309,7 @@ function OtpStep(props: StepProps<'otp'>) {
Wrong number? Wrong number?
</div> </div>
<div class="mt-4 text-center text-sm text-muted-foreground"> <div class="text-muted-foreground mt-4 text-center text-sm">
{description()} {description()}
</div> </div>
<div class="mt-4 flex flex-col items-center text-center"> <div class="mt-4 flex flex-col items-center text-center">
@ -343,7 +329,7 @@ function OtpStep(props: StepProps<'otp'>) {
handleSubmit() handleSubmit()
} }
}} }}
ref={(e) => { setInputRef(e); setInnerInputRef(e) }} ref={props.ctx.setInputRef}
/> />
</TextFieldFrame> </TextFieldFrame>
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage> <TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
@ -358,7 +344,7 @@ function OtpStep(props: StepProps<'otp'>) {
> >
<OTPFieldInput <OTPFieldInput
disabled={loading()} disabled={loading()}
ref={(e) => { setInputRef(e); setInnerInputRef(e) }} ref={props.ctx.setInputRef}
onKeyPress={(e) => { onKeyPress={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSubmit() handleSubmit()
@ -377,7 +363,7 @@ function OtpStep(props: StepProps<'otp'>) {
</OTPFieldGroup> </OTPFieldGroup>
</OTPField> </OTPField>
{error() && ( {error() && (
<div class="mt-1 text-sm text-error-foreground">{error()}</div> <div class="text-error-foreground mt-1 text-sm">{error()}</div>
)} )}
</Show> </Show>
@ -400,10 +386,24 @@ function OtpStep(props: StepProps<'otp'>) {
} }
function PasswordStep(props: StepProps<'password'>) { function PasswordStep(props: StepProps<'password'>) {
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
const [password, setPassword] = createSignal('') const [password, setPassword] = createSignal('')
const [error, setError] = createSignal<string | undefined>() const [error, setError] = createSignal<string | undefined>()
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
onMount(() => {
const pasteHandler = () => {
if (document.activeElement !== inputRef()) {
inputRef()?.focus()
}
}
window.addEventListener('paste', pasteHandler)
onCleanup(() => {
window.removeEventListener('paste', pasteHandler)
})
})
const abortController = new AbortController() const abortController = new AbortController()
onCleanup(() => abortController.abort()) onCleanup(() => abortController.abort())
@ -411,6 +411,7 @@ function PasswordStep(props: StepProps<'password'>) {
const handleSubmit = async () => { const handleSubmit = async () => {
if (!password()) { if (!password()) {
setError('Password is required') setError('Password is required')
inputRef()?.focus()
return return
} }
@ -427,20 +428,23 @@ function PasswordStep(props: StepProps<'password'>) {
setLoading(false) setLoading(false)
setError(unknownToError(e).message) setError(unknownToError(e).message)
} }
inputRef()?.focus()
} }
createEffect(() => inputRef()?.focus())
return ( return (
<div class="flex flex-col items-center justify-center"> <div class="flex flex-col items-center justify-center">
<LucideLockKeyhole class="size-16 pb-2" strokeWidth={1.5} />
<h2 class="text-xl font-bold"> <h2 class="text-xl font-bold">
2FA password 2FA password
</h2> </h2>
<div class="mt-4 text-center text-sm text-muted-foreground"> <div class="text-muted-foreground mt-4 text-center text-sm">
Your account is protected with an additional password. Your account is protected with an additional password.
</div> </div>
<div class="mt-4 flex flex-col"> <div class="mt-4">
<TextFieldRoot validationState={error() ? 'invalid' : 'valid'}> <TextFieldRoot validationState={error() ? 'invalid' : 'valid'}>
<TextFieldLabel>Password</TextFieldLabel> <TextFieldLabel>Password</TextFieldLabel>
<div class="flex flex-row">
<TextFieldFrame> <TextFieldFrame>
<TextField <TextField
type="password" type="password"
@ -449,7 +453,7 @@ function PasswordStep(props: StepProps<'password'>) {
value={password()} value={password()}
onInput={e => setPassword(e.currentTarget.value)} onInput={e => setPassword(e.currentTarget.value)}
disabled={loading()} disabled={loading()}
ref={setInputRef} ref={(e) => { props.ctx.setInputRef(e); setInputRef(e) }}
onKeyPress={(e) => { onKeyPress={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSubmit() handleSubmit()
@ -457,19 +461,18 @@ function PasswordStep(props: StepProps<'password'>) {
}} }}
/> />
</TextFieldFrame> </TextFieldFrame>
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
</TextFieldRoot>
<div class="mt-2 flex w-full justify-end">
<Button <Button
variant="default" variant="default"
size="sm" size="icon"
class="ml-2 size-9 min-w-[36px]"
disabled={loading() || password() === ''}
onClick={handleSubmit} onClick={handleSubmit}
disabled={loading()}
> >
Next <LucideChevronRight class="size-5" />
</Button> </Button>
</div> </div>
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
</TextFieldRoot>
</div> </div>
</div> </div>
) )
@ -480,9 +483,9 @@ function DoneStep(props: StepProps<'done'>) {
<div class="flex flex-col items-center justify-center"> <div class="flex flex-col items-center justify-center">
<AccountAvatar <AccountAvatar
account={props.ctx.account} account={props.ctx.account}
class="mb-4 size-24 animate-scale-up shadow-sm fill-mode-forwards" class="animate-scale-up fill-mode-forwards mb-4 size-24 shadow-sm"
/> />
<div class="animate-fade-out-down text-center font-medium fill-mode-forwards"> <div class="animate-fade-out-down fill-mode-forwards text-center font-medium">
Welcome, Welcome,
{' '} {' '}
{props.ctx.account.name} {props.ctx.account.name}
@ -508,6 +511,26 @@ export function LoginForm(props: {
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>() const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
onMount(() => {
const pasteHandler = () => {
if (document.activeElement !== inputRef()) {
inputRef()?.focus()
}
}
const handleKeyDown = () => {
inputRef()?.focus()
}
window.addEventListener('paste', pasteHandler)
window.addEventListener('keydown', handleKeyDown)
onCleanup(() => {
window.removeEventListener('paste', pasteHandler)
window.removeEventListener('keydown', handleKeyDown)
})
})
return ( return (
<div class={cn('flex h-full flex-col items-center justify-center gap-4', props.class)}> <div class={cn('flex h-full flex-col items-center justify-center gap-4', props.class)}>
<TransitionSlideLtr onAfterExit={() => inputRef()?.focus()} mode="outin"> <TransitionSlideLtr onAfterExit={() => inputRef()?.focus()} mode="outin">

View file

@ -29,26 +29,11 @@ interface PhoneInputProps {
} }
export function PhoneInput(props: PhoneInputProps) { export function PhoneInput(props: PhoneInputProps) {
let inputRef: HTMLInputElement | undefined
const [countriesList, setCountriesList] = createSignal<mtcute.RawCountry[]>([]) const [countriesList, setCountriesList] = createSignal<mtcute.RawCountry[]>([])
const [chosenCode, setChosenCode] = createSignal<ChosenCode | undefined>() const [chosenCode, setChosenCode] = createSignal<ChosenCode | undefined>()
const [inputValue, setInputValue] = createSignal('+') const [inputValue, setInputValue] = createSignal('+')
onMount(async () => {
const { countries, countryByIp } = await workerInvoke('telegram', 'loadCountries', { accountId: props.accountId })
setCountriesList(countries)
if (inputValue() === '+') {
// guess the country code
for (const country of countries) {
if (country.iso2 === countryByIp) {
setChosenCode(mapCountryCode(country, country.countryCodes[0]))
setInputValue(`+${country.countryCodes[0].countryCode} `)
break
}
}
}
})
const handleInput = (e: InputEvent) => { const handleInput = (e: InputEvent) => {
const el = e.currentTarget as HTMLInputElement const el = e.currentTarget as HTMLInputElement
const value = el.value const value = el.value
@ -155,6 +140,26 @@ export function PhoneInput(props: PhoneInputProps) {
} }
} }
onMount(async () => {
const { countries, countryByIp } = await workerInvoke('telegram', 'loadCountries', { accountId: props.accountId })
setCountriesList(countries)
if (chosenCode() === undefined) {
handleInput({ currentTarget: inputRef } as any)
}
if (inputValue() === '+') {
// guess the country code
for (const country of countries) {
if (country.iso2 === countryByIp) {
setChosenCode(mapCountryCode(country, country.countryCodes[0]))
setInputValue(`+${country.countryCodes[0].countryCode} `)
break
}
}
}
})
const handleKeyPress = (e: KeyboardEvent) => { const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'Enter' && chosenCode() !== undefined) { if (e.key === 'Enter' && chosenCode() !== undefined) {
props.onSubmit?.() props.onSubmit?.()
@ -180,7 +185,10 @@ export function PhoneInput(props: PhoneInputProps) {
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
autocomplete="off" autocomplete="off"
disabled={props.disabled} disabled={props.disabled}
ref={props.ref} ref={(e: HTMLInputElement) => {
inputRef = e
props.ref?.(e)
}}
/> />
</TextFieldFrame> </TextFieldFrame>
) )