feat(bridge-widgets): format phone input with libphonenumber-js AsYouType, show country flag, soft-warn on invalid before letting bridgev2 arbitrate

This commit is contained in:
heaven 2026-05-27 01:56:22 +03:00
parent 7b3a4145a7
commit 6c052bbff9
12 changed files with 332 additions and 91 deletions

View file

@ -8,6 +8,7 @@
"name": "@vojo/widget-telegram",
"version": "0.0.1",
"dependencies": {
"libphonenumber-js": "^1.11.7",
"preact": "10.22.1",
"qrcode-generator": "^1.4.4"
},
@ -1611,6 +1612,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/libphonenumber-js": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.7.tgz",
"integrity": "sha512-x2xON4/Qg2bRIS11KIN9yCNYUjhtiEjNyptjX0mX+pyKHecxuJVLIpfX1lq9ZD6CrC/rB+y4GBi18c6CEcUR+A==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",

View file

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"libphonenumber-js": "^1.11.7",
"preact": "10.22.1",
"qrcode-generator": "^1.4.4"
},

View file

@ -2,6 +2,12 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'p
import type { Dispatch } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import qrcodeGenerator from 'qrcode-generator';
// `/min` metadata (~15 KB gzip) covers all country calling codes + length
// validation. Sufficient for «is this a plausible phone number?» — the
// bridge does the authoritative validation server-side. Avoid `/max`
// (~60 KB) since the widget is a separate Preact bundle and ships into
// the bot iframe on cold start.
import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min';
import type { WidgetBootstrap } from './bootstrap';
import { WidgetApi, type RoomEvent } from './widget-api';
import { createT, type T } from './i18n';
@ -222,6 +228,49 @@ type FormProps = {
// 60 s matches Telegram Desktop's own "Resend code" lockout.
const PHONE_COOLDOWN_MS = 60_000;
// Minimum digit count before we'd dare call a number «invalid». Below
// this threshold the user is still typing the country prefix and the
// formatter has nothing to validate against — showing red here would
// blink at every keystroke. 7 covers single-digit calling codes (US/RU
// = 1 + 6 digits is the shortest reasonable subscriber number).
const PHONE_MIN_DIGITS_FOR_VALIDATION = 7;
// Strip every character that isn't `+` or a digit, then guarantee a
// single leading `+` (bridgev2's E.164 validator rejects anything
// without it). Used both as the AsYouType input AND as the wire-format
// stripped value sent to the bridge — so paste-friendly cleanup
// («+1 (213) 373-4253», «+7-905-…») falls out for free.
const phoneToE164 = (raw: string): string => {
const cleaned = raw.replace(/[^\d+]/g, '');
if (cleaned.length === 0) return '';
return cleaned.startsWith('+') ? cleaned : `+${cleaned}`;
};
// AsYouType is stateful — calling `.input()` repeatedly with a growing
// string mutates the internal char buffer. Use a fresh instance per
// call so editing in the middle of the string (paste, backspace) can't
// desync the formatter state from the input value.
type PhoneFormat = { formatted: string; country: string | undefined };
const formatPhoneInput = (raw: string): PhoneFormat => {
const e164 = phoneToE164(raw);
if (!e164) return { formatted: '', country: undefined };
const formatter = new AsYouType();
const formatted = formatter.input(e164);
return { formatted, country: formatter.getCountry() };
};
// ISO 3166-1 alpha-2 → regional-indicator-symbol emoji. 'RU' → 🇷🇺.
// Browsers without flag-emoji fonts (Windows Chrome) fall back to the
// two-letter code rendered as letter glyphs, which is still readable.
const countryToFlagEmoji = (cc: string | undefined): string => {
if (!cc || cc.length !== 2) return '';
const codePoints = cc
.toUpperCase()
.split('')
.map((c) => 127397 + c.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
// Tick once per second while a future timestamp is still in the future.
// Returns the seconds remaining (0 once expired). When `until` is null
// the hook is idle.
@ -271,6 +320,11 @@ const PhoneForm = ({
setPhoneCooldownEnd,
}: FormProps) => {
const [value, setValue] = useState('');
// Country is captured directly from the AsYouType formatter in
// `onInput` so we don't run AsYouType a second time per keystroke
// just to read `getCountry()` — keeps the formatter call count at
// one per actual user input event instead of one per render.
const [country, setCountry] = useState<string | undefined>(undefined);
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const stillWaiting = useStillWaitingHint([submitting]);
@ -282,17 +336,35 @@ const PhoneForm = ({
inputRef.current?.focus();
}, []);
// Wire-format value (no spaces, single leading `+`) — what we send to
// the bridge, and what libphonenumber-js validates. `phoneToE164` is
// a regex on a 10-char string and `isValidPhoneNumber` (`/min`
// metadata) is a length-table lookup — both safe to recompute every
// render without memoisation.
const e164 = phoneToE164(value);
const digitsCount = e164.replace('+', '').length;
const hasEnoughDigits = digitsCount >= PHONE_MIN_DIGITS_FOR_VALIDATION;
// `isValidPhoneNumber` from `/min` metadata is intentionally treated as
// a soft hint, not a hard gate: the libphonenumber-js README itself
// warns that strict validation can reject newly-allocated mobile pools
// until the package is bumped, and bridgev2 has the authoritative word
// (it replies `invalid_value` + the App-level effect clears the
// cooldown). Matches Stripe / Auth0 / WhatsApp Web's warn-don't-block
// pattern.
const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164);
const onSubmit = async (event: Event) => {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed || submitting || inCooldown) return;
if (!e164 || submitting || inCooldown || !hasEnoughDigits) return;
setSubmitting(true);
// Clear any stale error optimistically so the form looks ready for the
// next attempt; a fresh error will re-arrive from the bot if the
// submit fails server-side.
dispatch({ kind: 'submit_phone' });
try {
await send(trimmed, 'phone');
// Strip the visual spaces / dashes AsYouType inserted before sending
// — bridgev2 normalises but server-side validation expects raw E.164.
await send(e164, 'phone');
// Cooldown locks retries ONLY after the Matrix transport accepted
// the message. If `await send` threw (network down, capability
// race, etc.), no SMS was attempted at the Telegram side — locking
@ -309,10 +381,11 @@ const PhoneForm = ({
};
const tone = error ? errorTone(error) : undefined;
const submitDisabled = submitting || inCooldown || value.trim() === '';
const submitDisabled = submitting || inCooldown || !hasEnoughDigits;
const submitLabel = inCooldown
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
: t('auth-card.phone.submit');
const flagEmoji = countryToFlagEmoji(country);
return (
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
@ -321,27 +394,42 @@ const PhoneForm = ({
{t('auth-card.phone.label')}
</label>
<div class="auth-card-row">
<input
id="auth-phone-input"
ref={inputRef}
class="auth-input"
type="tel"
autocomplete="tel"
inputmode="tel"
placeholder={t('auth-card.phone.placeholder')}
value={value}
onInput={(e) => {
// Auto-prepend `+` so the user never has to remember to type
// it — bridgev2 rejects anything without a leading `+` per
// its E.164 input validator. Skipping the special-case
// formatting (8→+7 etc.) on purpose: keeping the rule at one
// line of logic means there's nothing to misinterpret a
// pasted international number as a Russian trunk number.
const raw = (e.currentTarget as HTMLInputElement).value;
setValue(raw.length > 0 && !raw.startsWith('+') ? `+${raw}` : raw);
}}
disabled={submitting}
/>
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
{flagEmoji ? (
<span class="auth-phone-flag" aria-hidden="true">
{flagEmoji}
</span>
) : null}
<input
id="auth-phone-input"
ref={inputRef}
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
type="tel"
autocomplete="tel"
inputmode="tel"
placeholder={t('auth-card.phone.placeholder')}
value={value}
onInput={(e) => {
// Re-format on every keystroke via a fresh AsYouType. The
// formatter strips non-digit/non-`+` chars (so pastes like
// «+1 (213) 373-4253» normalise), auto-prepends `+` if
// missing (bridgev2 rejects without it), and groups digits
// by country convention. Caret jumps to end — acceptable
// for left-to-right phone entry; mid-string edits remain
// possible but the caret resets. Avoiding 8→+7 special-
// casing on purpose so a paste of an international number
// can't be misread as a Russian trunk dial. Country is
// captured here (instead of recomputed via useMemo) so the
// single AsYouType call covers both formatting and flag
// detection.
const raw = (e.currentTarget as HTMLInputElement).value;
const next = formatPhoneInput(raw);
setValue(next.formatted);
setCountry(next.country);
}}
disabled={submitting}
/>
</div>
<button type="submit" class="btn-primary" disabled={submitDisabled}>
{submitLabel}
</button>
@ -350,6 +438,9 @@ const PhoneForm = ({
</button>
</div>
<div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
{showInvalidHint && !error ? (
<div class="auth-card-warn">{t('auth-card.phone.invalid')}</div>
) : null}
{error ? (
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
{localizeError(error, t)}
@ -1182,10 +1273,7 @@ export function App({ bootstrap, api }: Props) {
append({ kind: 'diag', text: t('diag.qr-issued') });
} else if (event.kind === 'qr_redacted') {
const liveState = stateRef.current;
if (
liveState.kind === 'awaiting_qr_scan' &&
liveState.qrEventId === event.redactsEventId
) {
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
append({ kind: 'diag', text: t('diag.qr-consumed') });
}
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
@ -1273,7 +1361,6 @@ export function App({ bootstrap, api }: Props) {
}
}, [sendBare]);
// In-flight guard against double-tap. The button is on the disconnected
// screen which unmounts as soon as state advances, BUT a rapid second
// click can fire in the microtask window between dispatch and the next

View file

@ -40,6 +40,7 @@ export const EN: Record<StringKey, string> = {
'auth-card.phone.hint': 'SMS may take up to 30 seconds.',
'auth-card.phone.submit': 'Send code',
'auth-card.phone.cooldown': 'Retry in {seconds}s',
'auth-card.phone.invalid': "This doesn't look like a complete international phone number.",
'auth-card.code.title': 'Verification code',
'auth-card.code.label': 'SMS code',
'auth-card.code.placeholder': '123456',
@ -65,7 +66,8 @@ export const EN: Record<StringKey, string> = {
'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.',
'auth-card.qr.step-1': 'Open Settings → Devices in the Telegram app.',
'auth-card.qr.step-2': 'Tap “Link Device” and scan this QR code.',
'auth-card.qr.step-3': 'If two-step verification is on, enter your cloud password on the next step.',
'auth-card.qr.step-3':
'If two-step verification is on, enter your cloud password on the next step.',
'auth-error.invalid-code': 'Code is invalid. Please try again.',
'auth-error.wrong-password': 'Password is incorrect. Please try again.',
'auth-error.invalid-value': 'Value not accepted: {reason}',

View file

@ -71,6 +71,7 @@ export const RU = {
'auth-card.phone.hint': 'SMS может идти до 30 секунд.',
'auth-card.phone.submit': 'Отправить код',
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
// --- Code form ---------------------------------------------------------
'auth-card.code.title': 'Код подтверждения',
'auth-card.code.label': 'Код из SMS',

View file

@ -573,6 +573,46 @@ body {
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
}
/* Soft-warn variant for client-side phone validation. The bridge still
* has the authoritative word (and the cooldown clears itself on
* `invalid_value`), so we use amber rather than the harder rose tone
* reserved for server-confirmed errors. */
.auth-input.warn {
border-color: var(--amber);
}
.auth-input.warn:focus {
box-shadow: 0 0 0 3px rgba(231, 178, 90, 0.22);
}
/* Phone-input shell: lets us position a country-flag emoji over the
* input's left padding without splitting the input's own background /
* border / focus ring. The shell IS the layout flex child; the input
* fills it. `with-flag` bumps text padding-left so the digits clear
* the flag glyph. */
.auth-phone-shell {
position: relative;
display: flex;
flex: 1;
min-width: 0;
}
.auth-phone-shell .auth-input {
flex: 1;
min-width: 0;
}
.auth-phone-shell.with-flag .auth-input {
padding-left: 44px;
}
.auth-phone-flag {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 20px;
line-height: 1;
pointer-events: none;
user-select: none;
}
.auth-input.code,
.auth-input.password {
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;

View file

@ -8,6 +8,7 @@
"name": "@vojo/widget-whatsapp",
"version": "0.0.1",
"dependencies": {
"libphonenumber-js": "^1.11.7",
"preact": "10.22.1",
"qrcode-generator": "1.4.4"
},
@ -1611,6 +1612,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/libphonenumber-js": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.7.tgz",
"integrity": "sha512-x2xON4/Qg2bRIS11KIN9yCNYUjhtiEjNyptjX0mX+pyKHecxuJVLIpfX1lq9ZD6CrC/rB+y4GBi18c6CEcUR+A==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",

View file

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"libphonenumber-js": "^1.11.7",
"preact": "10.22.1",
"qrcode-generator": "1.4.4"
},

View file

@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'p
import type { Dispatch } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import qrcodeGenerator from 'qrcode-generator';
// `/min` metadata (~15 KB gzip) covers all country calling codes + length
// validation. Sufficient for «is this a plausible phone number?» — the
// bridge does the authoritative validation server-side.
import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min';
import type { WidgetBootstrap } from './bootstrap';
import { WidgetApi, type RoomEvent } from './widget-api';
import { createT, type T, type StringKey } from './i18n';
@ -98,11 +102,7 @@ const LogoutIcon = () => (
// picks up the amber tint via `currentColor` in either context.
const WarningIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M10 3.2 L17.5 16.5 L2.5 16.5 Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M10 3.2 L17.5 16.5 L2.5 16.5 Z" stroke-linecap="round" stroke-linejoin="round" />
<path d="M10 8.5 L10 12" stroke-linecap="round" />
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
</svg>
@ -136,8 +136,7 @@ const URL_RE = /https?:\/\/[^\s)]+/g;
// version-bumps from `2@` to e.g. `3@`. Reject patterns are e.g.
// «error: a,b,c,d in field» — without the digit prefix and the segment
// length floor, the old regex would clobber that.
const WA_QR_PAYLOAD_GLOBAL_RE =
/\d@[A-Za-z0-9+/=@:_.\-]{7,}(?:,[A-Za-z0-9+/=@:_.\-]{8,}){3}/g;
const WA_QR_PAYLOAD_GLOBAL_RE = /\d@[A-Za-z0-9+/=@:_.\-]{7,}(?:,[A-Za-z0-9+/=@:_.\-]{8,}){3}/g;
const scrubLoginSecret = (body: string): string =>
body.replace(WA_QR_PAYLOAD_GLOBAL_RE, '[redacted QR payload]');
@ -199,8 +198,8 @@ const localizeError = (err: LoginErrorFlag, t: T): string => {
err.reason === 'another_device'
? 'auth-error.external-logout.another-device'
: err.reason === 'phone_logged_out'
? 'auth-error.external-logout.phone-logged-out'
: 'auth-error.external-logout.unknown';
? 'auth-error.external-logout.phone-logged-out'
: 'auth-error.external-logout.unknown';
return t(subKey);
}
default: {
@ -243,6 +242,44 @@ type FormProps = {
// stop firing after.
const PHONE_COOLDOWN_MS = 60_000;
// Minimum digits required before we surface an «invalid number» hint.
// Below this the user is still typing the country prefix and the
// formatter has nothing useful to validate.
const PHONE_MIN_DIGITS_FOR_VALIDATION = 7;
// Strip every character that isn't `+` or a digit, then guarantee a
// single leading `+` — whatsmeow's PairPhone validator fires
// `PHONE_NUMBER_NOT_INTERNATIONAL` without it.
const phoneToE164 = (raw: string): string => {
const cleaned = raw.replace(/[^\d+]/g, '');
if (cleaned.length === 0) return '';
return cleaned.startsWith('+') ? cleaned : `+${cleaned}`;
};
// AsYouType is stateful — call `.input()` on a fresh instance per render
// so paste / mid-string edits don't desync the formatter buffer from the
// React state.
type PhoneFormat = { formatted: string; country: string | undefined };
const formatPhoneInput = (raw: string): PhoneFormat => {
const e164 = phoneToE164(raw);
if (!e164) return { formatted: '', country: undefined };
const formatter = new AsYouType();
const formatted = formatter.input(e164);
return { formatted, country: formatter.getCountry() };
};
// ISO 3166-1 alpha-2 → regional-indicator-symbol emoji («RU» → 🇷🇺).
// Windows Chrome doesn't ship the flag glyphs and falls back to the
// two-letter code rendered as plain letters — still readable.
const countryToFlagEmoji = (cc: string | undefined): string => {
if (!cc || cc.length !== 2) return '';
const codePoints = cc
.toUpperCase()
.split('')
.map((c) => 127397 + c.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
const useCooldownSeconds = (until: number | null): number => {
const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0);
const [seconds, setSeconds] = useState(compute);
@ -287,6 +324,10 @@ const PhoneForm = ({
setPhoneCooldownEnd,
}: FormProps) => {
const [value, setValue] = useState('');
// Country comes straight from the AsYouType call inside `onInput` so
// the formatter runs once per keystroke (instead of once for
// formatting and once more in a useMemo for the flag).
const [country, setCountry] = useState<string | undefined>(undefined);
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const stillWaiting = useStillWaitingHint([submitting]);
@ -298,14 +339,28 @@ const PhoneForm = ({
inputRef.current?.focus();
}, []);
// Wire-format value (no spaces, single leading `+`) — what we send to
// the bridge, and what libphonenumber-js validates. Both helpers are
// cheap (regex on a 10-char string, length-table lookup) and safe to
// recompute every render without memoisation.
const e164 = phoneToE164(value);
const digitsCount = e164.replace('+', '').length;
const hasEnoughDigits = digitsCount >= PHONE_MIN_DIGITS_FOR_VALIDATION;
// `isValidPhoneNumber` is a soft hint, not a hard gate: stale `/min`
// metadata can reject freshly-allocated mobile pools, and whatsmeow's
// own validator on the bridge side is authoritative. Match the
// Stripe / Auth0 / WhatsApp Web warn-don't-block pattern.
const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164);
const onSubmit = async (event: Event) => {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed || submitting || inCooldown) return;
if (!e164 || submitting || inCooldown || !hasEnoughDigits) return;
setSubmitting(true);
dispatch({ kind: 'submit_phone' });
try {
await send(trimmed);
// Strip visual spaces AsYouType inserted before sending — whatsmeow
// PairPhone wants raw E.164.
await send(e164);
// Cooldown locks retries ONLY after the Matrix transport accepted
// the message. If `await send` threw (network down, capability
// race), no pairing-code request was attempted at the WhatsApp
@ -322,10 +377,11 @@ const PhoneForm = ({
};
const tone = error ? errorTone(error) : undefined;
const submitDisabled = submitting || inCooldown || value.trim() === '';
const submitDisabled = submitting || inCooldown || !hasEnoughDigits;
const submitLabel = inCooldown
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
: t('auth-card.phone.submit');
const flagEmoji = countryToFlagEmoji(country);
return (
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
@ -334,31 +390,38 @@ const PhoneForm = ({
{t('auth-card.phone.label')}
</label>
<div class="auth-card-row">
<input
id="auth-phone-input"
ref={inputRef}
class="auth-input"
type="tel"
autocomplete="tel"
inputmode="tel"
placeholder={t('auth-card.phone.placeholder')}
value={value}
onInput={(e) => {
// Auto-prepend `+` so the user never has to remember to type
// it — the connector's PHONE_NUMBER_NOT_INTERNATIONAL error
// fires for anything without a leading `+` (whatsmeow
// PairPhone's validator). Skipping locale-specific
// formatting (8→+7 etc.) keeps the rule single-line.
//
// trimStart on the raw input so that a paste of « +12345…»
// (some clipboard sources include a leading space) still
// resolves to a single `+`, instead of producing the
// double-prefix `+ +12345…` bridgev2 then rejects.
const raw = (e.currentTarget as HTMLInputElement).value.trimStart();
setValue(raw.length > 0 && !raw.startsWith('+') ? `+${raw}` : raw);
}}
disabled={submitting}
/>
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
{flagEmoji ? (
<span class="auth-phone-flag" aria-hidden="true">
{flagEmoji}
</span>
) : null}
<input
id="auth-phone-input"
ref={inputRef}
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
type="tel"
autocomplete="tel"
inputmode="tel"
placeholder={t('auth-card.phone.placeholder')}
value={value}
onInput={(e) => {
// Re-format on every keystroke via a fresh AsYouType. Strips
// non-digit / non-`+` chars (so a paste of «+1 (213) 373-4253»
// or « +1…» normalises), auto-prepends `+` if missing
// (whatsmeow PairPhone rejects otherwise), and groups digits
// per country convention. Caret jumps to end on re-format —
// acceptable for left-to-right phone entry. Country is read
// from the same formatter call so the flag updates without
// a second AsYouType pass.
const raw = (e.currentTarget as HTMLInputElement).value;
const next = formatPhoneInput(raw);
setValue(next.formatted);
setCountry(next.country);
}}
disabled={submitting}
/>
</div>
<button type="submit" class="btn-primary" disabled={submitDisabled}>
{submitLabel}
</button>
@ -367,6 +430,9 @@ const PhoneForm = ({
</button>
</div>
<div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
{showInvalidHint && !error ? (
<div class="auth-card-warn">{t('auth-card.phone.invalid')}</div>
) : null}
{error ? (
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
{localizeError(error, t)}
@ -556,10 +622,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
}, []);
const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
const remainingSeconds = Math.max(
0,
Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000)
);
const remainingSeconds = Math.max(0, Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000));
const expired = elapsed >= PAIRING_CODE_TIMEOUT_MS && state.firstShownAt > 0;
return (
@ -578,10 +641,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
// user-select: all on the text element keeps one-tap copy
// working on touch devices.
<>
<output
class="auth-card-pairing-code-text"
aria-describedby="auth-pairing-code-desc"
>
<output class="auth-card-pairing-code-text" aria-describedby="auth-pairing-code-desc">
{state.code}
</output>
<span id="auth-pairing-code-desc" class="visually-hidden">
@ -603,9 +663,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
})}
</div>
) : (
<div class="auth-card-countdown expired">
{t('auth-card.pairing-code.expired')}
</div>
<div class="auth-card-countdown expired">{t('auth-card.pairing-code.expired')}</div>
)}
<ol class="auth-card-pairing-steps">
<li>{t('auth-card.pairing-code.step-1')}</li>
@ -1058,10 +1116,7 @@ export function App({ bootstrap, api }: Props) {
append({ kind: 'diag', text: t('diag.qr-issued') });
} else if (event.kind === 'qr_redacted') {
const liveState = stateRef.current;
if (
liveState.kind === 'awaiting_qr_scan' &&
liveState.qrEventId === event.redactsEventId
) {
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
append({ kind: 'diag', text: t('diag.qr-consumed') });
}
} else if (event.kind === 'pairing_code_displayed') {

View file

@ -47,11 +47,13 @@ export const EN: Record<StringKey, string> = {
'Enter your phone number including the country code. WhatsApp will then generate an 8-character pairing code that you enter in the WhatsApp app.',
'auth-card.phone.submit': 'Get code',
'auth-card.phone.cooldown': 'Retry in {seconds}s',
'auth-card.phone.invalid': "This doesn't look like a complete international phone number.",
'auth-card.pairing-code.title': 'Enter this code in WhatsApp',
'auth-card.pairing-code.hint':
'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.',
'auth-card.pairing-code.preparing': 'Preparing the code…',
'auth-card.pairing-code.aria': 'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.',
'auth-card.pairing-code.aria':
'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.',
'auth-card.pairing-code.countdown': 'Time left to enter: {minutes}:{seconds}',
'auth-card.pairing-code.expired': 'Sign-in window expired. Tap Cancel and try again.',
'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.',
@ -83,8 +85,7 @@ export const EN: Record<StringKey, string> = {
'WhatsApp unlinked this device from another device. Sign in again.',
'auth-error.external-logout.phone-logged-out':
'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.',
'auth-error.external-logout.unknown':
'WhatsApp dropped the session. Sign in again.',
'auth-error.external-logout.unknown': 'WhatsApp dropped the session. Sign in again.',
'card.logout.name': 'Sign out of WhatsApp',
'card.logout.desc': 'End the session for this account',
'card.logout.confirm-prompt': 'Sign out for real?',

View file

@ -94,6 +94,7 @@ export const RU = {
'Введите номер с кодом страны. После этого WhatsApp создаст 8-символьный код — его нужно будет ввести в приложении.',
'auth-card.phone.submit': 'Получить код',
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
// --- Pairing-code form -------------------------------------------------
'auth-card.pairing-code.title': 'Введите этот код в WhatsApp',
'auth-card.pairing-code.hint':
@ -104,7 +105,8 @@ export const RU = {
'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.',
'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».',
'auth-card.pairing-code.step-3': 'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
'auth-card.pairing-code.step-3':
'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.',
// --- QR form -----------------------------------------------------------
'auth-card.qr.title': 'Вход по QR-коду',
@ -147,8 +149,7 @@ export const RU = {
'WhatsApp отвязал это устройство с другого устройства. Войдите снова.',
'auth-error.external-logout.phone-logged-out':
'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.',
'auth-error.external-logout.unknown':
'WhatsApp разорвал сессию. Войдите снова.',
'auth-error.external-logout.unknown': 'WhatsApp разорвал сессию. Войдите снова.',
// --- Logout ------------------------------------------------------------
'card.logout.name': 'Выйти из WhatsApp',
'card.logout.desc': 'Завершить сеанс на этом аккаунте',

View file

@ -592,6 +592,44 @@ body {
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
}
/* Soft-warn for client-side phone validation. The bridge still has the
* final say (and the cooldown self-clears on `invalid_value`), so amber
* is the right register server-confirmed errors keep rose. */
.auth-input.warn {
border-color: var(--amber);
}
.auth-input.warn:focus {
box-shadow: 0 0 0 3px rgba(231, 178, 90, 0.22);
}
/* Phone-input shell host for the country-flag emoji positioned over
* the input's left padding (no need to split the input's background /
* border / focus ring across two siblings). `with-flag` bumps
* padding-left so digits clear the glyph. */
.auth-phone-shell {
position: relative;
display: flex;
flex: 1;
min-width: 0;
}
.auth-phone-shell .auth-input {
flex: 1;
min-width: 0;
}
.auth-phone-shell.with-flag .auth-input {
padding-left: 44px;
}
.auth-phone-flag {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 20px;
line-height: 1;
pointer-events: none;
user-select: none;
}
/* Note: TG-style `.auth-input.code` / `.auth-input.password` /
* `.password-row` / `.btn-icon` selectors were intentionally NOT
* carried over WhatsApp has no SMS-code form (pairing-code is
@ -835,7 +873,7 @@ body {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba(212, 184, 138, 0.10);
background: rgba(212, 184, 138, 0.1);
border: 1px solid var(--amber);
border-radius: 10px;
padding: 12px 14px;