fix(регистрация/моб): первое касание кнопки всегда отправляет форму
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

На мобильном первое касание «Начать бесплатно»/«Войти» терялось: при касании
кнопки поле теряло фокус (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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-13 17:16:59 +05:00
parent 164ad57ed7
commit 3322da06a0
2 changed files with 43 additions and 7 deletions

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { validateEmail, validatePassword, validatePhone } from '@/lib/validation' import { validateEmail, validatePassword, validatePhone } from '@/lib/validation'
import { PhoneInput } from '@/components/PhoneInput' import { PhoneInput } from '@/components/PhoneInput'
import { PRICING, businessPrice, clamp, fmtKzt, fmtKztMonth, type BusinessConfig } from '@/lib/pricing' 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 [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}) const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const submittingRef = useRef(false)
const validate = (): FieldErrors => { const validate = (): FieldErrors => {
const e: FieldErrors = {} const e: FieldErrors = {}
@ -112,11 +113,15 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
} }
} }
const submit = async (ev: React.FormEvent) => { // Единое тело отправки. Вызывается и из onSubmit формы (Enter), и из
ev.preventDefault() // onPointerDown кнопки (мобильное/десктоп касание). submittingRef — защита от
// двойного сетевого запроса, если оба пути сработают.
const runSubmit = async () => {
const errs = validate() const errs = validate()
setFieldErrors(errs) setFieldErrors(errs)
if (Object.keys(errs).length) { focusFirstError(errs); return } if (Object.keys(errs).length) { focusFirstError(errs); return }
if (submittingRef.current) return
submittingRef.current = true
setError(null); setBusy(true) setError(null); setBusy(true)
try { try {
const res = await fetch(`${API_URL}/api/auth/signup`, { const res = await fetch(`${API_URL}/api/auth/signup`, {
@ -151,7 +156,22 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
window.location.assign(url) window.location.assign(url)
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка регистрации') 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<HTMLButtonElement>) => {
if (e.button !== 0) return
e.preventDefault()
runSubmit()
} }
// text-base на мобильной ширине (16px) → iOS Safari не зумит страницу при // text-base на мобильной ширине (16px) → iOS Safari не зумит страницу при
@ -294,7 +314,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
{fieldErrors.agree && <p className="text-xs text-red-600 mt-1">{fieldErrors.agree}</p>} {fieldErrors.agree && <p className="text-xs text-red-600 mt-1">{fieldErrors.agree}</p>}
</div> </div>
<button type="submit" disabled={busy} className="w-full px-4 py-3 bg-brand text-white font-semibold rounded-md hover:bg-[#009305] disabled:opacity-60"> <button type="submit" disabled={busy} onPointerDown={onSubmitBtnPointerDown} className="w-full px-4 py-3 bg-brand text-white font-semibold rounded-md hover:bg-[#009305] disabled:opacity-60">
{busy ? 'Регистрация…' : 'Начать бесплатно (90 дней)'} {busy ? 'Регистрация…' : 'Начать бесплатно (90 дней)'}
</button> </button>
<p className="text-xs text-center text-slate-500">Уже есть аккаунт? <a href={`${APP_URL}/login`} className="text-brand underline">Войти</a></p> <p className="text-xs text-center text-slate-500">Уже есть аккаунт? <a href={`${APP_URL}/login`} className="text-brand underline">Войти</a></p>

View file

@ -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 { useNavigate, useLocation } from 'react-router-dom'
import { login } from '@/lib/auth' import { login } from '@/lib/auth'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -31,13 +31,27 @@ export function LoginPage() {
const [emailErr, setEmailErr] = useState<string | null>(null) const [emailErr, setEmailErr] = useState<string | null>(null)
const [passwordErr, setPasswordErr] = useState<string | null>(null) const [passwordErr, setPasswordErr] = useState<string | null>(null)
const [loading, setLoading] = useState(false) 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<HTMLButtonElement>) {
if (e.button !== 0) return
e.preventDefault() e.preventDefault()
runSubmit()
}
async function runSubmit() {
const em = validateEmail(email) const em = validateEmail(email)
const pw = password ? null : messages.required const pw = password ? null : messages.required
setEmailErr(em); setPasswordErr(pw) setEmailErr(em); setPasswordErr(pw)
if (em || pw) return if (em || pw) return
if (submittingRef.current) return
submittingRef.current = true
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
@ -55,6 +69,7 @@ export function LoginPage() {
setError(err instanceof Error ? err.message : 'Ошибка входа') setError(err instanceof Error ? err.message : 'Ошибка входа')
} finally { } finally {
setLoading(false) setLoading(false)
submittingRef.current = false
} }
} }
@ -139,6 +154,7 @@ export function LoginPage() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
onPointerDown={onSubmitBtnPointerDown}
className="w-full rounded-md bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] disabled:opacity-60 text-white font-medium py-2.5 text-sm transition-colors" className="w-full rounded-md bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] disabled:opacity-60 text-white font-medium py-2.5 text-sm transition-colors"
> >
{loading ? 'Выполняется вход…' : 'Войти'} {loading ? 'Выполняется вход…' : 'Войти'}