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
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:
parent
164ad57ed7
commit
3322da06a0
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ? 'Выполняется вход…' : 'Войти'}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue