feat(services): add dentistry direction with existing prices and linked dentist

This commit is contained in:
Dana Medical Developer 2026-10-23 09:33:18 +00:00
parent 1d8f166e88
commit b0e9da35e7
5 changed files with 108 additions and 0 deletions

View File

@ -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',
'<h2>Стоматологические услуги</h2><p>В прайсе клиники представлены консультации стоматолога, лечение зубов, профессиональная гигиена и другие стоматологические услуги для детей и взрослых. Актуальную стоимость смотрите в разделе «Стоматология».</p><h2>Что есть в прайсе</h2><ul><li>Консультации стоматолога</li><li>Лечение кариеса и пульпита</li><li>Профессиональная гигиена полости рта</li><li>Удаление зубов</li><li>Реставрация, протезирование и отбеливание</li><li>Рентген-диагностика в стоматологии</li></ul><h2>Запись к специалисту</h2><p>В каталоге сайта представлен детский стоматолог Шугаев Эльдар Эдманович. Его карточка и запись доступны ниже.</p><p>Для записи взрослого пациента или уточнения конкретной процедуры оставьте заявку администратору в форме внизу страницы. Администратор уточнит возможность приёма, специалиста и стоимость.</p>')),
520,false,true,
'Стоматология в Махачкале — услуги и цены | DANA Medical',
'Стоматологические услуги DANA Medical: актуальный прайс, детский стоматолог и запись на приём в Махачкале.',
'Стоматология'
) RETURNING id
)
INSERT INTO doctor_services (doctor_id,service_id) SELECT 20,id FROM direction;
COMMIT;

View File

@ -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('<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/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();
}

View File

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" fill="none">
<defs>
<linearGradient id="bg" x1="70" y1="40" x2="580" y2="460" gradientUnits="userSpaceOnUse"><stop stop-color="#eef4fa"/><stop offset="1" stop-color="#dae3f1"/></linearGradient>
<linearGradient id="tooth" x1="220" y1="115" x2="415" y2="385" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset=".55" stop-color="#f5fafc"/><stop offset="1" stop-color="#b9cddc"/></linearGradient>
<linearGradient id="coral" x1="420" y1="80" x2="550" y2="230" gradientUnits="userSpaceOnUse"><stop stop-color="#ffb5a7"/><stop offset="1" stop-color="#f56355"/></linearGradient>
<filter id="shadow" x="100" y="70" width="440" height="400" filterUnits="userSpaceOnUse"><feDropShadow dx="0" dy="20" stdDeviation="17" flood-color="#48697d" flood-opacity=".18"/></filter>
</defs>
<rect width="640" height="480" rx="36" fill="url(#bg)"/>
<circle cx="481" cy="147" r="76" fill="url(#coral)" opacity=".85"/>
<circle cx="145" cy="345" r="59" fill="#b9d9ed"/>
<ellipse cx="322" cy="403" rx="129" ry="18" fill="#adc1d3" opacity=".25"/>
<g filter="url(#shadow)">
<path d="M318 133c-32-17-65-34-98-21-33 13-47 44-40 82 7 37 29 59 33 96 4 36 12 85 36 99 25 14 29-22 39-54 8-26 13-42 31-42 17 0 23 16 31 42 10 32 14 68 39 54 24-14 33-63 37-99 4-37 25-59 32-96 7-38-7-69-40-82-33-13-67 4-100 21Z" fill="url(#tooth)" stroke="#dae5eb" stroke-width="3"/>
<path d="M236 145c24-6 48 2 71 12 18 8 36 13 53 9" stroke="#fff" stroke-width="14" stroke-linecap="round" opacity=".85"/>
<path d="M220 186c0 25 9 44 18 64" stroke="#fff" stroke-width="9" stroke-linecap="round" opacity=".7"/>
</g>
<path d="m461 257 9 24 24 9-24 9-9 24-9-24-24-9 24-9 9-24Z" fill="#fff"/>
<path d="m162 154 6 15 15 6-15 6-6 15-6-15-15-6 15-6 6-15Z" fill="#7896ab" opacity=".65"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -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 },
};
// Отдельные услуги, для которых в макете нарисован свой герой.

View File

@ -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)]); }