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 ( <>