fix(booking): use ophthalmologists for pediatric eye consultation

This commit is contained in:
Dana Medical Developer 2026-10-22 14:16:59 +00:00
parent 0bca60ed0b
commit d791ea4d12
4 changed files with 78 additions and 7 deletions

View File

@ -0,0 +1,62 @@
// Fictional doctors only; no external network or appointment submissions.
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/services/detskaya-klinika/priem-detskogo-oftalmologa/');
document.oninput = null;
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { MemoryRouter, Routes, Route } = await import('react-router-dom');
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
const { api } = await vite.ssrLoadModule('/src/api.js');
const { default: ServicePage } = await vite.ssrLoadModule('/src/pages/ServicePage.jsx');
const { LeadModalProvider } = await vite.ssrLoadModule('/src/context/LeadModalContext.jsx');
const { serviceSpecialty } = await vite.ssrLoadModule('/src/lib/media.js');
const { renderService, renderPage } = await vite.ssrLoadModule('/src/entry-server.jsx');
const path = 'detskaya-klinika/priem-detskogo-oftalmologa';
const service = { id: 17, path, slug: 'priem-detskogo-oftalmologa', name: 'Тестовый детский офтальмолог', doctors: [], blocks: [], seo: {} };
const parent = { id: 1, path: 'detskaya-klinika', name: 'Детская клиника', children: [service], blocks: [], seo: {} };
const doctors = [
{ id: 1, slug: 'eye-test', full_name: 'Тестовый офтальмолог', last_name: 'Тестовый', first_name: 'Офтальмолог', position: 'Офтальмолог', specialties: [{ slug: 'oftalmologi', name: 'Офтальмологи', name_plural: 'Офтальмологи' }], booking: { provider: 'none' } },
{ id: 2, slug: 'uzi-test', full_name: 'Посторонний врач УЗИ', last_name: 'Посторонний', first_name: 'Врач', position: 'Врач УЗИ', specialties: [{ slug: 'detskie-vrachi', name: 'Детские врачи' }], booking: { provider: 'none' } },
];
const requested = [];
api.service = async (requestedPath) => ({ data: requestedPath === path ? service : parent });
api.prices = async () => ({ data: [] });
api.specialties = async () => ({ data: [{ slug: 'oftalmologi', name: 'Офтальмологи' }] });
api.doctors = async (params = {}) => {
requested.push(params.specialty);
return { data: doctors.filter((doctor) => !params.specialty || doctor.specialties.some((s) => s.slug === params.specialty)) };
};
api.bookingAvailability = async () => ({ data: { enabled: true, days: [] } });
api.createLead = api.createAppointment = () => { throw new Error('Submissions forbidden'); };
const container = document.getElementById('root');
const root = createRoot(container);
const click = async (node) => act(async () => node.dispatchEvent(new window.Event('click', { bubbles: true })));
try {
assert.equal(serviceSpecialty(path), 'oftalmologi');
assert.equal(serviceSpecialty('detskaya-klinika'), 'detskie-vrachi');
assert.equal(serviceSpecialty('oko'), 'oftalmologi');
assert.equal(serviceSpecialty('ginekologiya/test'), 'ginekologi');
assert.equal(serviceSpecialty('unknown/test'), undefined);
await act(async () => root.render(React.createElement(MemoryRouter, { initialEntries: [`/services/${path}/`] },
React.createElement(LeadModalProvider, {}, React.createElement(Routes, {}, React.createElement(Route, { path: '/services/*', element: React.createElement(ServicePage) }))))));
assert.match(container.querySelector('.service-doctors').textContent, /Тестовый офтальмолог/);
assert.doesNotMatch(container.querySelector('.service-doctors').textContent, /Посторонний/);
await click(container.querySelector('button.service-hero__btn'));
await click(container.querySelector('.doctor-picker__trigger'));
assert.deepEqual([...container.querySelectorAll('[data-doctor-id]')].map((node) => node.dataset.doctorId), ['1']);
assert.ok(requested.length >= 2);
assert.ok(requested.every((specialty) => specialty === 'oftalmologi'), 'Both service cards and booking must request ophthalmologists');
const common = { doctors, specialties: [], prices: [], site: { branches: [], addresses: [], phone: '', hours: '' } };
const server = renderService(service, parent, common, 'https://test.invalid');
assert.deepEqual(JSON.parse(server.initial)['doctors:oftalmologi'].data.map((d) => d.id), [1]);
const runtime = renderPage(`/services/${path}/`, { [`service:${path}`]: { data: service }, doctors: { data: doctors } }, 'https://test.invalid');
assert.deepEqual(JSON.parse(runtime.initial)['doctors:oftalmologi'].data.map((d) => d.id), [1]);
console.log('PASS: service doctor cards, booking picker, runtime SSR and prerender use the specific ophthalmology specialty');
} finally {
await act(async () => root.unmount());
await vite.close();
}

