diff --git a/deploy/add-stomatology.sql b/deploy/add-stomatology.sql new file mode 100644 index 0000000..99bc09b --- /dev/null +++ b/deploy/add-stomatology.sql @@ -0,0 +1,30 @@ +-- Publish only after the UI overlay has passed its health check. +-- Existing prices, specialties and doctor cards are not modified. +BEGIN; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM services WHERE path='stomatologiya') THEN + RAISE EXCEPTION 'Stomatology service already exists; inspect it before applying'; + END IF; + IF NOT EXISTS (SELECT 1 FROM doctors WHERE id=20 AND last_name='Шугаев' AND position='Детский стоматолог' AND published AND deleted_at IS NULL) THEN + RAISE EXCEPTION 'Expected published pediatric dentist not found'; + END IF; + IF NOT EXISTS (SELECT 1 FROM price_sections WHERE slug='stomatologiya' AND published) THEN + RAISE EXCEPTION 'Existing dentistry price section is missing'; + END IF; +END $$; +WITH direction AS ( + INSERT INTO services (slug,path,name,short_name,lead,blocks,sort,published,show_in_menu,seo_title,seo_description,seo_h1) + VALUES ( + 'stomatologiya','stomatologiya','Стоматология','Стоматология', + 'Стоматологические услуги и цены клиники. Запишитесь к детскому стоматологу или оставьте заявку — администратор уточнит детали обращения.', + jsonb_build_array(jsonb_build_object('type','html','html', + '
В прайсе клиники представлены консультации стоматолога, лечение зубов, профессиональная гигиена и другие стоматологические услуги для детей и взрослых. Актуальную стоимость смотрите в разделе «Стоматология».
В каталоге сайта представлен детский стоматолог Шугаев Эльдар Эдманович. Его карточка и запись доступны ниже.
Для записи взрослого пациента или уточнения конкретной процедуры оставьте заявку администратору в форме внизу страницы. Администратор уточнит возможность приёма, специалиста и стоимость.
')), + 520,false,true, + 'Стоматология в Махачкале — услуги и цены | DANA Medical', + 'Стоматологические услуги DANA Medical: актуальный прайс, детский стоматолог и запись на приём в Махачкале.', + 'Стоматология' + ) RETURNING id +) +INSERT INTO doctor_services (doctor_id,service_id) SELECT 20,id FROM direction; +COMMIT; diff --git a/web/scripts/test-dentistry.mjs b/web/scripts/test-dentistry.mjs new file mode 100644 index 0000000..aff0aa3 --- /dev/null +++ b/web/scripts/test-dentistry.mjs @@ -0,0 +1,55 @@ +// Fictional service and doctors only; no production 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/stomatologiya/'); +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 { default: ServicesPage } = await vite.ssrLoadModule('/src/pages/ServicesPage.jsx'); +const { LeadModalProvider } = await vite.ssrLoadModule('/src/context/LeadModalContext.jsx'); +const { renderService } = await vite.ssrLoadModule('/src/entry-server.jsx'); +const { servicePriceSection, priceDirection } = await vite.ssrLoadModule('/src/lib/serviceNavigation.js'); +const dentist = { id: 20, slug: 'test-dentist', full_name: 'Тестовый стоматолог', position: 'Детский стоматолог', specialties: [{ slug: 'detskie-vrachi', name: 'Детские врачи' }], booking: { provider: 'none' } }; +const service = { id: 99, slug: 'stomatologiya', path: 'stomatologiya', name: 'Стоматология', doctors: [dentist], blocks: [], seo: {}, children: [] }; +const prices = [{ id: 21, slug: 'stomatologiya', name: 'Стоматология', items: [{ id: 1, name: 'Тестовая консультация', price: 1000 }] }]; +api.services = async () => ({ data: [service] }); +api.service = async () => ({ data: service }); +api.prices = async () => ({ data: prices }); +api.branches = api.settings = async () => ({ data: [] }); +api.bookingAvailability = async () => ({ data: { enabled: true, days: [] } }); +api.doctors = () => { throw new Error('Dentistry must not load the whole pediatric catalog'); }; +api.createLead = api.createAppointment = () => { throw new Error('Submissions forbidden'); }; +const container = document.getElementById('root'); +const root = createRoot(container); +const click = async (node) => { assert.ok(node); await act(async () => node.dispatchEvent(new window.Event('click', { bubbles: true }))); }; +try { + assert.equal(servicePriceSection(service.path, prices).id, 21); + assert.equal(priceDirection('stomatologiya', [service]).id, service.id); + await act(async () => root.render(React.createElement(MemoryRouter, { key: 'list', initialEntries: ['/services/'] }, React.createElement(ServicesPage)))); + assert.ok(container.querySelector('a[href="/services/stomatologiya/"]')); + assert.ok(container.querySelector('.svc-tile__image img')); + await act(async () => root.render(React.createElement(MemoryRouter, { key: 'detail', initialEntries: ['/services/stomatologiya/'] }, React.createElement(LeadModalProvider, {}, + React.createElement(Routes, {}, React.createElement(Route, { path: '/services/*', element: React.createElement(ServicePage) })))))); + assert.match(container.querySelector('h1').textContent, /Стоматология/); + assert.ok(container.querySelector('.service-hero__image img')); + assert.ok(container.querySelector('a[href="/price/stomatologiya/"]')); + assert.match(container.querySelector('.service-doctors').textContent, /Тестовый стоматолог/); + await click(container.querySelector('.service-hero__btn')); + await click(container.querySelector('.doctor-picker__trigger')); + assert.deepEqual([...container.querySelectorAll('[data-doctor-id]')].map((node) => node.dataset.doctorId), ['20']); + const rendered = renderService(service, null, { doctors: [dentist], specialties: [], prices, site: { addresses: [], branches: [] } }, 'https://test.invalid'); + assert.match(rendered.html, /Стоматология/); + assert.match(rendered.html, /Тестовый стоматолог/); + assert.match(rendered.html, /\/price\/stomatologiya\//); + console.log('PASS: dentistry directory tile, styled service page, existing price links both ways, SSR and only the explicitly linked dentist in booking'); +} finally { + await act(async () => root.unmount()); + await vite.close(); +} diff --git a/web/src/assets/figma/svc-stomatologiya.svg b/web/src/assets/figma/svc-stomatologiya.svg new file mode 100644 index 0000000..3b415ad --- /dev/null +++ b/web/src/assets/figma/svc-stomatologiya.svg @@ -0,0 +1,19 @@ + diff --git a/web/src/lib/media.js b/web/src/lib/media.js index 6dc503f..56608a6 100644 --- a/web/src/lib/media.js +++ b/web/src/lib/media.js @@ -12,6 +12,7 @@ import svcProkto from '../assets/figma/svc-proktologiya.webp'; import svcUro from '../assets/figma/svc-urologiya.webp'; import svcSurgery from '../assets/figma/svc-khirurgiya.webp'; import svcEndo from '../assets/figma/svc-endokrinologiya.webp'; +import svcDental from '../assets/figma/svc-stomatologiya.svg'; import ckGirl from '../assets/figma/ck-zdorovaya-devochka.webp'; import ckWoman from '../assets/figma/ck-zhenskoe-zdorove.webp'; @@ -80,6 +81,8 @@ export const DIRECTION_MEDIA = { urologiya: { image: svcUro, specialty: 'urologi' }, khirurgiya: { image: svcSurgery, specialty: 'khirurgi' }, endokrinologiya: { image: svcEndo, specialty: 'endokrinologi' }, + // У направления — только явно привязанные стоматологи, не вся детская клиника. + stomatologiya: { image: svcDental }, }; // Отдельные услуги, для которых в макете нарисован свой герой. diff --git a/web/src/lib/serviceNavigation.js b/web/src/lib/serviceNavigation.js index e647736..d710ee5 100644 --- a/web/src/lib/serviceNavigation.js +++ b/web/src/lib/serviceNavigation.js @@ -9,6 +9,7 @@ export const DIRECTION_PRICE_SECTIONS = { proktologiya: 'proktologiya-price', urologiya: 'urologiya-price', khirurgiya: 'hirurgiya-price', + stomatologiya: 'stomatologiya', }; function flatten(tree) { return (tree || []).flatMap((s) => [s, ...flatten(s.children)]); }