fix(mera/b2c): на мобильном липкий хром съедал 42% экрана

Жалоба «много всплывашек, экрана не видно» — это не всплывашки, а постоянный
хром: на 375×812 липкая шапка (235 px) и фиксированная нижняя панель (109 px)
вместе занимали 42% экрана и отъедали их при любой прокрутке. Замер тем же
способом (getBoundingClientRect по элементам с position fixed/sticky): было
366 px = 45,1% высоты экрана, стало 119,5 px = 14,7%. На 1280 — 135,5 px до и
после, без изменений.

Шапка на узком экране сведена к одной строке (лого + «Для бизнеса»):

  - навигация по секциям скрыта — четыре пункта переносились в две строки, а
    на мобильном к секциям всё равно доезжают прокруткой; «Статьи» и «МЕРА для
    бизнеса» остаются ссылками в подвале, «Продажа под ключ» и на десктопе
    неактивная заглушка;

  - CTA скрыт — он вёл туда же, куда нижняя панель, и два одинаковых призыва
    на одном экране это и есть жалоба; панель осталась, шапка уступила.

Селектор города (CityPicker) удалён совсем, а не спрятан: он не владел своим
выбором — состояние жило внутри компонента и никуда не отправлялось, — тогда
как гео-гейт расчёта стоит на городе из формы (FreeCheckCard, #2576). Два
места, задающих одно значение, из которых работает одно, — дефект сам по себе;
то же правило уже записано в InnerHeader. Граница покрытия, ради которой
выпадашку открывали, стоит текстом в герое.

Нижняя панель на узком экране — только кнопка: её строка переносилась на две-
три и делала панель втрое выше кнопки. То же обещание остаётся словами героя.

Шаг медиазапроса — 1000 px, а не общий для файла 720: на 720 шапка макета всё
ещё переносится в две строки (замерено: 109 px хрома), то есть возвращать её
там значило бы вернуть тот же дефект планшету.
This commit is contained in:
bot-backend 2026-08-29 23:20:54 +05:00
parent 1f3d8d0b15
commit 5ed4a4ec25
5 changed files with 100 additions and 305 deletions

View file

@ -1,140 +0,0 @@
"use client";
/**
* CityPicker выбор города в шапке лэндинга v3 (макет ~ строки 47-73).
*
* Презентационный клиентский остров: сегодня публичный расчёт выключен
* (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь
* город никуда не отправляется состояние живёт только в этом компоненте и
* показывает посетителю, что сервис знает больше одного города области.
* Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора
* наружу (проп/контекст) в этом заходе scope ограничен шапкой/подвалом/
* sticky-баром, самой формы здесь нет.
*
* Список городов и деление на «полное покрытие» / «данных меньше» берутся из
* `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно
* объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса»
* по городам (тысячи объявлений) мы их не повторяем: таких цифр в коде нет,
* а гадать нельзя (правило честности в content.ts).
*/
import { useEffect, useId, useRef, useState } from "react";
import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry";
import { PRIMARY_CITY, REGION_NAME } from "../../content";
import styles from "../../landing-v3.module.css";
export function CityPicker() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [city, setCity] = useState<string>(DEFAULT_CITY.label);
const wrapRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelId = useId();
// Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx.
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// Escape — закрыть и вернуть фокус на кнопку.
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
const normalizedQuery = query.trim().toLowerCase();
const filtered = normalizedQuery
? OBLAST_CITIES.filter((c) =>
c.label.toLowerCase().includes(normalizedQuery),
)
: OBLAST_CITIES;
return (
<div className={styles.hdrCityWrap} ref={wrapRef}>
<button
ref={buttonRef}
type="button"
className={styles.hdrCityBtn}
onClick={() => setOpen((v) => !v)}
aria-haspopup="true"
aria-expanded={open}
aria-controls={panelId}
>
<span className={styles.hdrCityDot} aria-hidden="true" />
<span>{city}</span>
<span className={styles.hdrCityCaret} aria-hidden="true">
{open ? "▲" : "▼"}
</span>
</button>
{open ? (
<div
id={panelId}
className={styles.hdrCityPanel}
aria-label="Выбор города"
>
<div className={styles.hdrCitySearchRow}>
<input
type="text"
className={styles.hdrCityInput}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск города"
aria-label="Поиск города"
/>
</div>
<div className={styles.hdrCityList}>
{filtered.length > 0 ? (
filtered.map((c) => (
<button
key={c.id}
type="button"
className={styles.hdrCityItem}
data-active={c.label === city ? "true" : undefined}
onClick={() => {
setCity(c.label);
setOpen(false);
setQuery("");
buttonRef.current?.focus();
}}
>
<span className={styles.hdrCityItemName}>{c.label}</span>
<span className={styles.hdrCityItemNote}>
{c.label === PRIMARY_CITY
? "Полное покрытие"
: "Данных меньше"}
</span>
</button>
))
) : (
<p className={styles.hdrCityEmpty}>
Города нет в списке. Мы работаем там, где хватает данных по
сделкам напишите нам, и добавим.
</p>
)}
</div>
<div className={styles.hdrCityFooter}>
ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "}
{REGION_NAME.toUpperCase()}
</div>
</div>
) : null}
</div>
);
}

