diff --git a/web/scripts/test-checkup-back.mjs b/web/scripts/test-checkup-back.mjs new file mode 100644 index 0000000..5c8633b --- /dev/null +++ b/web/scripts/test-checkup-back.mjs @@ -0,0 +1,77 @@ +// In-memory browser history and fictional programs only. No network calls. +import assert from 'node:assert/strict'; +import { parseHTML } from 'linkedom'; +import { createServer } from 'vite'; +const { window } = parseHTML('
'); +Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); +window.location = new URL('https://test.invalid/checkup/'); +window.history = { scrollRestoration: 'auto' }; +const scrolls = []; +window.scrollTo = (...args) => scrolls.push(['top', ...args]); +window.HTMLElement.prototype.scrollIntoView = function(options) { scrolls.push(['list', this.textContent, options]); }; +const { default: React, act } = await import('react'); +const { createRoot } = await import('react-dom/client'); +const { Simulate } = await import('react-dom/test-utils'); +const { MemoryRouter, Routes, Route, useNavigate, useLocation } = await import('react-router-dom'); +const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' }); +const { default: Layout } = await vite.ssrLoadModule('/src/components/Layout.jsx'); +const { default: CheckupsPage } = await vite.ssrLoadModule('/src/pages/CheckupsPage.jsx'); +const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx'); +const { api } = await vite.ssrLoadModule('/src/api.js'); +const programs = [{ id: 1, slug: 'child', name: 'Детский тест', audience: 'kids' }, { id: 2, slug: 'adult', name: 'Взрослый тест', audience: 'adults' }]; +api.checkups = async () => ({ data: programs }); +api.branches = api.settings = async () => ({ data: [] }); +let navigate; +let currentLocation; +function HistoryProbe() { navigate = useNavigate(); currentLocation = useLocation(); return null; } +const container = document.getElementById('root'); +const root = createRoot(container); +const click = async (node) => { assert.ok(node); await act(async () => Simulate.click(node, { button: 0 })); }; +async function mount(url) { + await act(async () => root.render(React.createElement(MemoryRouter, { key: url, initialEntries: ['/previous/', url] }, + React.createElement(ApiDataProvider, { value: {} }, React.createElement(HistoryProbe), React.createElement(Routes, {}, + React.createElement(Route, { element: React.createElement(Layout) }, + React.createElement(Route, { path: '/checkup/', element: React.createElement(CheckupsPage) }), + React.createElement(Route, { path: '/checkup/:slug/', element: React.createElement('p', {}, 'Detail page') }), + React.createElement(Route, { path: '/previous/', element: React.createElement('p', {}, 'Previous page') }))))))); +} +const selected = () => container.querySelector('[role="tab"][aria-selected="true"]').textContent; +try { + await mount('/checkup/'); + assert.equal(window.history.scrollRestoration, 'manual'); + assert.equal(selected(), 'Взрослые'); + scrolls.length = 0; + await click([...container.querySelectorAll('[role="tab"]')].find((node) => node.textContent === 'Детские')); + assert.equal(selected(), 'Детские'); + assert.equal(currentLocation.search, '?audience=kids'); + assert.equal(scrolls.length, 0, 'Tab selection must not jump'); + await click(container.querySelector('.checkup-card__more')); + assert.match(container.textContent, /Detail page/); + assert.ok(scrolls.some((s) => s[0] === 'top'), 'New detail page starts at top'); + scrolls.length = 0; + await act(async () => navigate(-1)); + assert.equal(selected(), 'Детские'); + assert.match(container.querySelector('.checkup-grid').textContent, /Детский тест/); + assert.equal(scrolls.length, 1); + assert.equal(scrolls[0][0], 'list'); + assert.equal(scrolls[0][1], 'Программы чек-апов'); + await act(async () => navigate(1)); + scrolls.length = 0; + await act(async () => navigate(-1)); + assert.equal(selected(), 'Детские'); + assert.equal(scrolls[0][0], 'list'); + await act(async () => navigate(-1)); + assert.equal(currentLocation.pathname, '/previous/', 'Tab changes do not pollute history'); + await mount('/checkup/?audience=kids'); + assert.equal(selected(), 'Детские', 'Direct links and reloads preserve audience'); + await click([...container.querySelectorAll('[role="tab"]')].find((node) => node.textContent === 'Взрослые')); + assert.equal(currentLocation.search, ''); + await click(container.querySelector('.checkup-card__more')); + await act(async () => navigate(-1)); + assert.equal(selected(), 'Взрослые'); + console.log('PASS: kids/adult tab persists, Back returns to list heading, Forward/Back works, no extra tab history, new details start at top'); +} finally { + await act(async () => root.unmount()); + assert.equal(window.history.scrollRestoration, 'auto', 'Unmount restores browser policy'); + await vite.close(); +} diff --git a/web/src/components/Layout.jsx b/web/src/components/Layout.jsx index 3519c14..2ecf18c 100644 --- a/web/src/components/Layout.jsx +++ b/web/src/components/Layout.jsx @@ -11,6 +11,15 @@ import { getConsent, hit, startMetrika } from '../lib/metrika.js'; export default function Layout() { const { pathname, search, state } = useLocation(); + // Иначе браузер после нашего scrollTo/scrollIntoView восстанавливает старую + // позицию документа и при «Назад» снова уводит посетителя к низу страницы. + useEffect(() => { + if (!window.history || !('scrollRestoration' in window.history)) return undefined; + const previous = window.history.scrollRestoration; + window.history.scrollRestoration = 'manual'; + return () => { window.history.scrollRestoration = previous; }; + }, []); + // Метрика — только если посетитель уже согласился раньше; иначе ждём выбора в баннере. useEffect(() => { if (getConsent() === 'all') startMetrika(); diff --git a/web/src/pages/CheckupsPage.jsx b/web/src/pages/CheckupsPage.jsx index c94675e..f4400b2 100644 --- a/web/src/pages/CheckupsPage.jsx +++ b/web/src/pages/CheckupsPage.jsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; +import { useEffect, useRef } from 'react'; +import { Link, useLocation, useNavigationType, useSearchParams } from 'react-router-dom'; import heroImage from '../assets/figma/checkups-hero-3d.webp'; import { api } from '../api.js'; import Breadcrumbs from '../components/Breadcrumbs.jsx'; @@ -30,12 +30,29 @@ export default function CheckupsPage() { usePageTitle('Чек-апы'); const { open } = useLeadModal(); const checkups = useApi((signal) => api.checkups({ signal }), [], 'checkups'); - const [active, setActive] = useState('adults'); + const [params, setParams] = useSearchParams(); + const location = useLocation(); + const navigationType = useNavigationType(); + const listStart = useRef(null); + const active = params.get('audience') === 'kids' ? 'kids' : 'adults'; + + useEffect(() => { + if (navigationType === 'POP' && location.state?.checkupList) { + listStart.current?.scrollIntoView({ block: 'start', behavior: 'instant' }); + } + }, [location.key, navigationType]); const pick = () => open({ form: 'callback', message: 'Помогите подобрать чек-ап' }); // вкладки переключают группу: показываются программы только для взрослых или только детские - const selectTab = (id) => setActive(id); + const selectTab = (id) => { + const next = new URLSearchParams(params); + if (id === 'kids') next.set('audience', 'kids'); + else next.delete('audience'); + // Меняем текущую запись истории, чтобы «Назад» возвращал к списку, + // а не перебирал ранее выбранные вкладки. + setParams(next, { replace: true, state: { ...location.state, keepScroll: true, checkupList: true } }); + }; return (Выберите программу по возрасту и задаче. Полный состав обследования и актуальную стоимость уточнит администратор.