fix(checkups): preserve audience and return to list on browser Back
This commit is contained in:
parent
cb387e1f71
commit
c49407047b
77
web/scripts/test-checkup-back.mjs
Normal file
77
web/scripts/test-checkup-back.mjs
Normal file
@ -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('<html><body><div id="root"></div></body></html>');
|
||||||
|
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();
|
||||||
|
}
|
||||||
@ -11,6 +11,15 @@ import { getConsent, hit, startMetrika } from '../lib/metrika.js';
|
|||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { pathname, search, state } = useLocation();
|
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(() => {
|
useEffect(() => {
|
||||||
if (getConsent() === 'all') startMetrika();
|
if (getConsent() === 'all') startMetrika();
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useLocation, useNavigationType, useSearchParams } from 'react-router-dom';
|
||||||
import heroImage from '../assets/figma/checkups-hero-3d.webp';
|
import heroImage from '../assets/figma/checkups-hero-3d.webp';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import Breadcrumbs from '../components/Breadcrumbs.jsx';
|
import Breadcrumbs from '../components/Breadcrumbs.jsx';
|
||||||
@ -30,12 +30,29 @@ export default function CheckupsPage() {
|
|||||||
usePageTitle('Чек-апы');
|
usePageTitle('Чек-апы');
|
||||||
const { open } = useLeadModal();
|
const { open } = useLeadModal();
|
||||||
const checkups = useApi((signal) => api.checkups({ signal }), [], 'checkups');
|
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 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 (
|
return (
|
||||||
<div className="checkups-page">
|
<div className="checkups-page">
|
||||||
@ -56,7 +73,7 @@ export default function CheckupsPage() {
|
|||||||
|
|
||||||
<section className="checkups-content">
|
<section className="checkups-content">
|
||||||
<div className="container checkups-content__inner">
|
<div className="container checkups-content__inner">
|
||||||
<h2 className="checkups-content__title">Программы чек-апов</h2>
|
<h2 ref={listStart} className="checkups-content__title" style={{ scrollMarginTop: 24 }}>Программы чек-апов</h2>
|
||||||
<p className="checkups-content__text">
|
<p className="checkups-content__text">
|
||||||
Выберите программу по возрасту и задаче. Полный состав обследования и актуальную стоимость уточнит
|
Выберите программу по возрасту и задаче. Полный состав обследования и актуальную стоимость уточнит
|
||||||
администратор.
|
администратор.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user