fix(landing): impeccable audit — фокус на первое неверное поле, POST без JS, отступ под липкую CTA, 320 без скролла, AVIF и sizes
- форма: после «Отправить» с ошибками фокус уходит на первое поле с 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
This commit is contained in:
@@ -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 [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(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<HTMLElement>('[aria-invalid="true"]');
|
||||
const target = first?.tagName === 'FIELDSET' ? first.querySelector<HTMLElement>('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}
|
||||
</p>
|
||||
{status !== 'success' && (
|
||||
<form noValidate onSubmit={form.submit} className="flex flex-col gap-5">
|
||||
<form
|
||||
ref={formRef}
|
||||
method="post"
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
setSubmitTick((n) => n + 1);
|
||||
void form.submit(e);
|
||||
}}
|
||||
className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="field-label" htmlFor={ids.name}>{FORM_TEXT.name.label}</label>
|
||||
<input
|
||||
|
||||
@@ -19,11 +19,15 @@ export function PriceTag({
|
||||
return (
|
||||
<p className={`flex items-baseline justify-between gap-4 ${bare ? '' : 'border-t border-ink pt-3'} ${bareOnWide ? 'lg:border-t-0 lg:pt-0' : ''}`}>
|
||||
{size ? <Size value={size} /> : <span />}
|
||||
<span className="sr-only"> </span>
|
||||
<span className="whitespace-nowrap">
|
||||
<span className={`mono ${large ? 'text-[clamp(2.25rem,1.6rem+2vw,3rem)]' : 'text-price'} font-semibold tracking-[-0.02em]`}>
|
||||
{price}
|
||||
</span>
|
||||
{/* пробелы для скринридера: визуальный отступ задают ml-*, без них читается «32BYN» */}
|
||||
<span className="sr-only"> </span>
|
||||
<span className={`mono ${large ? 'text-xl' : 'text-sm'} ml-1.5 font-semibold`}>BYN</span>
|
||||
{unit && <span className="sr-only"> </span>}
|
||||
{unit && <span className="ml-2 font-sans text-base font-medium text-muted">{unit}</span>}
|
||||
</span>
|
||||
</p>
|
||||
@@ -38,6 +42,7 @@ function Size({ value }: { value: string }) {
|
||||
{m ? (
|
||||
<>
|
||||
<span>{m[1]}</span>
|
||||
<span className="sr-only"> </span>
|
||||
<span className="ml-[0.25em]">{m[2]}</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -30,7 +30,13 @@ export function Stamp({ lines, width = 168, rotate = -6, tone = 'cherry', animat
|
||||
const H = PAD * 2 + heights.reduce((a, b) => a + b, 0);
|
||||
const tops = heights.map((_, i) => PAD + heights.slice(0, i).reduce((a, b) => a + b, 0));
|
||||
const color = `var(--color-${tone})`;
|
||||
const style = { '--stamp-rotate': `${rotate}deg`, width, marginLeft: offsetX || undefined } as CSSProperties;
|
||||
// maxWidth: на 320 этикетка уже штампа — штамп сжимается, а не выталкивает страницу вбок
|
||||
const style = {
|
||||
'--stamp-rotate': `${rotate}deg`,
|
||||
width,
|
||||
maxWidth: offsetX ? `calc(100% - ${offsetX}px)` : '100%',
|
||||
marginLeft: offsetX || undefined,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<span className={`stamp ${className ?? ''}`} style={style} data-stamp={animate} data-backing={backing ? '' : undefined} aria-hidden="true">
|
||||
|
||||
@@ -13,7 +13,7 @@ export function About() {
|
||||
src={cooler}
|
||||
alt={site.images.cooler.alt}
|
||||
fill
|
||||
sizes="(min-width: 1280px) 610px, (min-width: 1024px) 48vw, 100vw"
|
||||
sizes="(min-width: 1280px) 630px, (min-width: 1024px) 48vw, 100vw"
|
||||
className="object-cover object-[center_40%]"
|
||||
placeholder="blur"
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function Catalog({ roastDate }: { roastDate: string }) {
|
||||
{typo(catalog.title)}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-[46ch] text-lead">{typo(catalog.subtitle)}</p>
|
||||
<div className="mt-10 grid gap-4 md:grid-cols-2 lg:mt-16 lg:grid-cols-3 lg:gap-x-6 lg:gap-y-6">
|
||||
<div className="mt-10 grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2 lg:mt-16 lg:grid-cols-3 lg:gap-x-6 lg:gap-y-6">
|
||||
{packs.map((item) => (
|
||||
<Label key={item.id} item={item} roastDate={roastDate} />
|
||||
))}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function Hero({ roastDate }: { roastDate: string }) {
|
||||
fill
|
||||
priority
|
||||
fetchPriority="high"
|
||||
sizes="(min-width: 1280px) 500px, (min-width: 1024px) 38vw, 100vw"
|
||||
sizes="(min-width: 1280px) 520px, (min-width: 1024px) 38vw, 100vw"
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user