Files
zeekay e5967776e2 sign-in: the phone step and its numbering plan load when it is chosen
The panel imported the phone step at its top, and with it
libphonenumber's metadata (lib/auth/phone `countries`, `shown`): some
36 KB of compressed script in the first load of every page that draws
the panel, the landing's hero, /login and /signup among them, for a
step a reader reaches only by pressing Continue with phone.

What needs the metadata is lib/auth/dial now; lib/auth/phone keeps the
counts and the consent. The panel loads Dial and Texted with
next/dynamic, and formats the texted number once the plan arrives (the
raw number stands until then). First load, gzip: / 542 -> 506 KB,
/login and /signup 530 -> 490 KB.
2026-10-29 23:20:01 -07:00

184 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* The two steps of a sign-in by phone, drawn in the panel (components/auth/panel),
* which runs every call: the number, with its country and the SMS consent, then
* the code IAM texted to it. A number is sent to IAM in E.164 (@hanzo/iam/validation
* `e164`), read in the country picked. IAM answers no question about whose number
* it is, so the page says what the code does and leaves the rest to IAM's sentence.
*/
import { useEffect, useId, useMemo, useState, type FormEvent } from 'react'
import { e164 } from '@hanzo/iam/validation'
import { Checkbox, Input, InputGroup, PhoneCountryPicker, Text, XStack, YStack } from '@hanzo/ui'
import { Action } from '@hanzo/ui/marketing'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Code, Held, Note, Other, Wrong } from '@/components/auth/steps'
import { LARGE } from '@/components/shell/focus'
import { CONSENT, wait } from '@/lib/auth/phone'
import { countries, shown } from '@/lib/auth/dial'
/** Said under the number when it is not one that can be dialled in the country picked. */
const WHOLE = phrase('Enter a full phone number, with its area code.')
/** Said when the consent is not ticked. */
const AGREE = phrase('Agree to receive the text to continue.')
/**
* The number and the SMS consent, in a form of their own: `onSend` is handed the
* number in E.164. Drawn by the sign-in (`Dial`) and by Settings, which adds a
* number to an account; `send` names the button, and `focus` puts the cursor in
* the number when the form is the page's one task.
*/
export function Entry({
busy,
wrong,
send,
focus = false,
onSend,
}: {
busy: boolean
wrong: string | null
send: string
focus?: boolean
onSend: (number: string) => void
}) {
const { t, locale } = useI18n()
const list = useMemo(() => countries(locale), [locale])
const [region, setRegion] = useState('US')
const [typed, setTyped] = useState('')
const [agree, setAgree] = useState(false)
const [bad, setBad] = useState<string | null>(null)
const ids = { number: useId(), consent: useId(), wrong: useId() }
const said = bad ?? wrong
function submit(e: FormEvent) {
e.preventDefault()
const number = e164(typed, region)
if (!number) return setBad(t(WHOLE))
if (!agree) return setBad(t(AGREE))
setBad(null)
onSend(number)
}
return (
<form onSubmit={submit} noValidate>
<YStack gap="$4">
<InputGroup size="lg" fill>
<PhoneCountryPicker
countries={list}
value={region}
onValueChange={(v) => v && setRegion(v)}
label={t('Country calling code')}
searchLabel={t('Search countries')}
emptyLabel={t('No country found')}
/>
<Input
id={ids.number}
type="tel"
inputMode="tel"
autoComplete="tel"
name="phone"
aria-label={t('Phone number')}
placeholder={t('Phone number')}
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoFocus={focus}
aria-invalid={said ? true : undefined}
aria-describedby={said ? ids.wrong : undefined}
/>
</InputGroup>
<XStack gap="$3" items="flex-start">
<Checkbox id={ids.consent} checked={agree} onCheckedChange={(v) => setAgree(v === true)} mt="$1" />
{/* The A2P wording, untranslated: carriers compare it across surfaces word for word. */}
<Text render="label" htmlFor={ids.consent} fontSize="$1" lineHeight="1.125rem" color="var(--muted-foreground)" flex={1}>
{CONSENT}
</Text>
</XStack>
<Wrong id={ids.wrong} text={said} />
<Action render="button" type="submit" fill disabled={busy} {...LARGE}>
{t(busy ? 'One moment…' : send)}
</Action>
</YStack>
</form>
)
}
/** The sign-in's first phone step: the number and the consent, under what the code does. */
export function Dial({ busy, wrong, onSend, onBack }: { busy: boolean; wrong: string | null; onSend: (number: string) => void; onBack: () => void }) {
const { t } = useI18n()
return (
<YStack gap="$4">
<Held address={t('We’ll text you a code.')} title={t('Continue with phone')} onBack={onBack} />
<Text fontSize="$2" lineHeight="1.125rem" color="var(--muted-foreground)">
{t('It signs in the account that uses this number.')}
</Text>
<Entry busy={busy} wrong={wrong} send="Text me a code" focus onSend={onSend} />
</YStack>
)
}
/**
* The code IAM texted to `number` at `at` (ms). Another is a press away once IAM
* will send one (`RESEND`); `spent` is a code tried as often as IAM allows, so
* only another can sign in.
*/
export function Texted({
number,
at,
code,
busy,
spent,
wrong,
note,
onCode,
onVerify,
onAgain,
onBack,
}: {
number: string
at: number
code: string
busy: boolean
spent: boolean
wrong: string | null
note: string | null
onCode: (code: string) => void
onVerify: () => void
onAgain: () => void
onBack: () => void
}) {
const { t } = useI18n()
const id = useId()
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const tick = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(tick)
}, [])
const left = wait(at, now)
return (
<form
onSubmit={(e) => {
e.preventDefault()
onVerify()
}}
noValidate
>
<YStack gap="$4">
<Held address={shown(number)} title={t('Enter the code we texted you')} onBack={onBack} />
<Code id={id} value={code} onChange={onCode} />
<Wrong text={wrong} />
<Note text={note} />
<Action render="button" type="submit" fill disabled={busy || spent} {...LARGE}>
{t(busy ? 'One moment…' : 'Continue')}
</Action>
<Other onClick={onAgain} disabled={busy || left > 0}>
{left > 0 ? t('Send a new code in {n}s', { n: left }) : t('Send a new code')}
</Other>
</YStack>
</form>
)
}