From d791ea4d126174b42d19c225172bf7a41fce6d11 Mon Sep 17 00:00:00 2001 From: Dana Medical Developer Date: Thu, 22 Oct 2026 14:16:59 +0000 Subject: [PATCH] fix(booking): use ophthalmologists for pediatric eye consultation --- web/scripts/test-service-specialty.mjs | 62 ++++++++++++++++++++++++++ web/src/entry-server.jsx | 6 +-- web/src/lib/media.js | 9 ++++ web/src/pages/ServicePage.jsx | 8 ++-- 4 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 web/scripts/test-service-specialty.mjs diff --git a/web/scripts/test-service-specialty.mjs b/web/scripts/test-service-specialty.mjs new file mode 100644 index 0000000..866b2e0 --- /dev/null +++ b/web/scripts/test-service-specialty.mjs @@ -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('
'); +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(); +} diff --git a/web/src/entry-server.jsx b/web/src/entry-server.jsx index 1d11011..1f84783 100644 --- a/web/src/entry-server.jsx +++ b/web/src/entry-server.jsx @@ -4,7 +4,7 @@ import App from './App.jsx'; import { ApiDataProvider } from './context/ApiData.jsx'; import { parseBlocks } from './lib/serviceContent.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 function siteFromCommon(common) { @@ -27,7 +27,7 @@ export function renderPage(url, data, siteURL) { const service = initial[`service:${servicePath}`]?.data; if (service) { 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: {} }; } const parts = pathname.split('/').filter(Boolean); @@ -82,7 +82,7 @@ export function renderPage(url, data, siteURL) { export function renderService(service, parent, common, siteURL) { service.content_sections = parseBlocks(service.blocks); - const specialty = DIRECTION_MEDIA[directionOf(service.path)]?.specialty; + const specialty = serviceSpecialty(service.path); const initial = { prerendered: true, siteURL, site: common.site, [`service:${service.path}`]: { data: service, meta: {} }, diff --git a/web/src/lib/media.js b/web/src/lib/media.js index 7417cb8..6dc503f 100644 --- a/web/src/lib/media.js +++ b/web/src/lib/media.js @@ -91,6 +91,15 @@ export function directionOf(path) { 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/iblock/…), макет не перебивают. const isUploaded = (src) => typeof src === 'string' && src.includes('/media/uploads/'); diff --git a/web/src/pages/ServicePage.jsx b/web/src/pages/ServicePage.jsx index ac601f6..7d83da8 100644 --- a/web/src/pages/ServicePage.jsx +++ b/web/src/pages/ServicePage.jsx @@ -11,7 +11,7 @@ import { useSiteData } from '../context/SiteData.jsx'; import { priceLabel, telHref } from '../lib/format.js'; import { servicePath, specialtyPath, priceSectionPath } from '../lib/links.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 { cleanText } from '../lib/text.js'; import { useApi } from '../lib/useApi.js'; @@ -76,7 +76,7 @@ function DirectionView({ direction }) { className="btn btn--coral direction-card__btn" onClick={() => open({ 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 image = SERVICE_IMAGES[service.path] || service.image || (!parent ? media?.image : ''); 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 = [ { to: '/', label: 'Главная' }, @@ -165,7 +165,7 @@ function ServiceView({ service, parent }) { {(service.prices?.length > 0 || service.path.startsWith('estetic/') || priceSection) && } - +