diff --git a/apps/widget-telegram/package-lock.json b/apps/widget-telegram/package-lock.json index 13c15bec..eb55825f 100644 --- a/apps/widget-telegram/package-lock.json +++ b/apps/widget-telegram/package-lock.json @@ -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", diff --git a/apps/widget-telegram/package.json b/apps/widget-telegram/package.json index 503f0703..4ba17d56 100644 --- a/apps/widget-telegram/package.json +++ b/apps/widget-telegram/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "libphonenumber-js": "^1.11.7", "preact": "10.22.1", "qrcode-generator": "^1.4.4" }, diff --git a/apps/widget-telegram/src/App.tsx b/apps/widget-telegram/src/App.tsx index 29c31ce1..724332a9 100644 --- a/apps/widget-telegram/src/App.tsx +++ b/apps/widget-telegram/src/App.tsx @@ -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(undefined); const [submitting, setSubmitting] = useState(false); const inputRef = useRef(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 (
@@ -321,27 +394,42 @@ const PhoneForm = ({ {t('auth-card.phone.label')}
- { - // 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} - /> +
+ {flagEmoji ? ( + + ) : null} + { + // 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} + /> +
@@ -350,6 +438,9 @@ const PhoneForm = ({
{t('auth-card.phone.hint')}
+ {showInvalidHint && !error ? ( +
{t('auth-card.phone.invalid')}
+ ) : null} {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 diff --git a/apps/widget-telegram/src/i18n/en.ts b/apps/widget-telegram/src/i18n/en.ts index accd21e9..1897441d 100644 --- a/apps/widget-telegram/src/i18n/en.ts +++ b/apps/widget-telegram/src/i18n/en.ts @@ -40,6 +40,7 @@ export const EN: Record = { '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 = { '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}', diff --git a/apps/widget-telegram/src/i18n/ru.ts b/apps/widget-telegram/src/i18n/ru.ts index a9dd9424..714692a2 100644 --- a/apps/widget-telegram/src/i18n/ru.ts +++ b/apps/widget-telegram/src/i18n/ru.ts @@ -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', diff --git a/apps/widget-telegram/src/styles.css b/apps/widget-telegram/src/styles.css index b67792ca..7b9736dd 100644 --- a/apps/widget-telegram/src/styles.css +++ b/apps/widget-telegram/src/styles.css @@ -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; diff --git a/apps/widget-whatsapp/package-lock.json b/apps/widget-whatsapp/package-lock.json index 9716ad62..9abf436c 100644 --- a/apps/widget-whatsapp/package-lock.json +++ b/apps/widget-whatsapp/package-lock.json @@ -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", diff --git a/apps/widget-whatsapp/package.json b/apps/widget-whatsapp/package.json index 59b8d648..8f76fa11 100644 --- a/apps/widget-whatsapp/package.json +++ b/apps/widget-whatsapp/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "libphonenumber-js": "^1.11.7", "preact": "10.22.1", "qrcode-generator": "1.4.4" }, diff --git a/apps/widget-whatsapp/src/App.tsx b/apps/widget-whatsapp/src/App.tsx index 16bbaa1a..4f4b1666 100644 --- a/apps/widget-whatsapp/src/App.tsx +++ b/apps/widget-whatsapp/src/App.tsx @@ -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 = () => ( @@ -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(undefined); const [submitting, setSubmitting] = useState(false); const inputRef = useRef(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 ( @@ -334,31 +390,38 @@ const PhoneForm = ({ {t('auth-card.phone.label')}
- { - // 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} - /> +
+ {flagEmoji ? ( + + ) : null} + { + // 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} + /> +
@@ -367,6 +430,9 @@ const PhoneForm = ({
{t('auth-card.phone.hint')}
+ {showInvalidHint && !error ? ( +
{t('auth-card.phone.invalid')}
+ ) : null} {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. <> - + {state.code} @@ -603,9 +663,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => { })}
) : ( -
- {t('auth-card.pairing-code.expired')} -
+
{t('auth-card.pairing-code.expired')}
)}
  1. {t('auth-card.pairing-code.step-1')}
  2. @@ -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') { diff --git a/apps/widget-whatsapp/src/i18n/en.ts b/apps/widget-whatsapp/src/i18n/en.ts index 7de15030..2285b9e3 100644 --- a/apps/widget-whatsapp/src/i18n/en.ts +++ b/apps/widget-whatsapp/src/i18n/en.ts @@ -47,11 +47,13 @@ export const EN: Record = { '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 = { '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?', diff --git a/apps/widget-whatsapp/src/i18n/ru.ts b/apps/widget-whatsapp/src/i18n/ru.ts index 0e5fb214..7239b2de 100644 --- a/apps/widget-whatsapp/src/i18n/ru.ts +++ b/apps/widget-whatsapp/src/i18n/ru.ts @@ -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': 'Завершить сеанс на этом аккаунте', diff --git a/apps/widget-whatsapp/src/styles.css b/apps/widget-whatsapp/src/styles.css index d5d54b86..3ee7dce8 100644 --- a/apps/widget-whatsapp/src/styles.css +++ b/apps/widget-whatsapp/src/styles.css @@ -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;