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 svcUro from '../assets/figma/svc-urologiya.webp';
|
||||||
import svcSurgery from '../assets/figma/svc-khirurgiya.webp';
|
import svcSurgery from '../assets/figma/svc-khirurgiya.webp';
|
||||||
import svcEndo from '../assets/figma/svc-endokrinologiya.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 ckGirl from '../assets/figma/ck-zdorovaya-devochka.webp';
|
||||||
import ckWoman from '../assets/figma/ck-zhenskoe-zdorove.webp';
|
import ckWoman from '../assets/figma/ck-zhenskoe-zdorove.webp';
|
||||||
@ -80,6 +81,8 @@ export const DIRECTION_MEDIA = {
|
|||||||
urologiya: { image: svcUro, specialty: 'urologi' },
|
urologiya: { image: svcUro, specialty: 'urologi' },
|
||||||
khirurgiya: { image: svcSurgery, specialty: 'khirurgi' },
|
khirurgiya: { image: svcSurgery, specialty: 'khirurgi' },
|
||||||
endokrinologiya: { image: svcEndo, specialty: 'endokrinologi' },
|
endokrinologiya: { image: svcEndo, specialty: 'endokrinologi' },
|
||||||
|
// У направления — только явно привязанные стоматологи, не вся детская клиника.
|
||||||
|
stomatologiya: { image: svcDental },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Отдельные услуги, для которых в макете нарисован свой герой.
|
// Отдельные услуги, для которых в макете нарисован свой герой.
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export const DIRECTION_PRICE_SECTIONS = {
|
|||||||
proktologiya: 'proktologiya-price',
|
proktologiya: 'proktologiya-price',
|
||||||
urologiya: 'urologiya-price',
|
urologiya: 'urologiya-price',
|
||||||
khirurgiya: 'hirurgiya-price',
|
khirurgiya: 'hirurgiya-price',
|
||||||
|
stomatologiya: 'stomatologiya',
|
||||||
};
|
};
|
||||||
|
|
||||||
function flatten(tree) { return (tree || []).flatMap((s) => [s, ...flatten(s.children)]); }
|
function flatten(tree) { return (tree || []).flatMap((s) => [s, ...flatten(s.children)]); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user