From 47df1a9020934d883e557621c984145f3de94bdf Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 14:13:36 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BB=D0=B8=D0=BF=D0=BA=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=BF=D1=80=D1=8F=D1=87=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F,=20=D0=BF=D0=BE=D0=BA=D0=B0=20=D0=B3=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=20=D0=B2=20=D0=BA=D0=B0=D0=B4=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец увидел на первом экране, что полоса «Проверить квартиру» лежит поверх абзаца «Мы называем реальную цену вашей квартиры». .barSpacer это НЕ лечит и не может: он добавляет высоту в КОНЕЦ документа, а .barRoot — fixed, то есть накрывает нижние ~67px вьюпорта при ЛЮБОЙ прокрутке, включая scrollTop=0. Разобравший это агент так и написал и не стал чинить молча. Пока герой виден, панель не нужна: прямо в нём стоит та же форма проверки, и полоса дублирует призыв, закрывая текст. Уходит герой — панель появляется. ПО УМОЛЧАНИЮ ПАНЕЛЬ ВИДИМА. Скрытие включает только клиентский наблюдатель; если скрипт не выполнился, поведение остаётся ровно сегодняшним. Обратный порядок (скрыта, показывает скрипт) в тех же условиях убрал бы призыв со страницы совсем — то есть чинил бы вид ценой работы. Нет секции героя — наблюдать нечего, панель остаётся видимой, а не пропадает. Убирается transform-ом, а не display:none: transform идёт на композиторе и не вызывает перекладку. Страница и так дорога в отрисовке на мобильном (1824 мс в Style & Layout), и лечение перекрытия не должно стоить пересчёта макета на каждом пересечении границы героя. Убранная панель уходит и из дерева доступности (aria-hidden + inert): иначе Tab уводит в кнопку, которой на экране нет. Тест проверяет ПОВЕДЕНИЕ, а не наличие класса в файле: наблюдателю скармливается пересечение. Фальсификация обеих сторон — панель не прячется («expected false to be true») и панель скрыта по умолчанию («до срабатывания панель видима»). 150 passed, eslint чисто. --- .../__tests__/sticky-cta-hero.test.tsx | 92 +++++++++++++++++++ .../_components/v3/StickyCtaV3.tsx | 51 +++++++++- .../src/app/mera-public/landing-v3.module.css | 18 ++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/sticky-cta-hero.test.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/sticky-cta-hero.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/sticky-cta-hero.test.tsx new file mode 100644 index 00000000..e14c8459 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/sticky-cta-hero.test.tsx @@ -0,0 +1,92 @@ +/** + * Липкая панель не должна лежать поверх первого экрана. + * + * Владелец увидел на проде, что полоса накрывает абзац «Мы называем реальную + * цену вашей квартиры». `.barSpacer` это не лечит и не может: он добавляет + * высоту в КОНЕЦ документа, а `.barRoot` — `fixed`, то есть накрывает нижние + * ~67px вьюпорта при любой прокрутке, включая самый верх страницы. + * + * Проверяется ПОВЕДЕНИЕ, а не наличие класса в файле: наблюдателю скармливается + * пересечение и проверяется, что панель убралась, затем обратное. + */ +import { render, screen } from "@testing-library/react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { StickyCtaV3 } from "../_components/v3/StickyCtaV3"; + +let срабатывание: ((entries: { isIntersecting: boolean }[]) => void) | null = null; + +beforeEach(() => { + срабатывание = null; + vi.stubGlobal( + "IntersectionObserver", + class { + constructor(cb: (entries: { isIntersecting: boolean }[]) => void) { + срабатывание = cb; + } + observe() {} + disconnect() {} + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** + * Корень панели ищем ПО КОНТЕЙНЕРУ, а не через getByRole: когда панель убрана, + * на ней стоит aria-hidden, и роль «ссылка» из дерева доступности пропадает — + * это и есть желаемое поведение, но локатор по роли на нём слепнет. + */ +function корень(c: HTMLElement): HTMLElement { + const link = c.querySelector('a[href*="estimate"]'); + if (!link?.parentElement) throw new Error("панель не отрендерилась"); + return link.parentElement; +} + +const скрыта = (c: HTMLElement) => /barHidden/u.test(корень(c).className); + +describe("липкая панель против первого экрана", () => { + it("без секции героя панель остаётся видимой, а не пропадает", () => { + // Наблюдать нечего — поведение обязано остаться сегодняшним. + const { container } = render(); + expect(скрыта(container)).toBe(false); + }); + + it("герой в кадре — панель убрана и недоступна с клавиатуры", () => { + const hero = document.createElement("section"); + hero.id = "check"; + document.body.appendChild(hero); + const { container } = render(); + + expect(скрыта(container), "до срабатывания панель видима").toBe(false); + + act(() => срабатывание!([{ isIntersecting: true }])); + expect(скрыта(container)).toBe(true); + expect(корень(container).getAttribute("aria-hidden")).toBe("true"); + + // Убранная панель обязана уйти и из дерева доступности — иначе Tab + // уводит в кнопку, которой на экране нет. + expect(screen.queryByRole("link", { name: /Проверить квартиру/u })).toBeNull(); + + hero.remove(); + }); + + it("герой ушёл — панель возвращается", () => { + const hero = document.createElement("section"); + hero.id = "check"; + document.body.appendChild(hero); + const { container } = render(); + + act(() => срабатывание!([{ isIntersecting: true }])); + expect(скрыта(container)).toBe(true); + + act(() => срабатывание!([{ isIntersecting: false }])); + expect(скрыта(container), "панель не вернулась — призыв потерян навсегда").toBe(false); + expect(screen.getByRole("link", { name: /Проверить квартиру/u })).toBeTruthy(); + + hero.remove(); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx index 21f1be7d..87e8dc4e 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx @@ -1,3 +1,5 @@ +"use client"; + /** * StickyCtaV3 — липкая нижняя CTA-панель (макет ~ строки 518-521). * @@ -32,17 +34,64 @@ * чаще всего. В тексте панели по той же причине нет и «срока продажи»: * платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз * срока (`_estimate_days_on_market`, см. HeroV3). + * + * ПАНЕЛЬ ПРЯЧЕТСЯ, ПОКА ГЕРОЙ В КАДРЕ (31.08.2026). Владелец увидел на первом + * экране, что полоса лежит поверх абзаца «Мы называем реальную цену вашей + * квартиры». `.barSpacer` это НЕ лечит и не может: он добавляет высоту в + * КОНЕЦ документа, а `.barRoot` — `fixed`, то есть накрывает нижние ~67px + * вьюпорта при ЛЮБОЙ прокрутке, включая `scrollTop = 0`. + * + * Пока герой виден, панель не нужна: прямо в нём стоит та же форма проверки, + * и панель дублирует призыв, закрывая текст. Уходит герой — панель появляется. + * + * ПО УМОЛЧАНИЮ ПАНЕЛЬ ВИДИМА, и это важно. Скрытие включает только клиентский + * наблюдатель; если скрипт не выполнился (ошибка, старый браузер, отключённый + * JS), поведение остаётся ровно сегодняшним — полоса на месте. Обратный + * порядок (по умолчанию скрыта, показывает скрипт) в тех же условиях убрал бы + * призыв со страницы совсем, то есть чинил бы вид ценой работы. + * + * Наблюдатель смотрит на `#check` — секцию героя (`HeroV3`). Если её на + * странице нет (компонент вставили в другую композицию), наблюдать нечего и + * панель просто остаётся видимой: снова сегодняшнее поведение, а не пустой + * экран. */ +import { useEffect, useState } from "react"; + import styles from "../../landing-v3.module.css"; import { PublicLink } from "../PublicLink"; +/** Секция героя: пока она в кадре, панель молчит. */ +const HERO_ID = "check"; + export function StickyCtaV3() { + const [heroVisible, setHeroVisible] = useState(false); + + useEffect(() => { + const hero = document.getElementById(HERO_ID); + if (!hero || typeof IntersectionObserver === "undefined") return; + + const io = new IntersectionObserver( + ([entry]) => setHeroVisible(entry.isIntersecting), + // Панель высотой ~67px снизу: считаем героя «в кадре», пока он заходит + // хотя бы на эту полосу — иначе она успевала бы мигнуть на его хвосте. + { rootMargin: "0px 0px -68px 0px" }, + ); + io.observe(hero); + return () => io.disconnect(); + }, []); + return ( <>