View File

@ -4,7 +4,7 @@ import App from './App.jsx';
import { ApiDataProvider } from './context/ApiData.jsx'; import { ApiDataProvider } from './context/ApiData.jsx';
import { parseBlocks } from './lib/serviceContent.js'; import { parseBlocks } from './lib/serviceContent.js';
import { serviceMetadata, doctorMetadata, priceMetadata, pageMetadata, jsonForHTML } from './lib/seo.js'; import { serviceMetadata, doctorMetadata, priceMetadata, pageMetadata, jsonForHTML } from './lib/seo.js';
import { DIRECTION_MEDIA, directionOf } from './lib/media.js'; import { serviceSpecialty } from './lib/media.js';
export { parseBlocks }; export { parseBlocks };
export function siteFromCommon(common) { export function siteFromCommon(common) {
@ -27,7 +27,7 @@ export function renderPage(url, data, siteURL) {
const service = initial[`service:${servicePath}`]?.data; const service = initial[`service:${servicePath}`]?.data;
if (service) { if (service) {
service.content_sections = parseBlocks(service.blocks); service.content_sections = parseBlocks(service.blocks);
const specialty = DIRECTION_MEDIA[directionOf(service.path)]?.specialty; const specialty = serviceSpecialty(service.path);
initial[`doctors:${specialty}`] = { data: (initial.doctors?.data || []).filter((d) => d.specialties?.some((s) => s.slug === specialty)), meta: {} }; initial[`doctors:${specialty}`] = { data: (initial.doctors?.data || []).filter((d) => d.specialties?.some((s) => s.slug === specialty)), meta: {} };
} }
const parts = pathname.split('/').filter(Boolean); const parts = pathname.split('/').filter(Boolean);
@ -82,7 +82,7 @@ export function renderPage(url, data, siteURL) {
export function renderService(service, parent, common, siteURL) { export function renderService(service, parent, common, siteURL) {
service.content_sections = parseBlocks(service.blocks); service.content_sections = parseBlocks(service.blocks);
const specialty = DIRECTION_MEDIA[directionOf(service.path)]?.specialty; const specialty = serviceSpecialty(service.path);
const initial = { const initial = {
prerendered: true, siteURL, site: common.site, prerendered: true, siteURL, site: common.site,
[`service:${service.path}`]: { data: service, meta: {} }, [`service:${service.path}`]: { data: service, meta: {} },

View File

@ -91,6 +91,15 @@ export function directionOf(path) {
return String(path || '').split('/')[0]; return String(path || '').split('/')[0];
} }
// Узкий профиль услуги важнее широкого раздела «Детская клиника».
// Только явные соответствия каталогу: не угадываем специальность по тексту.
const SERVICE_SPECIALTIES = {
'detskaya-klinika/priem-detskogo-oftalmologa': 'oftalmologi',
};
export function serviceSpecialty(path) {
return SERVICE_SPECIALTIES[path] || DIRECTION_MEDIA[directionOf(path)]?.specialty;
}
// Фото, загруженное через админку (/media/uploads/…), главнее картинки макета — как у чек-апов. // Фото, загруженное через админку (/media/uploads/…), главнее картинки макета — как у чек-апов.
// Старые картинки, перенесённые из Битрикса (/media/iblock/…), макет не перебивают. // Старые картинки, перенесённые из Битрикса (/media/iblock/…), макет не перебивают.
const isUploaded = (src) => typeof src === 'string' && src.includes('/media/uploads/'); const isUploaded = (src) => typeof src === 'string' && src.includes('/media/uploads/');

View File

@ -11,7 +11,7 @@ import { useSiteData } from '../context/SiteData.jsx';
import { priceLabel, telHref } from '../lib/format.js'; import { priceLabel, telHref } from '../lib/format.js';
import { servicePath, specialtyPath, priceSectionPath } from '../lib/links.js'; import { servicePath, specialtyPath, priceSectionPath } from '../lib/links.js';
import { servicePriceSection } from '../lib/serviceNavigation.js'; import { servicePriceSection } from '../lib/serviceNavigation.js';
import { DIRECTION_MEDIA, SERVICE_IMAGES, directionOf } from '../lib/media.js'; import { DIRECTION_MEDIA, SERVICE_IMAGES, directionOf, serviceSpecialty } from '../lib/media.js';
import { URGENT_TITLE, parseBlocks } from '../lib/serviceContent.js'; import { URGENT_TITLE, parseBlocks } from '../lib/serviceContent.js';
import { cleanText } from '../lib/text.js'; import { cleanText } from '../lib/text.js';
import { useApi } from '../lib/useApi.js'; import { useApi } from '../lib/useApi.js';
@ -76,7 +76,7 @@ function DirectionView({ direction }) {
className="btn btn--coral direction-card__btn" className="btn btn--coral direction-card__btn"
onClick={() => open({ onClick={() => open({
form: 'appointment', service: c, message: `Услуга: ${c.name}`, form: 'appointment', service: c, message: `Услуга: ${c.name}`,
specialty: DIRECTION_MEDIA[directionOf(direction.path)]?.specialty, specialty: serviceSpecialty(c.path),
})} })}
> >
Записаться Записаться
@ -106,7 +106,7 @@ function ServiceView({ service, parent }) {
const media = DIRECTION_MEDIA[directionOf(service.path)]; const media = DIRECTION_MEDIA[directionOf(service.path)];
const image = SERVICE_IMAGES[service.path] || service.image || (!parent ? media?.image : ''); const image = SERVICE_IMAGES[service.path] || service.image || (!parent ? media?.image : '');
const lead = cleanText(service.lead); const lead = cleanText(service.lead);
const book = () => open({ form: 'appointment', service, specialty: media?.specialty, message: `Услуга: ${service.name}` }); const book = () => open({ form: 'appointment', service, specialty: serviceSpecialty(service.path), message: `Услуга: ${service.name}` });
const crumbs = [ const crumbs = [
{ to: '/', label: 'Главная' }, { to: '/', label: 'Главная' },
@ -165,7 +165,7 @@ function ServiceView({ service, parent }) {
{(service.prices?.length > 0 || service.path.startsWith('estetic/') || priceSection) && <PricesSection prices={service.prices || []} service={service} priceSection={priceSection} />} {(service.prices?.length > 0 || service.path.startsWith('estetic/') || priceSection) && <PricesSection prices={service.prices || []} service={service} priceSection={priceSection} />}
<ServiceDoctors service={service} specialtySlug={media?.specialty} /> <ServiceDoctors service={service} specialtySlug={serviceSpecialty(service.path)} />
<BookingSection service={service} /> <BookingSection service={service} />
<script id="service-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonForHTML(serviceSchema(service, parent, site, siteURL, <script id="service-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonForHTML(serviceSchema(service, parent, site, siteURL,
(service.doctors || []).filter((d) => !d.absent).sort((a, b) => Number(Boolean(b.photo)) - Number(Boolean(a.photo))).slice(0, 4))) }} /> (service.doctors || []).filter((d) => !d.absent).sort((a, b) => Number(Boolean(b.photo)) - Number(Boolean(a.photo))).slice(0, 4))) }} />