feat(services): add dentistry direction with existing prices and linked dentist
This commit is contained in:
parent
1d8f166e88
commit
b0e9da35e7
30
deploy/add-stomatology.sql
Normal file
30
deploy/add-stomatology.sql
Normal 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;
|
||||
55
web/scripts/test-dentistry.mjs
Normal file
55
web/scripts/test-dentistry.mjs
Normal 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();
|
||||
}
|
||||
19
web/src/assets/figma/svc-stomatologiya.svg
Normal file
19
web/src/assets/figma/svc-stomatologiya.svg
Normal 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 |
@ -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 },
|
||||
};
|
||||
|
||||
// Отдельные услуги, для которых в макете нарисован свой герой.
|
||||
|
||||
@ -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)]); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user