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

Регрессия к 3322da0: submit запускался прямо на pointerdown, поэтому прокрутка,
начатая пальцем на широкой кнопке «Начать бесплатно»/«Войти», отправляла форму.

Теперь на pointerdown только preventDefault (держим фокус в поле — blur-ошибка
не сдвигает вёрстку) и запоминаем точку касания. Отправляем на pointerup,
только если палец не сдвинулся больше порога (10px); pointercancel (жест стал
прокруткой) сбрасывает — submit не происходит. Тап отправляет с первого раза,
свайп с кнопки просто прокручивает страницу. onSubmit формы сохранён для Enter,
submittingRef защищает от двойного запроса.

Применено к форме регистрации и форме входа кабинета.

Проверено настоящими touch-жестами в Chromium (mobile 360×676, CDP
dispatchTouchEvent): тап → submit; свайп вверх 250px с кнопки → страница
прокручивается, submit НЕ срабатывает; долгое нажатие без движения → submit;
валидная форма — ровно один запрос.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-13 17:37:30 +05:00
parent 3322da06a0
commit 87a4666c25
2 changed files with 48 additions and 13 deletions

View file

@ -162,17 +162,31 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
// onSubmit формы — срабатывает на Enter с клавиатуры.
const submit = (ev: React.FormEvent) => { ev.preventDefault(); runSubmit() }
// Касание кнопки «Начать бесплатно»: отправляем на pointerdown, ДО того как
// смена фокуса (blur поля) успеет перерисовать форму и сдвинуть кнопку.
// preventDefault не даёт фокусу уйти с поля (значит blur-валидация не
// вставит ошибку и вёрстка не прыгнет) и подавляет последующий click, так что
// первое касание всегда отправляет форму — валидация всех полей + прокрутка и
// фокус к первой ошибке. Фикс потери первого касания на мобильном.
const onSubmitBtnPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
// Касание кнопки «Начать бесплатно». На pointerdown только preventDefault —
// фокус остаётся в поле, blur-ошибка не вставляется и вёрстка не прыгает
// (иначе кнопка уезжала и первое касание терялось). Отправляем на pointerup,
// только если палец НЕ сдвинулся больше порога и жест не стал прокруткой
// (pointercancel → tapRef сбрасывается). Так тап отправляет с первого раза,
// а свайп, начатый на кнопке, просто прокручивает страницу.
const TAP_SLOP = 10
const tapRef = useRef<{ id: number; x: number; y: number; moved: boolean } | null>(null)
const onBtnPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
if (e.button !== 0) return
e.preventDefault()
tapRef.current = { id: e.pointerId, x: e.clientX, y: e.clientY, moved: false }
}
const onBtnPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
const t = tapRef.current
if (!t || e.pointerId !== t.id) return
if (Math.abs(e.clientX - t.x) > TAP_SLOP || Math.abs(e.clientY - t.y) > TAP_SLOP) t.moved = true
}
const onBtnPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
const t = tapRef.current
tapRef.current = null
if (!t || e.pointerId !== t.id || t.moved) return
runSubmit()
}
const onBtnPointerCancel = () => { tapRef.current = null }
// text-base на мобильной ширине (16px) → iOS Safari не зумит страницу при
// фокусе на поле; на sm и выше — привычные 14px. user-scalable=no НЕ ставим.
@ -314,7 +328,10 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
{fieldErrors.agree && <p className="text-xs text-red-600 mt-1">{fieldErrors.agree}</p>}
</div>
<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">
<button type="submit" disabled={busy}
onPointerDown={onBtnPointerDown} onPointerMove={onBtnPointerMove}
onPointerUp={onBtnPointerUp} onPointerCancel={onBtnPointerCancel}
className="w-full px-4 py-3 bg-brand text-white font-semibold rounded-md hover:bg-[#009305] disabled:opacity-60">
{busy ? 'Регистрация…' : 'Начать бесплатно (90 дней)'}
</button>
<p className="text-xs text-center text-slate-500">Уже есть аккаунт? <a href={`${APP_URL}/login`} className="text-brand underline">Войти</a></p>

View file

@ -36,14 +36,29 @@ export function LoginPage() {
// onSubmit формы (Enter с клавиатуры).
function handleSubmit(e: FormEvent) { e.preventDefault(); runSubmit() }
// Касание кнопки «Войти»: отправляем на pointerdown, ДО того как blur поля
// перерисует форму и сдвинёт кнопку (иначе на мобильном первое касание
// терялось). preventDefault удерживает фокус в поле и подавляет click.
function onSubmitBtnPointerDown(e: React.PointerEvent<HTMLButtonElement>) {
// Касание кнопки «Войти». На pointerdown только preventDefault — фокус
// остаётся в поле, blur-ошибка не сдвигает вёрстку (иначе первое касание
// терялось). Отправляем на pointerup, только если палец не сдвинулся и жест
// не стал прокруткой (pointercancel сбрасывает). Свайп с кнопки — прокрутка.
const TAP_SLOP = 10
const tapRef = useRef<{ id: number; x: number; y: number; moved: boolean } | null>(null)
function onBtnPointerDown(e: React.PointerEvent<HTMLButtonElement>) {
if (e.button !== 0) return
e.preventDefault()
tapRef.current = { id: e.pointerId, x: e.clientX, y: e.clientY, moved: false }
}
function onBtnPointerMove(e: React.PointerEvent<HTMLButtonElement>) {
const t = tapRef.current
if (!t || e.pointerId !== t.id) return
if (Math.abs(e.clientX - t.x) > TAP_SLOP || Math.abs(e.clientY - t.y) > TAP_SLOP) t.moved = true
}
function onBtnPointerUp(e: React.PointerEvent<HTMLButtonElement>) {
const t = tapRef.current
tapRef.current = null
if (!t || e.pointerId !== t.id || t.moved) return
runSubmit()
}
function onBtnPointerCancel() { tapRef.current = null }
async function runSubmit() {
const em = validateEmail(email)
@ -154,7 +169,10 @@ export function LoginPage() {
<button
type="submit"
disabled={loading}
onPointerDown={onSubmitBtnPointerDown}
onPointerDown={onBtnPointerDown}
onPointerMove={onBtnPointerMove}
onPointerUp={onBtnPointerUp}
onPointerCancel={onBtnPointerCancel}
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 ? 'Выполняется вход…' : 'Войти'}