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, '../..'),
|
outputFileTracingRoot: path.join(import.meta.dirname, '../..'),
|
||||||
transpilePackages: ['@kvarta/shared'],
|
transpilePackages: ['@kvarta/shared'],
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
|
// AVIF заметно легче WebP на фото зерна (LCP на мобиле); WebP — запасной
|
||||||
|
images: { formats: ['image/avif', 'image/webp'] },
|
||||||
// в проде /api/ отдаёт nginx на контейнер api; локально проксируем на dev-сервер api
|
// в проде /api/ отдаёт nginx на контейнер api; локально проксируем на dev-сервер api
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
if (process.env.NODE_ENV === 'production') return [];
|
if (process.env.NODE_ENV === 'production') return [];
|
||||||
|
|||||||
@@ -70,6 +70,12 @@
|
|||||||
scroll-padding-top: 16px;
|
scroll-padding-top: 16px;
|
||||||
-webkit-text-size-adjust: 100%;
|
-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 {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-body);
|
font-size: var(--text-body);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Разметка формы заявки. Вся логика — в useLeadForm; вёрстку переделает дизайнер.
|
// Разметка формы заявки. Вся логика — в useLeadForm; вёрстку переделает дизайнер.
|
||||||
// Состояния для стилей: data-state (idle|submitting|success|error), data-notice, data-invalid, data-checked.
|
// Состояния для стилей: data-state (idle|submitting|success|error), data-notice, data-invalid, data-checked.
|
||||||
import { INTERESTS, INTEREST_LABELS, type LeadField } from '@kvarta/shared/lead';
|
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 { CONTACT_PHONE, FORM_TEXT } from '@/content/form';
|
||||||
import { LEAD_NAME_INPUT_ID } from '@/lib/leadPrefill';
|
import { LEAD_NAME_INPUT_ID } from '@/lib/leadPrefill';
|
||||||
import { Stamp } from './Stamp';
|
import { Stamp } from './Stamp';
|
||||||
@@ -99,12 +99,22 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
|
|||||||
const { values, errors, status, notice } = form;
|
const { values, errors, status, notice } = form;
|
||||||
const submitting = status === 'submitting';
|
const submitting = status === 'submitting';
|
||||||
const sendAnotherRef = useRef<HTMLButtonElement>(null);
|
const sendAnotherRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [submitTick, setSubmitTick] = useState(0);
|
||||||
|
|
||||||
// кнопка отправки исчезает вместе с формой — переносим фокус, чтобы он не потерялся
|
// кнопка отправки исчезает вместе с формой — переносим фокус, чтобы он не потерялся
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'success') sendAnotherRef.current?.focus();
|
if (status === 'success') sendAnotherRef.current?.focus();
|
||||||
}, [status]);
|
}, [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) => ({
|
const fieldProps = (field: LeadField) => ({
|
||||||
'aria-invalid': errors[field] ? true : undefined,
|
'aria-invalid': errors[field] ? true : undefined,
|
||||||
'aria-describedby': errors[field] ? errorId(field) : undefined,
|
'aria-describedby': errors[field] ? errorId(field) : undefined,
|
||||||
@@ -129,7 +139,15 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
|
|||||||
{FORM_TEXT.formTag}
|
{FORM_TEXT.formTag}
|
||||||
</p>
|
</p>
|
||||||
{status !== 'success' && (
|
{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">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="field-label" htmlFor={ids.name}>{FORM_TEXT.name.label}</label>
|
<label className="field-label" htmlFor={ids.name}>{FORM_TEXT.name.label}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ export function PriceTag({
|
|||||||
return (
|
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' : ''}`}>
|
<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 />}
|
{size ? <Size value={size} /> : <span />}
|
||||||
|
<span className="sr-only"> </span>
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
<span className={`mono ${large ? 'text-[clamp(2.25rem,1.6rem+2vw,3rem)]' : 'text-price'} font-semibold tracking-[-0.02em]`}>
|
<span className={`mono ${large ? 'text-[clamp(2.25rem,1.6rem+2vw,3rem)]' : 'text-price'} font-semibold tracking-[-0.02em]`}>
|
||||||
{price}
|
{price}
|
||||||
</span>
|
</span>
|
||||||
|
{/* пробелы для скринридера: визуальный отступ задают ml-*, без них читается «32BYN» */}
|
||||||
|
<span className="sr-only"> </span>
|
||||||
<span className={`mono ${large ? 'text-xl' : 'text-sm'} ml-1.5 font-semibold`}>BYN</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>}
|
{unit && <span className="ml-2 font-sans text-base font-medium text-muted">{unit}</span>}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -38,6 +42,7 @@ function Size({ value }: { value: string }) {
|
|||||||
{m ? (
|
{m ? (
|
||||||
<>
|
<>
|
||||||
<span>{m[1]}</span>
|
<span>{m[1]}</span>
|
||||||
|
<span className="sr-only"> </span>
|
||||||
<span className="ml-[0.25em]">{m[2]}</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 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 tops = heights.map((_, i) => PAD + heights.slice(0, i).reduce((a, b) => a + b, 0));
|
||||||
const color = `var(--color-${tone})`;
|
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 (
|
return (
|
||||||
<span className={`stamp ${className ?? ''}`} style={style} data-stamp={animate} data-backing={backing ? '' : undefined} aria-hidden="true">
|
<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}
|
src={cooler}
|
||||||
alt={site.images.cooler.alt}
|
alt={site.images.cooler.alt}
|
||||||
fill
|
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%]"
|
className="object-cover object-[center_40%]"
|
||||||
placeholder="blur"
|
placeholder="blur"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function Catalog({ roastDate }: { roastDate: string }) {
|
|||||||
{typo(catalog.title)}
|
{typo(catalog.title)}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 max-w-[46ch] text-lead">{typo(catalog.subtitle)}</p>
|
<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) => (
|
{packs.map((item) => (
|
||||||
<Label key={item.id} item={item} roastDate={roastDate} />
|
<Label key={item.id} item={item} roastDate={roastDate} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function Hero({ roastDate }: { roastDate: string }) {
|
|||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
fetchPriority="high"
|
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"
|
className="object-cover"
|
||||||
placeholder="blur"
|
placeholder="blur"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user