From be8db997a5a5a79685e84c1e9f5fcc01bcd63e03 Mon Sep 17 00:00:00 2001
From: Nikolay Unuchek
Date: Sat, 17 Oct 2026 17:21:12 +0300
Subject: [PATCH] =?UTF-8?q?fix(landing):=20impeccable=20audit=20=E2=80=94?=
=?UTF-8?q?=20=D1=84=D0=BE=D0=BA=D1=83=D1=81=20=D0=BD=D0=B0=20=D0=BF=D0=B5?=
=?UTF-8?q?=D1=80=D0=B2=D0=BE=D0=B5=20=D0=BD=D0=B5=D0=B2=D0=B5=D1=80=D0=BD?=
=?UTF-8?q?=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B5,=20POST=20=D0=B1=D0=B5?=
=?UTF-8?q?=D0=B7=20JS,=20=D0=BE=D1=82=D1=81=D1=82=D1=83=D0=BF=20=D0=BF?=
=?UTF-8?q?=D0=BE=D0=B4=20=D0=BB=D0=B8=D0=BF=D0=BA=D1=83=D1=8E=20CTA,=2032?=
=?UTF-8?q?0=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=BA=D1=80=D0=BE=D0=BB=D0=BB?=
=?UTF-8?q?=D0=B0,=20AVIF=20=D0=B8=20sizes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- форма: после «Отправить» с ошибками фокус уходит на первое поле с aria-invalid, скринридер читает подсказку; method=post — без JS имя и телефон не попадают в URL
- scroll-padding-bottom < 768: фокус и якоря не прячутся под липкой CTA (WCAG 2.4.11)
- штамп на этикетке сжимается на 320 вместо горизонтального скролла (WCAG 1.4.10); на 390 и шире без изменений
- цена читается «32 BYN», «250 г» (sr-only пробелы)
- sizes фото hero и «Кто обжаривает» по реальной ширине колонки на 1440; AVIF перед WebP
---
apps/landing/next.config.ts | 2 ++
apps/landing/src/app/globals.css | 6 +++++
apps/landing/src/components/LeadForm.tsx | 22 +++++++++++++++++--
apps/landing/src/components/PriceTag.tsx | 5 +++++
apps/landing/src/components/Stamp.tsx | 8 ++++++-
.../landing/src/components/sections/About.tsx | 2 +-
.../src/components/sections/Catalog.tsx | 2 +-
apps/landing/src/components/sections/Hero.tsx | 2 +-
8 files changed, 43 insertions(+), 6 deletions(-)
diff --git a/apps/landing/next.config.ts b/apps/landing/next.config.ts
index a4fbc75..b165982 100644
--- a/apps/landing/next.config.ts
+++ b/apps/landing/next.config.ts
@@ -6,6 +6,8 @@ const config: NextConfig = {
outputFileTracingRoot: path.join(import.meta.dirname, '../..'),
transpilePackages: ['@kvarta/shared'],
poweredByHeader: false,
+ // AVIF заметно легче WebP на фото зерна (LCP на мобиле); WebP — запасной
+ images: { formats: ['image/avif', 'image/webp'] },
// в проде /api/ отдаёт nginx на контейнер api; локально проксируем на dev-сервер api
async rewrites() {
if (process.env.NODE_ENV === 'production') return [];
diff --git a/apps/landing/src/app/globals.css b/apps/landing/src/app/globals.css
index b56007c..9ae054f 100644
--- a/apps/landing/src/app/globals.css
+++ b/apps/landing/src/app/globals.css
@@ -70,6 +70,12 @@
scroll-padding-top: 16px;
-webkit-text-size-adjust: 100%;
}
+ /* < 768 снизу липкая CTA (~68px): фокус и якоря не уходят под неё (WCAG 2.4.11) */
+ @media (max-width: 767px) {
+ html {
+ scroll-padding-bottom: calc(76px + env(safe-area-inset-bottom));
+ }
+ }
body {
font-family: var(--font-sans);
font-size: var(--text-body);
diff --git a/apps/landing/src/components/LeadForm.tsx b/apps/landing/src/components/LeadForm.tsx
index 1b598cb..dfb92aa 100644
--- a/apps/landing/src/components/LeadForm.tsx
+++ b/apps/landing/src/components/LeadForm.tsx
@@ -2,7 +2,7 @@
// Разметка формы заявки. Вся логика — в useLeadForm; вёрстку переделает дизайнер.
// Состояния для стилей: data-state (idle|submitting|success|error), data-notice, data-invalid, data-checked.
import { INTERESTS, INTEREST_LABELS, type LeadField } from '@kvarta/shared/lead';
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { CONTACT_PHONE, FORM_TEXT } from '@/content/form';
import { LEAD_NAME_INPUT_ID } from '@/lib/leadPrefill';
import { Stamp } from './Stamp';
@@ -99,12 +99,22 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
const { values, errors, status, notice } = form;
const submitting = status === 'submitting';
const sendAnotherRef = useRef(null);
+ const formRef = useRef(null);
+ const [submitTick, setSubmitTick] = useState(0);
// кнопка отправки исчезает вместе с формой — переносим фокус, чтобы он не потерялся
useEffect(() => {
if (status === 'success') sendAnotherRef.current?.focus();
}, [status]);
+ // после «Отправить» с ошибками фокус — на первое неверное поле: скринридер прочтёт его подсказку (aria-describedby)
+ useEffect(() => {
+ if (submitTick === 0 || status === 'submitting') return;
+ const first = formRef.current?.querySelector('[aria-invalid="true"]');
+ const target = first?.tagName === 'FIELDSET' ? first.querySelector('input:checked, input') : first;
+ target?.focus();
+ }, [submitTick, status]);
+
const fieldProps = (field: LeadField) => ({
'aria-invalid': errors[field] ? true : undefined,
'aria-describedby': errors[field] ? errorId(field) : undefined,
@@ -129,7 +139,15 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
{FORM_TEXT.formTag}
{status !== 'success' && (
-