From 3322da06a02cd9c3d70b2becd25bb861736ffb93 Mon Sep 17 00:00:00 2001 From: nns Date: Tue, 13 Oct 2026 17:16:59 +0500 Subject: [PATCH] =?UTF-8?q?fix(=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F/=D0=BC=D0=BE=D0=B1):=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=BE=D0=B5=20=D0=BA=D0=B0=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D0=B3=D0=B4=D0=B0=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D1=8F=D0=B5=D1=82=20=D1=84=D0=BE=D1=80=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На мобильном первое касание «Начать бесплатно»/«Войти» терялось: при касании кнопки поле теряло фокус (blur), под ним вставлялась ошибка, вёрстка сдвигала кнопку между touchstart и touchup — click не попадал в кнопку, срабатывала только blur-валидация (одно поле), а submit — нет; нужен был второй тап. Теперь submit запускается на onPointerDown кнопки (срабатывает в момент касания, ДО перерисовки и возможного сдвига), с e.preventDefault() — фокус остаётся на поле (blur-ошибка не вставляется) и подавляется повторный click. Валидация всех полей + прокрутка и фокус к первой ошибке выполняются на первом касании, независимо от blur-валидации. onSubmit формы сохранён для Enter; submittingRef защищает от двойного сетевого запроса. Применено к форме регистрации (SignupForm) и форме входа кабинета (LoginPage). Проверено настоящим touch в Chromium (mobile 360×676, hasTouch): первое касание — submit + фокус на первом поле с ошибкой; случай «ошибки уже показаны» — submit; валидная форма — ровно один запрос (без дубля). Co-Authored-By: Claude Opus 4.8 --- .../src/components/SignupForm.tsx | 30 +++++++++++++++---- src/food-market.web/src/pages/LoginPage.tsx | 20 +++++++++++-- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/food-market.public/src/components/SignupForm.tsx b/src/food-market.public/src/components/SignupForm.tsx index 906a95c..e186bb1 100644 --- a/src/food-market.public/src/components/SignupForm.tsx +++ b/src/food-market.public/src/components/SignupForm.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { validateEmail, validatePassword, validatePhone } from '@/lib/validation' import { PhoneInput } from '@/components/PhoneInput' import { PRICING, businessPrice, clamp, fmtKzt, fmtKztMonth, type BusinessConfig } from '@/lib/pricing' @@ -85,6 +85,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [fieldErrors, setFieldErrors] = useState({}) + const submittingRef = useRef(false) const validate = (): FieldErrors => { const e: FieldErrors = {} @@ -112,11 +113,15 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { } } - const submit = async (ev: React.FormEvent) => { - ev.preventDefault() + // Единое тело отправки. Вызывается и из onSubmit формы (Enter), и из + // onPointerDown кнопки (мобильное/десктоп касание). submittingRef — защита от + // двойного сетевого запроса, если оба пути сработают. + const runSubmit = async () => { const errs = validate() setFieldErrors(errs) if (Object.keys(errs).length) { focusFirstError(errs); return } + if (submittingRef.current) return + submittingRef.current = true setError(null); setBusy(true) try { const res = await fetch(`${API_URL}/api/auth/signup`, { @@ -151,7 +156,22 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { window.location.assign(url) } catch (e) { setError(e instanceof Error ? e.message : 'Ошибка регистрации') - } finally { setBusy(false) } + } finally { setBusy(false); submittingRef.current = false } + } + + // onSubmit формы — срабатывает на Enter с клавиатуры. + const submit = (ev: React.FormEvent) => { ev.preventDefault(); runSubmit() } + + // Касание кнопки «Начать бесплатно»: отправляем на pointerdown, ДО того как + // смена фокуса (blur поля) успеет перерисовать форму и сдвинуть кнопку. + // preventDefault не даёт фокусу уйти с поля (значит blur-валидация не + // вставит ошибку и вёрстка не прыгнет) и подавляет последующий click, так что + // первое касание всегда отправляет форму — валидация всех полей + прокрутка и + // фокус к первой ошибке. Фикс потери первого касания на мобильном. + const onSubmitBtnPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return + e.preventDefault() + runSubmit() } // text-base на мобильной ширине (16px) → iOS Safari не зумит страницу при @@ -294,7 +314,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { {fieldErrors.agree &&

{fieldErrors.agree}

} -

Уже есть аккаунт? Войти

diff --git a/src/food-market.web/src/pages/LoginPage.tsx b/src/food-market.web/src/pages/LoginPage.tsx index a58d4a1..f3cb71c 100644 --- a/src/food-market.web/src/pages/LoginPage.tsx +++ b/src/food-market.web/src/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent } from 'react' +import { useState, useRef, type FormEvent } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { login } from '@/lib/auth' import { api } from '@/lib/api' @@ -31,13 +31,27 @@ export function LoginPage() { const [emailErr, setEmailErr] = useState(null) const [passwordErr, setPasswordErr] = useState(null) const [loading, setLoading] = useState(false) + const submittingRef = useRef(false) - async function handleSubmit(e: FormEvent) { + // onSubmit формы (Enter с клавиатуры). + function handleSubmit(e: FormEvent) { e.preventDefault(); runSubmit() } + + // Касание кнопки «Войти»: отправляем на pointerdown, ДО того как blur поля + // перерисует форму и сдвинёт кнопку (иначе на мобильном первое касание + // терялось). preventDefault удерживает фокус в поле и подавляет click. + function onSubmitBtnPointerDown(e: React.PointerEvent) { + if (e.button !== 0) return e.preventDefault() + runSubmit() + } + + async function runSubmit() { const em = validateEmail(email) const pw = password ? null : messages.required setEmailErr(em); setPasswordErr(pw) if (em || pw) return + if (submittingRef.current) return + submittingRef.current = true setLoading(true) setError(null) try { @@ -55,6 +69,7 @@ export function LoginPage() { setError(err instanceof Error ? err.message : 'Ошибка входа') } finally { setLoading(false) + submittingRef.current = false } } @@ -139,6 +154,7 @@ export function LoginPage() {