View file

@ -28,10 +28,11 @@
* Правило из шапки `lib/city-registry.ts`: город считается известным, только
* если человек выбрал его сам ИЛИ `detectCityInText` нашёл его в тексте
* адреса; молча подставлять Екатеринбург нельзя. Ответ про город вне покрытия
* тот же и дословно тот же, что в v1. Выпадашка города в шапке
* (`CityPicker`) сюда НЕ пробрасывается: она презентационная и своим выбором
* не владеет (см. её JSDoc), а гейт обязан читать то, что человек указал
* рядом с адресом.
* тот же и дословно тот же, что в v1. Выпадашки города в шапке больше нет
* вовсе (`CityPicker` удалён 29.08.2026, см. JSDoc HeaderV3): она была
* презентационной и своим выбором не владела, то есть значение задавалось в
* двух местах, а на расчёт влияло только это. Гейт обязан читать то, что
* человек указал рядом с адресом, и теперь это единственное такое место.
*
* СОСТОЯНИЕ ТЕПЕРЬ ДОЕЗЖАЕТ ДО ЧЕРНОВИКА. Поле было обязательным, а его
* значение никуда не клалось человек выбирал состояние квартиры, и мы его

View file

@ -1,10 +1,34 @@
/**
* HeaderV3 шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого +
* навигация + селектор города + CTA.
* навигация + CTA.
*
* Логотип НЕ ссылка: на публичном домене корень это сам лэндинг, то есть
* ссылка вела бы на текущую же страницу.
*
* СЕЛЕКТОРА ГОРОДА ЗДЕСЬ БОЛЬШЕ НЕТ (29.08.2026). `CityPicker` жил в этой
* шапке с тех пор, когда карточка проверки города не спрашивала вовсе, и
* своим выбором он НЕ владел: состояние оставалось внутри компонента и никуда
* не отправлялось (см. его прежний JSDoc). Город у формы появился
* `FreeCheckCard` спрашивает его сама и на нём стоит гео-гейт расчёта (#2576),
* и с этого момента шапка стала вторым местом, задающим одно значение, но
* ни на что не влияющим: человек выбирал в шапке Нижний Тагил и всё равно
* заполнял город в форме. Правило то же, по которому селектора нет во
* внутренней шапке (`_components/InnerHeader.tsx`). Граница покрытия, ради
* которой выпадашку когда-то читали, теперь стоит текстом в герое (плашка
* «полное покрытие / данных меньше», HeroV3), то есть видна без клика.
*
* НА УЗКОМ ЭКРАНЕ ШАПКА ОДНА СТРОКА: лого и «Для бизнеса». Навигация по
* секциям и CTA скрыты в CSS (`landing-v3.module.css`, блок шапки) и
* возвращаются с той ширины, на которой шапка макета замерена как
* однострочная, а не с общего для файла шага в 720px, где она всё ещё
* переносится. На 375px переносилось всё, и липкая шапка занимала 29%
* экрана эти проценты отъедались при любой прокрутке. Куда делись пункты: «Точность»
* и «Проверьте себя» секции этой же страницы, до них доезжают прокруткой
* (ради чего на мобильном и скроллят); «Статьи» и «МЕРА для бизнеса» стоят
* ссылками в подвале (`FooterV3`); «Продажа под ключ» и на десктопе не
* ссылка, а заглушка. CTA не потерян: снизу висит `StickyCtaV3` на экране
* остаётся ОДИН призыв вместо двух одинаковых.
*
* НЕАКТИВНЫЕ ПУНКТЫ (29.08.2026). Осталась одна «Продажа под ключ»
* партнёрской формы нет ни в коде, ни у владельца, и пункт остаётся видимым,
* но некликабельным. «Проверьте себя» и «Статьи» раньше стояли рядом с ней по
@ -21,16 +45,14 @@
* «Для бизнеса» ведёт на АБСОЛЮТНЫЙ адрес B2B-контура: относительный «/» на
* публичном домене вёл на сам лэндинг, то есть пункт не работал вовсе.
*
* Единственный клиентский остров `CityPicker` (открытие/закрытие, поиск).
* Сама шапка серверная.
* Шапка целиком серверная: клиентских островов в ней не осталось (последним
* был `CityPicker`).
*/
import { B2B_URL } from "../../content";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
import { CityPicker } from "./CityPicker";
export function HeaderV3() {
return (
<header className={styles.hdrRoot}>
@ -65,7 +87,6 @@ export function HeaderV3() {
</nav>
<div className={styles.hdrRight}>
<CityPicker />
<a href={B2B_URL} className={styles.hdrBizLink}>
Для бизнеса
</a>

View file

@ -15,6 +15,16 @@
* того, куда именно в дерево страницы его вставят (ожидаемое место сразу
* после подвала, как Header/Footer в текущем `layout.tsx`).
*
* НА УЗКОМ ЭКРАНЕ ПАНЕЛЬ ТОЛЬКО КНОПКА (29.08.2026). Строка «Узнайте цену
* своей квартиры» на 375px переносилась и панель занимала 13% экрана, а
* вместе с шапкой хром съедал 42% при любой прокрутке. Текст скрыт в CSS
* (`.barText`), а не удалён: с той же ширины, что возвращает шапку макета
* (1000px там строка и кнопка встают рядом), панель снова целиком. То же
* обещание на самой странице остаётся словами героя («Бесплатно: сколько
* похожих квартир продаётся рядом и как давно они висят»), то есть уходит
* повтор, а не мысль. Призыв на мобильном теперь ОДИН: `HeaderV3` свою
* кнопку на этой ширине не показывает.
*
* НА КНОПКЕ НЕТ СРОКА. Здесь стояло «Проверить за 30 секунд» замера времени
* заполнения формы нет и не было (та же причина, по которой «30 СЕКУНД» ушло
* из бейджа первого шага, см. `landing-facts.ts`). Панель висит на экране

View file

@ -17,14 +17,15 @@
*
* ------------------------------------------------------------------------
* ЭТОТ БЛОК (шапка / подвал / sticky-CTA) префиксы hdr / ftr / bar.
* Секция «Шапка» = HeaderV3.tsx + CityPicker.tsx.
* Секция «Шапка» = HeaderV3.tsx (селектор города из неё убран 29.08.2026:
* город спрашивает сама форма проверки, FreeCheckCard).
* Секция «Подвал» = FooterV3.tsx.
* Секция «Sticky CTA» = StickyCtaV3.tsx.
* ------------------------------------------------------------------------
*/
/* ==========================================================================
* Шапка (HeaderV3 + CityPicker)
* Шапка (HeaderV3)
* ========================================================================== */
.hdrRoot {
@ -40,11 +41,15 @@
border-bottom: 1px solid var(--b2c-line);
}
/* База узкий экран: в шапке остаются только лого и «Для бизнеса», они
помещаются в одну строку, поэтому и высота узкая (`flex-wrap` тут страховка,
а не режим работы). Навигация и CTA возвращаются в @media-блоке в конце
секции. */
.hdrInner {
max-width: 1280px;
margin-inline: auto;
padding: 10px clamp(20px, 5vw, 56px);
min-height: 64px;
padding: 8px clamp(20px, 5vw, 56px);
min-height: 52px;
display: flex;
align-items: center;
gap: 14px 26px;
@ -101,7 +106,10 @@
}
.hdrNav {
display: flex;
/* База узкий экран, где навигации по секциям НЕТ: четыре пункта
переносились в две строки и вместе с остальным делали липкую шапку в 29%
экрана. Куда делись пункты в шапке HeaderV3.tsx. */
display: none;
gap: 10px 22px;
flex-wrap: wrap;
font-size: 14.5px;
@ -119,8 +127,9 @@
color: var(--b2c-accent-text);
}
/* «Статьи» раздела нет в коде (ни маршрута, ни контента): пункт нарочно
неинтерактивен (aria-disabled, без href), а не ссылка в 404. */
/* «Продажа под ключ» раздела нет в коде (ни маршрута, ни контента): пункт
нарочно неинтерактивен (aria-disabled, без href), а не ссылка в 404.
«Статьи» стояли здесь же, пока не появился `articles/`. */
.hdrNavDisabled {
color: var(--b2c-muted);
opacity: 0.65;
@ -164,7 +173,11 @@
color: var(--b2c-on-accent);
}
/* На узком экране призыв в шапке скрыт: он вёл туда же, куда липкая нижняя
панель (`.barRoot`), и два одинаковых призыва на одном экране это ровно то
«много всплывашек», на которое жалуются. Панель остаётся, шапка отдаёт. */
.hdrCta {
display: none;
padding: 11px 22px;
font-size: 14px;
}
@ -174,146 +187,24 @@
filter: brightness(0.92);
}
/* -- Селектор города (CityPicker) ---------------------------------------- */
/* Шапка макета целиком с той ширины, на которой она РЕАЛЬНО влезает в одну
строку (замерено: 1000px лого, четыре пункта, «Для бизнеса» и кнопка;
на 720px то же самое переносится в две строки и даёт 109px липкого хрома,
то есть ровно тот дефект, от которого узкий экран и лечили). Отсюда шаг
1000, а не общий для файла 720. */
@media (min-width: 1000px) {
.hdrInner {
padding-block: 10px;
min-height: 64px;
}
.hdrCityWrap {
position: relative;
}
.hdrNav {
display: flex;
}
.hdrCityBtn {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: 1px solid var(--b2c-line);
border-radius: 6px;
padding: 9px 14px;
cursor: pointer;
font-family: var(--b2c-font-sans);
font-size: 14px;
font-weight: 700;
color: var(--b2c-ink);
}
.hdrCityBtn:hover {
border-color: var(--b2c-accent);
color: var(--b2c-accent-text);
}
.hdrCityDot {
width: 6px;
height: 6px;
flex: 0 0 auto;
display: block;
border-radius: 50%;
background: var(--b2c-accent);
}
.hdrCityCaret {
font-family: var(--b2c-font-mono);
font-size: 11px;
color: var(--b2c-muted);
}
.hdrCityPanel {
position: absolute;
top: calc(100% + 10px);
left: 0;
z-index: 80;
width: min(84vw, 340px);
max-width: calc(100vw - 40px);
overflow: hidden;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 10px;
box-shadow: 0 24px 64px color-mix(in srgb, var(--b2c-ink) 18%, transparent);
}
.hdrCitySearchRow {
padding: 14px 16px;
border-bottom: 1px solid var(--b2c-line-soft);
}
.hdrCityInput {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--b2c-line);
border-radius: 6px;
padding: 11px 14px;
font-family: var(--b2c-font-sans);
font-size: 14.5px;
color: var(--b2c-ink);
background: var(--b2c-page-bg);
outline: none;
}
.hdrCityInput:focus {
border-color: var(--b2c-accent);
background: var(--b2c-surface);
}
.hdrCityList {
max-height: 300px;
overflow-y: auto;
padding: 6px 0;
}
.hdrCityItem {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: none;
border: none;
padding: 11px 18px;
text-align: left;
cursor: pointer;
font-family: var(--b2c-font-sans);
}
.hdrCityItem:hover {
background: var(--b2c-page-bg);
}
/* Текущий выбранный город (проставляется из CityPicker.tsx как data-атрибут,
не класс состояние живёт в JS, а не в CSS-модуле). */
.hdrCityItem[data-active="true"] {
background: var(--b2c-surface-soft);
}
.hdrCityItem[data-active="true"] .hdrCityItemName {
font-weight: 800;
}
.hdrCityItemName {
font-size: 14.5px;
font-weight: 600;
color: var(--b2c-ink);
}
.hdrCityItemNote {
font-family: var(--b2c-font-mono);
font-size: 10.5px;
color: var(--b2c-muted);
white-space: nowrap;
}
.hdrCityEmpty {
margin: 0;
padding: 22px 18px;
font-size: 14px;
line-height: 1.55;
color: var(--b2c-muted);
}
.hdrCityFooter {
padding: 12px 18px;
border-top: 1px solid var(--b2c-line-soft);
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--b2c-muted);
.hdrCta {
display: inline-flex;
}
}
/* ==========================================================================
@ -472,16 +363,10 @@
/* Компенсация fixed-позиционирования `.barRoot` (тот вырезан из потока
документа): без спейсера панель легла бы поверх последних пикселей
страницы при докрутке до конца (реквизиты в подвале, последний вопрос
FAQ). Высота на мобильном тексте+кнопка чаще переносятся на две строки
(flex-wrap), поэтому база выше; на 720px+ строка одна. */
FAQ). Высота одна на все ширины: панель везде в одну строку на узком
экране за счёт того, что текст скрыт (см. `.barText`), а не переносится. */
.barSpacer {
height: 96px;
}
@media (min-width: 720px) {
.barSpacer {
height: 68px;
}
height: 68px;
}
.barRoot {
@ -495,14 +380,20 @@
justify-content: center;
gap: 16px 24px;
flex-wrap: wrap;
padding: 12px clamp(16px, 4vw, 32px);
padding: 10px clamp(16px, 4vw, 32px);
/* Фоллбэк на непрозрачный surfaceDark для браузеров без color-mix(). */
background: var(--b2c-surface-dark);
background: color-mix(in srgb, var(--b2c-surface-dark) 97%, transparent);
backdrop-filter: blur(8px);
}
/* На узком экране панель только кнопка. Строка «Узнайте цену своей
квартиры» переносилась на две-три строки и делала постоянную панель втрое
выше кнопки; то же самое сказано словами в герое (заголовок, лид и перк
«Бесплатно: сколько похожих квартир продаётся рядом»), то есть со страницы
мысль не пропадает пропадает её третий повтор поверх контента. */
.barText {
display: none;
margin: 0;
text-align: center;
font-weight: 700;
@ -518,6 +409,18 @@
font-size: 14.5px;
}
/* Панель макета целиком с той же ширины, что и шапка (1000px): ниже неё
текст и кнопка тоже переносились, и панель становилась вдвое выше. */
@media (min-width: 1000px) {
.barRoot {
padding-block: 12px;
}
.barText {
display: block;
}
}
/* ==========================================================================
* HERO (hero*) первый экран: герой-текст + карточка «Бесплатная проверка».
* Секция «Герой» = HeroV3.tsx + FreeCheckCard.tsx + FreeResultV3.tsx.