Compare commits

..

23 Commits

Author SHA1 Message Date
Dana Medical Developer
b26919b73e Merge remote-tracking branch 'origin/main'
Some checks failed
Deploy Dana Medical Site / deploy (push) Failing after 1s
2026-10-23 14:45:14 +00:00
Abdulkadyr Karagishiev
870f929cb2 fix(web): align doctor experience badge and increase font size
Some checks failed
Deploy Dana Medical Site / deploy (push) Failing after 54s
2026-10-23 17:36:10 +03:00
Dana Medical Developer
da0fb50ad0 fix(booking): keep doctor-card appointments bound to chosen doctor 2026-10-23 13:47:38 +00:00
Dana Medical Developer
c7be67f96c fix(prices): unpublish operations and cosmetology; import prices as drafts 2026-10-23 13:03:14 +00:00
Dana Medical Developer
7b1cfde984 fix(booking): quarantine unverified shared doctor service catalog 2026-10-23 11:58:30 +00:00
Dana Medical Developer
d8e7272800 fix(admin): show lead refresh progress and prevent stale responses 2026-10-23 11:15:24 +00:00
Dana Medical Developer
b0e9da35e7 feat(services): add dentistry direction with existing prices and linked dentist 2026-10-23 09:33:18 +00:00
Dana Medical Developer
1d8f166e88 fix(booking): preserve selected doctor when changing calendar date 2026-10-23 08:18:22 +00:00
Dana Medical Developer
c49407047b fix(checkups): preserve audience and return to list on browser Back 2026-10-23 07:50:14 +00:00
Dana Medical Developer
cb387e1f71 perf(web): defer hidden banners and reuse fresh SSR data 2026-10-23 07:23:20 +00:00
Dana Medical Developer
f9f45e5eae fix(checkup): request programs without selecting a doctor 2026-10-22 14:34:30 +00:00
Dana Medical Developer
d791ea4d12 fix(booking): use ophthalmologists for pediatric eye consultation 2026-10-22 14:16:59 +00:00
Dana Medical Developer
0bca60ed0b style(booking): remove initial doctor selection hint 2026-10-22 14:02:01 +00:00
Dana Medical Developer
37fe9acd55 style(booking): highlight unavailable doctor list 2026-10-22 13:38:13 +00:00
Dana Medical Developer
8d4aedaa09 style(booking): highlight unavailable schedule warning 2026-10-22 13:18:31 +00:00
Dana Medical Developer
a0ba86bba3 fix(navigation): isolate data when switching dropdown menus 2026-10-22 12:44:21 +00:00
Dana Medical Developer
58c99d6286 polish(booking): focus treatment and specialty scroll 2026-10-22 11:20:05 +00:00
Dana Medical Developer
ec46d9af67 feat(booking): searchable specialty-grouped doctor picker 2026-10-22 10:59:52 +00:00
Dana Medical Developer
64ee909b3e fix(booking): refresh date filter on calendar input 2026-10-22 10:14:35 +00:00
Dana Medical Developer
da3b074c98 fix(booking): filter specialists and real slots by chosen date 2026-10-22 09:44:04 +00:00
Dana Medical Developer
97fc02778e fix(booking): select doctor before showing real 1C slots 2026-10-22 08:18:17 +00:00
Dana Medical Developer
6e30d4cb5f fix(booking): remove Medflex and keep appointments on site 2026-10-22 07:40:00 +00:00
Dana Medical Developer
30cdeb8dbc fix(onec): show all actual supplied schedule dates 2026-10-21 12:38:59 +00:00
64 changed files with 2727 additions and 369 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,11 @@
# Doctor-card booking keeps the chosen doctor
When an appointment modal receives a doctor from a catalog card, profile or explicit slot button, show a static doctor summary instead of DoctorPicker. The doctor prop is authoritative for native booking and fallback lead doctor_id. Do not request the all-doctors catalog. Calendar changes and unavailable schedules cannot reset or replace the doctor. The no-slots hint suggests another date, not another specialist.
General appointment entry points without a doctor retain the grouped/searchable DoctorPicker, including date filtering and switching doctors. Review forms are unchanged.
Files: web/src/components/LeadModal.jsx, DoctorPicker.jsx, DoctorPicker.css; web/scripts/test-booking-flow.mjs.
Verification: from web/, run node scripts/test-booking-flow.mjs and the other regression scripts; build client + SSR. Tests use fictional doctors, mocked API responses and no network submissions. Live verification opens booking only; do not create patient appointments or leads for a smoke test.
Deployment is a frontend-only image overlay on the current runtime, preserving the server binary, admin refresh fix, service-catalog quarantine, database and media. Back up the current image before switching; restore its tag and recreate only api if health checks fail. No 1C data, appointments, prices or schema are changed.

View File

@ -0,0 +1,46 @@
# Услуги в публичной записи: временная защита от неверного справочника
Проверка 23.10.2026: в `onec_services` у 175 врачей разных направлений сохранён
один и тот же набор из 18 идентификаторов/названий (3150 строк). В нём смешаны
анализы, рентген, хирургические и стоматологические процедуры. Это не
подтверждённая связь «врач → выполняемая услуга». Точное место, где формируется
неверный список в действующем отправителе 1С, этой проверкой не установлено.
До сверки с 1С публичный `GET /api/v1/doctors/{slug}/slots` возвращает `services: []`,
форма не предлагает услуги и не отправляет `service_id`. Непустой `service_id`
из старой вкладки отклоняется до создания записи с кодом `service_unavailable`.
Выбор врача, реального свободного окна, филиала и очередь записи не меняются.
Импортированный справочник и прошлые записи не удаляются и не переписываются.
Для возвращения выбора услуг нужны проверенные привязки к конкретному врачу
из 1С, проверка этих привязок перед созданием записи и регрессионные примеры
для стоматолога, ЛОРа и хирурга. Нельзя заменять их общим прайсом клиники или
угадывать исполнителя по ключевым словам. Это ограничение снимается отдельным
проверенным изменением; один новый снимок 1С сам по себе его не отключает.
## Проверка
- `node web/scripts/test-booking-service-safety.mjs` запускать из `web/`,
используя путь `scripts/test-booking-service-safety.mjs`.
- `go test ./internal/api -run TestAppointmentRejectsUnverifiedService` —
отказ до обращения к БД для непроверенной услуги.
- `TestDoctorServicesQuarantine` — отдельная временная БД через
`DANA_AUTONOMY_TEST_DSN`. Проверяет пустой публичный список, сохранность
сырого импорта и создание pending-записи без `service_id`. Не запускать с
DSN продакшна.
- На сайте выбрать Шугаева и свободное время: в форме контактов нет списка
услуг; не создавать проверочную запись на продакшне.
## Откат
Предыдущий образ: `dana-medical-api:before-booking-service-safety-20261023`.
Из каталога `/home/mikail/dana-medical`:
```sh
docker tag dana-medical-api:before-booking-service-safety-20261023 dana-medical-api:latest
docker compose -p dana-medical -f docker-compose.prod.yml up -d --no-deps api
```
Проверить `/api/v1/health`. Такой откат возвращает и неверный список услуг;
использовать только при регрессии и затем согласовать исправление. БД и
медиа не откатывать, контейнер bridge не запускать.

View File

@ -0,0 +1,596 @@
-- Approved scope: operations and the unoffered cosmetology section only.
-- No price amounts, catalog entries, appointments or patient data are deleted.
BEGIN;
SET LOCAL lock_timeout='5s';
LOCK TABLE price_items IN SHARE ROW EXCLUSIVE MODE;
CREATE TEMP TABLE price_publication_targets(id bigint PRIMARY KEY, fingerprint text, reason text) ON COMMIT DROP;
INSERT INTO price_publication_targets VALUES
(10433,'c2e7b12628cc858144e407bbd91c3afb','surgical_price_detail'),
(10434,'c8b6d263eaf1cc6fa8a6162a42d31552','surgical_price_detail'),
(10435,'1371ec7024d8da5e0a47b460d91900e3','operation_price'),
(10436,'7b440227f6f89b86db6ede4cda9f0587','operation_price'),
(10437,'26dfad19f905ea667b6570acdb949dd1','operation_price'),
(10438,'33aa70c469152aa7ef80eecc354d9d30','operation_price'),
(10439,'9f1b4f0ad2db943fd8a064d0b277e69b','operation_price'),
(10440,'6e5aacfcc2311df141ebc78421d210be','operation_price'),
(10441,'0fa740bcbb584b420899cf47bae2ec65','operation_price'),
(10442,'2da7ce2f8a3d86079d3b6209b1e90834','operation_price'),
(10443,'1bc116d8508bade2940e382a31f01beb','surgical_intervention_price'),
(10444,'d34950d14d97506d9e5e530a7ff9d4af','surgical_intervention_price'),
(10445,'edbf54ebe7623665ee47bc6517c98d51','surgical_intervention_price'),
(10450,'ae407d3d96928d8fc79fdcfa45291412','operation_price'),
(10451,'4800d31388dad5af6b15e2a81626a6ca','operation_price'),
(10452,'633f47a3a5588895618d48b91f12371a','surgical_intervention_price'),
(10453,'33151408284050b64af93e9b66922b9b','surgical_intervention_price'),
(10454,'02f80619133da44426abf3722fd18b32','surgical_intervention_price'),
(10455,'b1c59680ec57d002767012225e38dba6','operation_price'),
(10456,'fd89aa6fd7f7a3492d074faaf6f3922f','surgical_intervention_price'),
(10457,'6958abca3e0480977ebac5aa208d2e07','surgical_intervention_price'),
(10458,'6599e2dc5d9b295eb9de05e2cf4b3693','surgical_intervention_price'),
(10459,'cdfa3f5ff6de52a054e773d715f84e5e','surgical_intervention_price'),
(10460,'9ff9f94b82da113156c8ba2e5849dc6f','surgical_intervention_price'),
(10461,'d91e28eaf3db98a91d2985b3b5f552c2','surgical_intervention_price'),
(10462,'8ee649f343ee5c8ea251d251472e0bdf','operation_price'),
(10463,'c8f376454796ae3db9e57312e0e818f2','operation_price'),
(10464,'785e4aa911bc1b67d0a09d7919720e36','surgical_intervention_price'),
(10465,'bca99056fd0929fa35e598d1042120c5','surgical_intervention_price'),
(10466,'e167345749412e10b007db8aee246e11','surgical_intervention_price'),
(10467,'2f2fed8d1530e81f73b8f289ef092a3f','surgical_intervention_price'),
(10468,'3b5c340ed8b352af0308f4611f794064','operation_price'),
(10469,'06f2d9b8a2bbc2066cde59d12672f74f','operation_price'),
(10470,'fcec51ae7c93abe6eea7e6411c45b098','operative_correction_price'),
(10473,'9bf2269f814d39bfee65387e45a7525d','surgical_intervention_price'),
(10474,'849e4f06abb2ddbe87568b1164f754a4','operation_price'),
(10475,'b268e903dad66595502bce851e1128dc','surgical_intervention_price'),
(10476,'1b32b8a2b40b59c239a639eb7f8bd9bc','surgical_intervention_price'),
(10477,'d2ea6e7a1d944aba70611cdda4019420','surgical_intervention_price'),
(10478,'bbfdbdb55c8351e7ad1a91f32666b1ec','surgical_intervention_price'),
(10479,'bcc82c8eaeefb2d01662583081d77452','surgical_intervention_price'),
(10480,'cb17152e02ae54ff49e306ed6f11c0dd','surgical_intervention_price'),
(10481,'ca580f9e73702dac6e08427b454df221','surgical_intervention_price'),
(10504,'2c2a484aef4468a928cfccd5474377d7','surgical_price_detail'),
(10505,'d10a0007bd6c774a4c70e6538a24534b','surgical_price_detail'),
(10506,'345d7f32e49113261b419c0932185f81','surgical_price_detail'),
(10507,'fb07367c9aad7ffd95e2e6bd4f251d81','surgical_price_detail'),
(10518,'b7a7df6aa69ad8d6fc9ac253e723a6d7','operative_correction_price'),
(10633,'836ed07a5a593d482570d05969bae94f','operation_price'),
(10634,'c01c413db199addad00a2cef05fe3634','operation_price'),
(10635,'e997e40d53512fb94c7a9dd8900b6042','operation_price'),
(10636,'a81137b1544df44a06449a96799e49ac','operation_price'),
(10637,'666dff37728415c1e98c1a73a993ccb1','operation_price'),
(10638,'ee37bc7a08a550af52158c7551a7d3a1','surgical_intervention_price'),
(10640,'2cab08ae0dcec4d27c9def7809bfc687','surgical_intervention_price'),
(10643,'5b98a1fbe08395b89612d601ea16389b','cosmetic_procedure_price'),
(10644,'3bde4f12b0c4bb68002da7a0e2de2bf9','gynecological_surgery_price'),
(10645,'42f6d7d791bbc7ed670b0b39593b1833','gynecological_surgery_price'),
(10646,'4bb71aac6f0f3c449df685d0b1faac26','gynecological_surgery_price'),
(10647,'60a79a7cb5eb3d02c693647352ee22d2','gynecological_surgery_price'),
(10656,'edbcbae5deca319942029a1bd90256e8','surgical_intervention_price'),
(10657,'ce20a163fb2a927d83c263c33388b8f8','surgical_intervention_price'),
(10658,'7369f6988cf8e04d11af4657b3831d80','operation_price'),
(10659,'40349ccf8f75cf57dae5a80df931457b','operation_price'),
(10660,'9f36223ae4643d738979feb063ab1ad5','operation_price'),
(10661,'de514786adc4ab687c05022a4cbca253','operation_price'),
(10662,'36c891413b6870f471ffadea0289ce22','operation_price'),
(10663,'b36cdf9ce778925f9d5568e39acfb644','gynecological_surgery_price'),
(10664,'857ab729c53af65b907ddb7ba416f3bf','gynecological_surgery_price'),
(10665,'e9d57dacfb82fd2b93637d92f305cd60','gynecological_surgery_price'),
(10667,'3afc2d1977a85668836ca4d37549a107','operation_price'),
(10668,'00585909e16ce60da5b119f2e75ca1f6','operation_price'),
(10669,'1462de0779537d50cb04f566693d194f','operation_price'),
(10670,'db8d4a0aed78adf87cf85db933f9ecd4','operation_price'),
(10671,'51ad6808708d7af2b1acd9b00e1ff6bd','operation_price'),
(10674,'33bd5a88d9a5bb14ce77fa2e76fc841a','cosmetic_procedure_price'),
(10675,'f7a3ab5a14d171070ba9716aa69ee739','cosmetic_procedure_price'),
(10676,'04f2094d2ad4b2cb191e75193861a694','cosmetic_procedure_price'),
(10677,'005a134e9581a367148ef80e17c3770f','cosmetic_procedure_price'),
(10678,'59451b367eec8ad89ba23a48a633a343','cosmetic_procedure_price'),
(10679,'ff7ad589f623f2c74f78ee9c0a1aa1ad','cosmetic_procedure_price'),
(10681,'52467c91c7d3108d785ebabc0a970c39','operation_price'),
(10682,'496f4df6f514d08d853b3d35e4e938d3','operation_price'),
(10683,'48ed7e9bd70ac076a9e71e2f615cc4d6','operation_price'),
(10684,'4b61604e1a5a1277fc94c92d783ddd6a','operation_price'),
(10685,'95072e33be368b6fad82eade473f7266','operation_price'),
(10686,'3f2fafd73588f2055f0e84609f37424b','operation_price'),
(10687,'f1e064fa78b0154d1fc756ae3e6bce67','surgical_intervention_price'),
(10688,'bfa7d0815be38f61d02ebb9ea7719d43','operation_price'),
(10689,'522d25d10f1e95f525a5fe454e693023','operation_price'),
(10690,'88bf63ecae8a4f32a8fe12b5c9e89134','operation_price'),
(10691,'66eef6639b04f0aee82352931d0379b5','operation_price'),
(10692,'9c08ff73d647f745f4a7ea9e0ec92be5','operation_price'),
(10693,'76a3961811a133dbb97e728736ec5e38','gynecological_surgery_price'),
(10694,'c5c4e24f9d069c6acbd5b1bc748f8547','gynecological_surgery_price'),
(10695,'e51b9f1fe4eca546af58561755cd6fc9','gynecological_surgery_price'),
(10696,'9e65e441441b029f1c0918675aede880','gynecological_surgery_price'),
(10697,'0a5e0adb2598c38fcea4d5b2195902e7','gynecological_surgery_price'),
(10698,'1ed2fca0910dd8e0aa15784c455b38e1','gynecological_surgery_price'),
(10699,'be7db087cdf5438a4cd287f67e8a2db8','operation_price'),
(10700,'52639e846a272e73c1ee06dec34e9c94','operation_price'),
(10701,'94761d538d481825c05d74cb2fb7594e','operation_price'),
(10702,'a73168672746f669333d46f62adbc2db','operation_price'),
(10703,'8153ed4a4f71216f5bf0efee9d37cd10','operation_price'),
(10704,'6de173552024b172f9a29aa24a9f4c9c','operation_price'),
(10705,'d0492d4c20d1200feabf2fe18f7f5c68','operation_price'),
(10706,'5e4d035b2075fcb330eceda35e9b6bbd','operation_price'),
(10707,'4e2a5e096d5139574121b80ea8a23556','operation_price'),
(10708,'62275749f0d6cd6f692c833a91d39ed8','operation_price'),
(10709,'9930ef4e9cfc78088c6668c2436fffea','operation_price'),
(10710,'336d27a2f187d2e1e4500f637e5b4932','cosmetic_procedure_price'),
(10711,'06c774891ecb49850d2c2ec085db1b6b','surgical_intervention_price'),
(10712,'87106dd495aa6101255293695c947b5a','surgical_intervention_price'),
(10713,'e66f44fbb836a40077ba014dbfad2164','operation_price'),
(10714,'832222634c14f0b5f63da31b1063ad0e','operation_price'),
(10715,'2382ee70fff468ebaf875fb7e8c34efc','operation_price'),
(10716,'284521e82b8490cbd1c7c9f310cbf411','operation_price'),
(10717,'97cb6f91955ff8033538c29f7290fafd','operation_price'),
(10718,'c0f67f04a844da62b481f15d165e3eb1','operation_price'),
(10720,'203776c03f88eb7b7a8f638b5618f9a5','operation_price'),
(10721,'1f87699d83c9f8d14fa282439dbcde2e','operation_price'),
(10722,'93df9cc3e34f0e73e445d9eb5cd10bf2','operation_price'),
(10723,'cd35f5b670d7bef80d462771a5b8482d','operation_price'),
(10724,'ebd76ef9ad9426ed1dad81659b7f5094','operation_price'),
(10725,'dbf535f85b1873f7a7eb328abca0c2d6','operation_price'),
(10726,'dfa985c2b7739d048c313eb40ae10334','operative_correction_price'),
(10727,'0c799817bd382482561c7737d85672d7','operation_price'),
(10728,'3791cd65af9124831a401596c77bca10','operation_price'),
(10729,'04becabc4832d8d894af6533450c31fd','operation_price'),
(10730,'cfd9f43285ff27ab34dfbd7de33bf8a2','operation_price'),
(10731,'ece5a894389f83a43f379df691b4bb80','operation_price'),
(10732,'94a603149a73154d50915eb631e76559','operation_price'),
(10733,'581d3c51f11698bd9bd572979a7ca07d','operation_price'),
(10734,'8f880dcfd91490bae76cd052cf82bb0d','operation_price'),
(10735,'98cfe4c58b1b0f3a1c22cd20048e1977','operation_price'),
(10736,'96237eea1552df9043c1541e0e9aa35e','operation_price'),
(10737,'ba985f86c6d1c47ee32abe5a73e05199','operation_price'),
(10738,'d9bdebdc7084d4e8a234a40df06b4200','operation_price'),
(10739,'918d8ac0bf14a35408be96036ad2ddc1','operation_price'),
(10740,'b15e240442ba50654306b405e6654b7c','surgical_intervention_price'),
(10741,'607085bee929dd3f379cbb80c7e79f0d','operation_price'),
(10742,'037769aef8981b8e3d0802efd46f1fda','operation_price'),
(10743,'d69fae4b64e16dcfd4eb9aa3ae158367','operation_price'),
(10744,'db2b3d0ece432dfe60f203142dd771e1','surgical_intervention_price'),
(10745,'b84d186c56730bd937b60188a3c3e569','operation_price'),
(10746,'46ba2c258b3cb05eae2c4a896a5d14ce','operation_price'),
(10747,'90db181137b3b0be06a04fed57b84bbc','surgical_intervention_price'),
(10748,'db54322083c3f6c76a588c3185a6eee2','operation_price'),
(10749,'22d86f7758da4c3651ff417b2b43df98','operation_price'),
(10750,'a98a0c0dda890589cad83b4040d9bfbd','operation_price'),
(10751,'c83444090ee1f7d981a04c4892375219','operation_price'),
(10752,'87517cd385192c0c438314d4558a27d9','operation_price'),
(10753,'06530db74ddd84d56f3d2b97cb8d1c98','operation_price'),
(10754,'6606812cfd68fff49cf575567e08c450','operation_price'),
(10755,'1d131133affff655387795f0f69b283d','operation_price'),
(10756,'b7f1866f768bcd8b61d77ee4b8c0f088','operation_price'),
(10757,'2b786890a9d7ad62129504349193cd8a','operation_price'),
(10758,'52f9e78bcfa02ac232d34bd053ffb469','operation_price'),
(10759,'7279ad719af236d43472144831fdf4ea','operation_price'),
(10760,'203012c16f6738542c0aca5534c10491','operation_price'),
(10761,'89d3b6ae30b1b70c039eb62d84510fc3','operation_price'),
(10762,'723776eafbe3dd8c9c84246e50a39134','surgical_intervention_price'),
(10767,'5093cff048e24d5eb30196fd5ca6b7f9','surgical_intervention_price'),
(10768,'a624496b31ccd48c03bacc71c6644576','surgical_intervention_price'),
(10769,'fd02ee55b3c30c10f4af617738e2708a','surgical_intervention_price'),
(10778,'b161b246135f3bdb5e3c09ad63ce535a','gynecological_surgery_price'),
(10779,'b029b7374c4aff87d2803086fdb48e89','gynecological_surgery_price'),
(10780,'ceb458f655cc000c6825a883f96ffe51','gynecological_surgery_price'),
(10783,'0ce7a87bd476357af8877a6702332102','surgical_intervention_price'),
(10784,'b77c4ff95a264238b3e538f1af471af3','surgical_intervention_price'),
(10785,'5ef7def0d92ff98fcb98afcafa75a0d6','surgical_intervention_price'),
(10786,'5b8075ed648e21cdc5d792db7b8ac2fd','surgical_intervention_price'),
(10787,'880cdff088c95648952891a21a9d9312','operation_price'),
(10788,'d70bb641e0a0e9496cccc7168be2f4a4','operation_price'),
(10789,'417f4a63381da5540844473928d354f7','operation_price'),
(10790,'9048b286593878df8f7252520063ed8a','operation_price'),
(10791,'2d7621bc34330109c201635ca0426539','operation_price'),
(10792,'dc5de284abc3db5f3dd09f5c7a0331d1','operation_price'),
(10796,'77fc30e67d3b9cf164a20784812b3929','surgical_intervention_price'),
(10797,'d3f58ba34a16665f574d0f75cdbe4a23','surgical_intervention_price'),
(10798,'3469d392b0ea79fa5f950170e4e999be','surgical_intervention_price'),
(10799,'21b01b48a40777c4f79700d8279a9181','operation_price'),
(10800,'3b42be6d95cb447808c8f0558cc12a74','operation_price'),
(10801,'6d3f4a218500025d2a07b50629756c89','surgical_price_detail'),
(10802,'8db358be4fc2f330d933b87251a0da5a','surgical_price_detail'),
(10803,'4703eeb23a617b12a20f0ad0e8ab11f2','surgical_price_detail'),
(10816,'e9203e9cfa2f47fb46c7593c23aa6a42','cosmetology_not_offered'),
(10817,'02a52fbfde44f46906cef0135dd8ef13','cosmetology_not_offered'),
(10818,'7ab9588e2b78af929c16256bb876f203','cosmetology_not_offered'),
(10819,'b1fd4821b1988a320d59dbbd6cf36db4','cosmetology_not_offered'),
(10820,'35b9e3ce780d33b8bbe1dc7154196b18','cosmetology_not_offered'),
(10821,'096de7306b2956384edf806c4863e5cd','cosmetology_not_offered'),
(10822,'e42a7bb9758b6cd17ed22151835e60c9','cosmetology_not_offered'),
(10823,'c6594f982ed6d7fbd47a53330c9b7128','cosmetology_not_offered'),
(10824,'880fcf1fddfb3ba770bba2961bf28af6','cosmetology_not_offered'),
(10825,'ed5721017d899c5f149c3a3e44ab93fb','cosmetology_not_offered'),
(10826,'4ced7fe2fb50316a8d8a981a608276e7','cosmetology_not_offered'),
(10827,'1cb611c2dd57cd9ff75f9f08c70fb2e0','cosmetology_not_offered'),
(10828,'c0393c594f8677a9a217b4f21364fcb4','cosmetology_not_offered'),
(10829,'a38eecdcef9a8965dba2a445e1fa9d64','cosmetology_not_offered'),
(10830,'f000b0cd246c69298ccfc591f173e0c4','cosmetology_not_offered'),
(10831,'f71d1fa3d6afd3e7c5df06eb6c48f8b6','cosmetology_not_offered'),
(10832,'edc9c679448b2a54f996a679745f9f1d','cosmetology_not_offered'),
(10833,'20e36210803fd99d7a1200a4218cdd51','cosmetology_not_offered'),
(10834,'ea2456cf4055239dd367f1ce39012080','cosmetology_not_offered'),
(10835,'dc5d3fc030220c883cd5990893a27e3a','cosmetology_not_offered'),
(10836,'d1c756de4d8f630aad48f9660104e4fa','cosmetology_not_offered'),
(10837,'153596fa9ec187c47fb315b94ea9b9ad','cosmetology_not_offered'),
(10838,'1e748ad03b157d6057fd900388aa158e','cosmetology_not_offered'),
(10839,'8f32b8a5fa955a8172aa7084bf25212e','cosmetology_not_offered'),
(10840,'e8bdb463139c5922c0a85acce7a1be36','cosmetology_not_offered'),
(10841,'c87bceab6bc494a47ae83204fedefafa','cosmetology_not_offered'),
(10842,'4749893279b958e8dbbc3d83b4a405d0','cosmetology_not_offered'),
(10843,'db63393ddb468461d3acc17594804687','cosmetology_not_offered'),
(10844,'efcf5d13acb33d4841bf0799fa46e87a','cosmetology_not_offered'),
(10845,'24b3a3910a890624a549345d4a8fde57','cosmetology_not_offered'),
(10846,'f2f2226e7c52e64b000dccaeb94665a4','cosmetology_not_offered'),
(10847,'500c2639412a89644651dd65b31966dc','cosmetology_not_offered'),
(10848,'2c62d26b1bb4fd66b5e0f7098d2ad6b5','cosmetology_not_offered'),
(10849,'874538368f4e9cb77f13555c61fc81fd','cosmetology_not_offered'),
(10850,'51c3a44c4d8e988be5083c4c64c33c33','cosmetology_not_offered'),
(10851,'2d453dda45b8237813c1b7c70d53cab0','cosmetology_not_offered'),
(10852,'a7240624b34c493ef1f0de39a730deef','cosmetology_not_offered'),
(10853,'0e3f99090495e54f078292a77926a434','cosmetology_not_offered'),
(10854,'6ce666f4e2f6d3004a6a906f5facf024','cosmetology_not_offered'),
(10855,'6d8075652c310eff50c901af47a95355','cosmetology_not_offered'),
(10856,'66e14a6782c1754fa3fceabf599acb24','cosmetology_not_offered'),
(10857,'5bffefa722c56dac8b06a91015b99355','cosmetology_not_offered'),
(10858,'89be2af6339c0dd3bc714a8477060297','cosmetology_not_offered'),
(10859,'242f703122fce689a3d9fa3851717a8e','cosmetology_not_offered'),
(10860,'0aa0f4522b86cc8cd280b81dc765d88f','cosmetology_not_offered'),
(10861,'5b742d5b9c838d7ad9a1ccbde306f766','cosmetology_not_offered'),
(10862,'3e3653283c86908756662ea350eceb03','cosmetology_not_offered'),
(10863,'1997c635583b98e763109cd4f0c2441a','cosmetology_not_offered'),
(10864,'916b5ac6af0a55be91b34ca8f0c8ff66','cosmetology_not_offered'),
(10865,'c405f12c18562d7f4be2a28eb74e3588','cosmetology_not_offered'),
(10866,'c2e2380dec0bc4525676abcc93ff1ac8','cosmetology_not_offered'),
(10867,'eee8612e777fcbf302f6384c5bdbb5bd','cosmetology_not_offered'),
(10868,'1ebb327a0147df8860edcff093cb520a','cosmetology_not_offered'),
(10869,'df6b3bbb4c199d81747c0268561953af','cosmetology_not_offered'),
(10870,'643c657db49ced08f077233244d27278','cosmetology_not_offered'),
(10871,'4a08d88a7c7b3426a1a914b68dde2e7f','cosmetology_not_offered'),
(10872,'e1106bdbe70e55523fe0cdd19434688c','cosmetology_not_offered'),
(10873,'432c40d5ecbe79d2b8e98e4303eebebf','cosmetology_not_offered'),
(10874,'099682d0a8eedb1b0c7694523107c7fe','cosmetology_not_offered'),
(10875,'09d308f8a15112af61372b81796d2b66','cosmetology_not_offered'),
(10876,'c12146e7ef55994016f89fc2f2f102d6','cosmetology_not_offered'),
(10877,'11a9718e8a4e73342a9e86d7558ec6e0','cosmetology_not_offered'),
(10878,'7e7d010e4527b0f2313a2808cdf0bdc4','cosmetology_not_offered'),
(10879,'8f0ae4d55d209bde7bf3bc3cd7092794','cosmetology_not_offered'),
(10880,'f34fe1ea3eaf179eca5965b564ca45d5','cosmetology_not_offered'),
(10881,'a3825cbb8d90a38e6a15d6579666a87a','cosmetology_not_offered'),
(10882,'359997233b6d83b601f2c1bc2c901ffd','cosmetology_not_offered'),
(10883,'fe1469af75ce2f322189d607eb88ba67','cosmetology_not_offered'),
(10884,'725ecf93f07032dfd38c4ee7f23c7a62','cosmetology_not_offered'),
(10885,'abb9ae3335e099ff36121ba9e7bbbd64','cosmetology_not_offered'),
(10886,'5274d17ba030273a7528da0da99d9f84','cosmetology_not_offered'),
(10887,'05b82cf65926f358a29e771766f9ddcc','cosmetology_not_offered'),
(10888,'c29426cbda49fc29101ab8170c1e887f','cosmetology_not_offered'),
(10889,'2e6e7d0f34f49db078b94477aaec8771','cosmetology_not_offered'),
(11042,'c54e2f8ad78eb30debd0ef486ad99076','surgical_price_detail'),
(11045,'6b66eda75eebdcad21e31e2c52b7cce6','surgical_intervention_price'),
(11046,'dc151d8dd7db70d3d910a44d6c427a25','surgical_intervention_price'),
(11047,'4b4567a00ae26b7224faf7d07c9c3aed','surgical_intervention_price'),
(11056,'70b0a321087f4ecc3a9e5b7ba2dccd92','surgical_intervention_price'),
(11057,'3bb00b63e332403050042eaee83b134d','surgical_intervention_price'),
(11058,'3bafea4d8811265fc6cef4a2ab608f47','surgical_intervention_price'),
(11059,'a6bf1b78ebb51c207d913f7956220c67','surgical_intervention_price'),
(11060,'f58820f161967403029836456eec4016','surgical_intervention_price'),
(11061,'5aaa3df167d6456853fcc34fffc12a53','operation_price'),
(11062,'2b9217c13afee37488a86984f99610da','operation_price'),
(11063,'e70f022419b60eb4684d100b9e73b863','surgical_intervention_price'),
(11064,'6bf045b5277d1360509cb4ef780faf22','surgical_intervention_price'),
(11065,'dfcaad65590a199efb96de785ac788de','surgical_intervention_price'),
(11070,'c07000774203934eeefe03ef831f5846','surgical_intervention_price'),
(11076,'cdc993dd103c07ddc2356f4f00dfe690','surgical_intervention_price'),
(11077,'3050824a65a86b0fadce3e94818d4bbe','surgical_intervention_price'),
(11078,'17c4e9eed83b4b0907127f7739442d7a','operation_price'),
(11079,'56129caa00d11b2fc25ba850a04c323c','surgical_price_detail'),
(11080,'5fb0d501d2b360a68f716453d545af77','surgical_price_detail'),
(11081,'a61d98c639819fbfdcf9a804a3c3b9cf','surgical_price_detail'),
(11082,'d242ede3851caafa7bd32d5bfaf52fe6','surgical_price_detail'),
(11083,'b0235e943177fad8ca6b6040b537af15','surgical_price_detail'),
(11084,'fae83f029c88a7ae202afc1adb48226c','surgical_price_detail'),
(11085,'e9481633f903e07d32ebf878f4fb3064','surgical_price_detail'),
(11086,'57f7992435e0eb96261622ab4e8254eb','surgical_price_detail'),
(11087,'f410750fa06d58815d7c63824544d42f','surgical_price_detail'),
(11088,'cb25da8c07b4a815a5db4a6787b1f7ce','surgical_price_detail'),
(11089,'4bd1576ac4e88b5032e56237e3c02078','surgical_price_detail'),
(11090,'29bbb1a80496730c150b1eb52d4b9ce7','surgical_price_detail'),
(11091,'117b8b35af1c626bba3ec5928e7ebf90','surgical_price_detail'),
(11092,'f6e9dcfbe7b2c67416637f5f513b63e4','surgical_price_detail'),
(11093,'00374cdd9bda6dc1ff2188df07a5b36a','surgical_price_detail'),
(11094,'a188d980bdd602e8c700f0738b0b7548','surgical_price_detail'),
(11095,'912535af80dd8ad2101247ee97f46e65','surgical_price_detail'),
(11096,'795bf4f7caf1c45f59cc3b747dfef584','surgical_price_detail'),
(11097,'5191252c8ed56f6f7b37e53b9fdc0bc4','surgical_price_detail'),
(11098,'d1d9560b8eec2329177525c1c625d563','surgical_price_detail'),
(11099,'e50854ab31dd64c0cd6e5ee3f6575fdd','surgical_price_detail'),
(11100,'9150fcc84ad1dea6dd578a9d853d7927','surgical_price_detail'),
(11101,'6091d8ae558c050db334ce90fd90cf68','surgical_price_detail'),
(11102,'bb534e4fb7f1f849bdb4e2a1239556c3','surgical_price_detail'),
(11103,'188a9d5041d2b976419df273fd8a8adf','surgical_price_detail'),
(11104,'9b940ab4bb1f78605c1b97f0abed4826','surgical_price_detail'),
(11105,'6d8fb4e654d6d1122a24b61c2b7f0d2f','surgical_price_detail'),
(11106,'2c4c4cb006267d913db8dbc3129ab163','surgical_price_detail'),
(11107,'a2f552c14dade7ff204a6ea51df011d8','surgical_price_detail'),
(11108,'605ea9336c810ffd05640427d681e4d8','surgical_price_detail'),
(11109,'7c7f66c0b19c94346359adee2e57cae9','surgical_price_detail'),
(11110,'8a07633f60c7dd857c8168830a28670d','surgical_price_detail'),
(11111,'e5ece75a6af5172465766f36b1066eee','surgical_price_detail'),
(11112,'12d32962c7d9cf0bc90bf7274ac61a7f','surgical_price_detail'),
(11113,'7e1cfc725df2e1d3814780fc3c44df0e','surgical_price_detail'),
(11114,'cd20842b80e7dc9a235905cd8db319be','surgical_price_detail'),
(11115,'a2f89185214a346ac7a185992b5ff443','surgical_price_detail'),
(11116,'dc21994e4eb94323bcd1d806979df638','surgical_price_detail'),
(11117,'11a2c1f87bdb5f32240b4a7b5cb759b5','surgical_price_detail'),
(11118,'a7725590c1edf3f529fd28524ae21fd8','surgical_price_detail'),
(11120,'3f7b6fa4ecab6daa3395ccd70b79ed23','operation_price'),
(11124,'22570fc114e73a5c3a26af0f384fc5a5','operation_price'),
(11125,'c0869c19ca9329ae6ed7ef8c6bddf3be','operative_correction_price'),
(11126,'490d69472d6958b26dd0469ff6a02228','operation_price'),
(11127,'b7c51630bdf82ecb229a48a7b3803534','operative_correction_price'),
(11128,'cade81079fb4dc3ae1233cfb399d4bfe','surgical_intervention_price'),
(11371,'4835325fa9c3db1ab4cf616b4ebebb7b','surgical_price_detail'),
(11372,'70e1d321000308214a3d395cd9991087','surgical_price_detail'),
(11373,'b9a6e984d2acc8e25db5642440c41004','surgical_price_detail'),
(11374,'abed3a1fb693cc3385ae5d08cb10279a','surgical_price_detail'),
(11375,'163073482b7c11d1b1a05fa15cc8e5eb','surgical_price_detail'),
(11376,'aacb181ddfea8dd4a4678db93732b886','surgical_price_detail'),
(11377,'260ad747d302e643cdbb726165ce2b95','surgical_price_detail'),
(11378,'0ddbcd28db9c3e68d0c7c506fbff6d56','surgical_price_detail'),
(11379,'c210dcf60bb444c7cdd3fe7edc0f7f40','surgical_price_detail'),
(11380,'fd26f451f64c945f85da3e0d4fc06684','surgical_price_detail'),
(11381,'15ce842e3cb8812f295994af5e431601','surgical_price_detail'),
(11382,'0b0723ef38f8d265fe51832a4fa1185f','surgical_price_detail'),
(11383,'ed99d882e3ccff9e89a269f3c0873aee','surgical_price_detail'),
(11384,'2a6a2af580393a5e9b26999b7014bf96','surgical_price_detail'),
(11385,'bcacd8b9ed933e295db5da448c2131c2','surgical_price_detail'),
(11386,'8986c17b1d1a4b9b9300c83d17fe0360','surgical_price_detail'),
(11387,'71fde66b891318171ff2f79b9758582e','surgical_price_detail'),
(11388,'4abb5f2c40654797f28b47cd2de5fb85','surgical_price_detail'),
(11389,'aef13333d7c725c1d20cddf9418f89cf','surgical_price_detail'),
(11390,'6efda183b30a072ccfc7a9ce9ec70a27','surgical_price_detail'),
(11391,'f46f8bcd0906a527b5368d4652239e2d','surgical_price_detail'),
(11392,'5ef34592099a55bf6e976a2fcb8bb2b3','surgical_price_detail'),
(11393,'43b5692c9344890e51bf0dabde868e97','surgical_price_detail'),
(11394,'1be8fadd8fc5b3ed1af7565d3c9a8e15','surgical_price_detail'),
(11395,'698e80a8892e997d9528ac854756a5d4','surgical_price_detail'),
(11396,'9c6edede1a27b2fb3ff38dabdf9e6121','surgical_price_detail'),
(11397,'f63e634a79360660fec73a48dbac88c2','surgical_price_detail'),
(11398,'12cb35dc719a1a3d416d7ffd476b2155','surgical_price_detail'),
(11399,'72666b27987bb6f1524fe868a5607bc2','surgical_price_detail'),
(11400,'0d941276edd4d090eebcaf2f3555f11d','surgical_price_detail'),
(11401,'f10f6578213e4de7f9410e89444e57a7','surgical_price_detail'),
(11402,'f14b546b9633b11859f7ae3bcadfdcb8','surgical_price_detail'),
(11403,'e0d23f3afaeb59d824c80aaa5593036e','surgical_price_detail'),
(11404,'2bbd858271ff1f7a58de069c3604f1f0','surgical_price_detail'),
(11405,'ddf2b953405867bdedabb3900e57cc4b','surgical_price_detail'),
(11406,'b594cd8688887b1823c681e2341941ed','surgical_price_detail'),
(11407,'1a02148597fc69c4342ed09be390965b','surgical_price_detail'),
(11408,'96772e1cac533f83d498c2d4d285cf16','surgical_price_detail'),
(11409,'033b24b79f2423430ec39ee98254f30b','surgical_price_detail'),
(11410,'8b846675995a30651044f251d071fb53','surgical_price_detail'),
(11411,'68ba94215cc375c8c10b5dd436777366','surgical_price_detail'),
(11412,'1fb1334af51aaff14d077b2f772b1e8a','surgical_price_detail'),
(11413,'0761ff91fe118dd7fdfa488371b3ac5b','surgical_price_detail'),
(11414,'5bb41478aaacf8c8b2c1b5008c34602e','surgical_price_detail'),
(11415,'79c83e3c423124512a1bd480294ada70','surgical_price_detail'),
(11416,'63d3a34c09acee0fa221a435bde8b926','surgical_price_detail'),
(11417,'7b6a1bbcacf5649b1c49e2f62f797309','surgical_price_detail'),
(11418,'22d645de1aa4b189f6a8512fb8da0bfa','surgical_price_detail'),
(11419,'39d71baf81a5f53af6d46d78b2b480ee','surgical_price_detail'),
(11420,'78e131cd660b178a2b7003595fe973f7','surgical_price_detail'),
(11421,'456c3e6ea1bfa6a996d2fd81983ff8f3','surgical_price_detail'),
(11422,'ac71fea6c7941acd228abbd9a06fcaa0','surgical_price_detail'),
(11423,'6469a16b6d197560c592052662b4b137','surgical_price_detail'),
(11424,'703dc05b18686061e74e8069bbc92b35','surgical_price_detail'),
(11425,'c21816c4d67553def882bcc3b279d94d','surgical_price_detail'),
(11540,'0378e2b5973d5d9543f5222f756fc6b0','operation_price'),
(11542,'1a6dc8d77dfae93497aa082aeb4d13f3','operation_price'),
(11543,'30b4f9699e6eabae15a387e9d9eec6c0','operation_price'),
(11544,'39b89b077f69a44e1bd27c691d2418ba','operation_price'),
(11546,'d1870950b98896d082290977af9feaf2','operation_price'),
(11547,'a00247f43a602cd6ef872ce7cf97f59f','operation_price'),
(11548,'b322b2786589d53844964d5658b87c9c','operation_price'),
(11549,'88e6873dd43ae94e37507fc1d05026ef','surgical_intervention_price'),
(11550,'6b3ae7e02681d06e6cfbaac3fed3d0bf','surgical_intervention_price'),
(11734,'575e625b225038437f925c66891f8354','operation_price'),
(11742,'294336db397b932643e2c28fe0b290e1','surgical_intervention_price'),
(11743,'5a40e6af7d2f39719086c93178973bea','surgical_intervention_price'),
(11756,'83c386c22f9712200aea617f620a8722','surgical_intervention_price'),
(11783,'8a77cc1efa74b79098cf3806d84159fe','surgical_intervention_price'),
(11786,'d87ee3ebe767d49a85d234482961db9b','surgical_intervention_price'),
(11958,'ca68d448d87d3350c0caa53e94e849fa','operative_correction_price'),
(11973,'bc9f99ce1f49555034f7393b0f1e45bb','operation_price'),
(11982,'afb816285e8f91d7570e18531e9f093b','operation_price'),
(11998,'5c95a46d4d34b625f8da9bebda7cd2a0','operation_price'),
(11999,'4384c815e5fa65e91b12cd79c8e7e476','operation_price'),
(12000,'7f38a2461e8d480f397f7fc5c20c6609','operation_price'),
(12003,'9505cbb041d36b5059e2f0d2d9033979','surgical_intervention_price'),
(12004,'140f64b3cb6b4439bf2fad2fe1deba47','surgical_intervention_price'),
(12046,'e3028dc66f7d7d0f86c5e45c47eed593','surgical_intervention_price'),
(12048,'5c0c484147ccbc505573680a689279dc','surgical_intervention_price'),
(12056,'d98059430332ff237c5e63d611ab98b6','cosmetology_not_offered'),
(12057,'38ec5698cd42e42225ae193cac982e49','cosmetology_not_offered'),
(12058,'8d3215a1fc6b3c80ea397478a795f75d','cosmetology_not_offered'),
(12059,'29f3f86dd88c19eb889696a5b99f3d1a','cosmetology_not_offered'),
(12060,'7a30e81b39e37dad29190873ba9ea411','cosmetology_not_offered'),
(12061,'a9ace30af64ffdaf7d748708edfc5f7d','cosmetology_not_offered'),
(12062,'39153e248f08090c3a3055eb86e18728','cosmetology_not_offered'),
(12063,'9a863edfaa397f2c98452899ea4e3c12','cosmetology_not_offered'),
(12064,'9a034bd8c0d211d6f1106756d79403e3','cosmetology_not_offered'),
(12065,'0a6820c0ef0d4da958d1894177502ada','cosmetology_not_offered'),
(12066,'e069a9a399ec0d284b6f448455ccb6de','cosmetology_not_offered'),
(12067,'a48df977faa2d02725281746651a8647','cosmetology_not_offered'),
(12068,'550ec815d50532a431e3f8cc91a9373e','cosmetology_not_offered'),
(12069,'e26e4b130f0f0119d68c693e20e0f12e','cosmetology_not_offered'),
(12070,'9f1ae2572a447dec18ca4b7975bb76c6','cosmetology_not_offered'),
(12071,'33b803c8a6a937f186f6925016703118','cosmetology_not_offered'),
(12072,'c8dbf522b1e762365d98a05cc518369b','cosmetology_not_offered'),
(12073,'1005826b078663c1d6a656158d0bd293','cosmetology_not_offered'),
(12074,'5833f2592b6e07bcdb9a3cc44617c9d1','cosmetology_not_offered'),
(12075,'fbc4b568ccca05478631fd12ee2f478c','cosmetology_not_offered'),
(12076,'d7325ad90ad8b82f7b1b52fea1141fb3','cosmetology_not_offered'),
(12077,'d1e9e7e92577d32012f31e0dcceaba85','cosmetology_not_offered'),
(12078,'f670717590d89b333f8324e6ec37b31b','cosmetology_not_offered'),
(12079,'677a1aafe1fa5c0ff773e74f8e68c512','cosmetology_not_offered'),
(12080,'5a3c5d05d793e80fb62df3629eae6890','cosmetology_not_offered'),
(12081,'8adf05d715ebccc133a4722138b68447','cosmetology_not_offered'),
(12082,'3f684b5b205c9dbc2c0b69d2dbae0425','cosmetology_not_offered'),
(12083,'b6fcbdec6bda1a0a7a2f6d43dc4661d1','cosmetology_not_offered'),
(12084,'88fee28949c00633e963f80ab57e4d80','cosmetology_not_offered'),
(12085,'c8ac45aef2b1ea75d7fb2712f7922acd','cosmetology_not_offered'),
(12086,'1578165652d4705d73ba5af5f81db35c','cosmetology_not_offered'),
(12087,'c42b0daf93cda799290431e5fb300aee','cosmetology_not_offered'),
(12088,'ca30d5cb505ad8e802e044c7f7f7a4e5','cosmetology_not_offered'),
(12089,'97e4155ef25ad9eda37a6231d4b1ca10','cosmetology_not_offered'),
(12090,'b70cb412b4109b1a9522369986152e53','cosmetology_not_offered'),
(12091,'6cde0eca5fbae6a3e8639d38e585a2ec','cosmetology_not_offered'),
(12092,'50ca0ca3bbf56b000844e117a4666668','cosmetology_not_offered'),
(12093,'36fea48f67ee40f57ee09ccde3a59f25','cosmetology_not_offered'),
(12094,'f97232126baf222255eb963ca1c5eeff','cosmetology_not_offered'),
(12095,'cb1cc59aaf7ede666c7ffd7cb1b600ab','cosmetology_not_offered'),
(12096,'ebdb880fd860b2df726fc3b4ffa500af','cosmetology_not_offered'),
(12097,'e66bbab385a9a7a7d5ca864e127ac35c','cosmetology_not_offered'),
(12098,'a98db63eb94119f889c319cea6c67ea9','cosmetology_not_offered'),
(12099,'d79dbd1850ed37244924d7f76fe4cd16','cosmetology_not_offered'),
(12100,'49aba0c54f7a5b41484d9fac4b38420c','cosmetology_not_offered'),
(12101,'7e89d2e9a79dd100dc5405be95d4bc63','cosmetology_not_offered'),
(12102,'5478dd5b6fc441af4ab4c7dd2fd3dbe2','cosmetology_not_offered'),
(12103,'a763c317b76c96cde72bda118462c86a','cosmetology_not_offered'),
(12104,'4fe0c52d90f929637863393062c36813','cosmetology_not_offered'),
(12105,'ea9b407b40836224d3b486602bd6a186','cosmetology_not_offered'),
(12106,'b7c605e616df70bcc4c5c23adb89dec3','cosmetology_not_offered'),
(12107,'8e915038e5372f17158ee2e9a95f26df','cosmetology_not_offered'),
(12108,'b247e363fc233fe1eaec4ec4931598d1','cosmetology_not_offered'),
(12109,'92a3a87a28a1205c59a76734ca4246a6','cosmetology_not_offered'),
(12110,'0594b56c5946e743ddbc4192a71a51b6','cosmetology_not_offered'),
(12111,'699560dacf39428a9b6cdbbbb19ba1c2','cosmetology_not_offered'),
(12112,'119fdc728b72b7d7f740292d53c4fce9','cosmetology_not_offered'),
(12113,'add125e7021a73fec6964e045246e6e2','cosmetology_not_offered'),
(12114,'6b4678a90096ceecdb5a86186428d98c','cosmetology_not_offered'),
(12115,'6fe3fecefc244391b6c7b3d9b3eee5a7','cosmetology_not_offered'),
(12116,'c2c9832567725ebda0004b894972c1eb','cosmetology_not_offered'),
(12117,'58f990a2e230859af42ac1f200fc3b49','cosmetology_not_offered'),
(12118,'6c8f1a02171e9a9d25d485cdb12e58e0','cosmetology_not_offered'),
(12119,'17456c63d7326ab1d771aad17c7c7ec6','cosmetology_not_offered'),
(12120,'78ac831fd6ab7682bbbb9232e69546b0','cosmetology_not_offered'),
(12121,'ca8a896bc0488318adbfdc5809a786a2','cosmetology_not_offered'),
(12122,'3d30264b2ef620c9bf84e124a0616e1a','cosmetology_not_offered'),
(12123,'ff1adb244f917563fed665fd60428b10','cosmetology_not_offered'),
(12124,'4a52e207367f602a220934d440ee431e','cosmetology_not_offered'),
(12125,'0ecb48d8caee8d216ace4efa9da4ca99','cosmetology_not_offered'),
(12162,'a4c3da9144f69caff0b463a51afa7167','operation_price'),
(12163,'0ae52e4322bd29835f4dae2ff5238a88','operation_price'),
(12193,'542c767c1d26d6912f9e61bbc15d39ed','operative_correction_price'),
(12194,'cccc4e8e479fcef138394a91230260af','operation_price'),
(12195,'314035d8ffd5aa8c2361d0386eff7088','operative_correction_price'),
(12196,'71d4bdfc0fc98c7a0bc2a8df384af920','operation_price'),
(12197,'57fe27f42fa99a0d3358f196eb3535a5','operation_price'),
(12198,'90490cf14c6ac0504c47b6a6dd749402','operation_price'),
(12199,'2ddc3a390b48505fcf8334def26699bf','operation_price'),
(12200,'dafa11f3d67bf3c0750f760f4eb11d00','operation_price'),
(12201,'ecd7e9c434f8326f45ef1b44c681d5d7','operation_price'),
(12202,'2ff12a562aa677a4ff9de597330c421e','operation_price'),
(12203,'47941eb20f0a5fe0406346a773664feb','operation_price'),
(12204,'1ba44455cb675793a85c908d9bf306f1','operation_price'),
(12207,'9d522efbdf389535d76ea5a4179f86df','operation_price'),
(12208,'c7de26b2fe741a7023975db500997eb1','operation_price'),
(12209,'e3e6cb8bc986bdcf71bb03ba728265f8','operation_price'),
(12210,'cb3ca69ac0f09cc9b49f5614e5f67d43','operation_price'),
(12211,'fb1f9989ae12feba70cef8762fc3313c','operation_price'),
(12214,'e2a5b26da171d66b0ee06b118a13a66b','operation_price'),
(12217,'f0ee5f4bfe96b7c45ab5e9614c2e1bfd','operation_price'),
(12228,'c5069f96a46edda7f13e21c0869ff8c2','operation_price'),
(12238,'87d9f5d15614a38aa09d12c78f247a00','operation_price'),
(12239,'45dd27984c36eff5feef811aa5aab72c','operation_price'),
(12241,'69c35f029b1dcf79a9afb6e5d736cead','operation_price'),
(12307,'8297cd9465f8a36f05260da47252b632','surgical_price_detail'),
(12308,'f7ffdf477d105555278de2dd0872b489','surgical_price_detail'),
(12316,'72418cde90192156f032920ea285cbb1','surgical_price_detail'),
(12317,'18a87beb7fa43564973e7394eb5190c5','surgical_price_detail'),
(12318,'8f617b629d1d376afed055c1809b68bc','surgical_price_detail'),
(12319,'cfd0a1104f1cd791256f9eb057279848','surgical_price_detail'),
(12329,'f79c0a1cb1d89dc88f8c88e58f5f634b','cosmetology_not_offered'),
(12451,'fb820f34e7cc179a8ac4db749346b02c','operative_correction_price'),
(12452,'a3b9e7059b40c1a9cee9d6a396b0b3de','operative_correction_price'),
(12453,'efe7b2ae87d26a889665d1c98637d48c','operative_correction_price'),
(12454,'19fdabbc1f10dc06adab322f58165fa5','operative_correction_price'),
(12455,'bfe4e4f04059282741fddc504b8b5900','operative_correction_price'),
(12515,'15515bcf1133c304424f3aa403a84d66','operation_price'),
(12516,'af4f2f96bc0b82b8ac58a7ebfac4a63b','operation_price'),
(12518,'9abc5653a6050e7a7f45d86c6eb0bbbf','operation_price'),
(12541,'ca64c5d03a1723790c47efddf13303d4','surgical_price_detail'),
(12542,'e5870768b77cb960ddbd1aac07337da9','surgical_price_detail'),
(12543,'9f2dab559415b5ba845227a85984106e','surgical_price_detail'),
(12544,'fadae736e6a7061c4737070af2518e1c','surgical_price_detail'),
(12545,'852cd92a5bc6682f4638bb936f5e6b56','surgical_price_detail'),
(12546,'cf6773a17090308651623ace4c8ffddc','surgical_price_detail'),
(12547,'1b0c6b3b932693dd976553985d4c11e2','surgical_price_detail'),
(12548,'34504b6f1bd86f1f292515c0f2a289c3','surgical_price_detail'),
(12549,'6951e1005cb7ec72ebf882ebaf3eb287','surgical_price_detail'),
(12550,'689ed0c3b18f72b0387a706dbaae480b','surgical_price_detail'),
(12551,'bf7d67c4e777492125163ba2aaf35a27','surgical_price_detail'),
(12552,'d6acf6ba0b21fff9993f144e8748d794','surgical_price_detail'),
(12553,'5c6cc47ccfd1e7d19ddc2e05e9e1b1c5','surgical_price_detail'),
(12554,'081c18c9fd4fce37fc708e969aeae7b1','surgical_price_detail'),
(12555,'7d41c0198f605c6be65bae417c384ba5','surgical_price_detail'),
(12556,'1ac579a1bd3c000739a6518c6a638984','surgical_price_detail'),
(12557,'d5d540caeaab5548daf57ee5d22ef822','surgical_price_detail'),
(12558,'a25496fe1583455e975dd17e92ebcc71','surgical_price_detail'),
(12559,'47c6b2d8e7574cc1ddd6a1b4ebeb451d','surgical_price_detail'),
(12560,'69c0abab89b2bd680ebd0390cfe5ee4b','surgical_price_detail'),
(12561,'45eff69bdec5e58159d08c6cee3fbfe5','surgical_price_detail'),
(12562,'cf90cee68b9460bdbf38906d8d772cb1','surgical_price_detail'),
(12563,'9106a74c00b398c8454ac712c32b30b3','surgical_price_detail'),
(12564,'4c0907c06c19fb51e7a2f0cfc6baa1ba','surgical_price_detail'),
(12565,'246529f025b520391f8be3b972c46fcc','surgical_price_detail'),
(12566,'be8442984c9b895fb07bab4c0dd503eb','surgical_price_detail'),
(12567,'36879d8f65bd1250eff26f01fb9b8343','surgical_price_detail'),
(12568,'9325bc3676af861bb47393f885756c20','surgical_price_detail'),
(12569,'9f330591da3a686aeaf90ba996f739d6','surgical_price_detail'),
(12570,'57e3c99799b65795a97165f0690daaf8','surgical_price_detail'),
(12571,'1db645094661e852838a405e297e479a','surgical_price_detail'),
(12572,'11041c5bf6f48751f364411371ce159d','surgical_price_detail'),
(12573,'d105445b1ca2ceea9c9dc489f88ae1a0','surgical_price_detail'),
(12574,'fd9bf203ac47c50f26fba1cb0280d704','surgical_price_detail'),
(12575,'9e42aa66ea210ce22c1c999dd754f077','surgical_price_detail'),
(12576,'da7dc22c14767e439544c037f5d4ccb6','surgical_price_detail'),
(12577,'a95f40ca9fa48fb7d8fb4d88546c7462','surgical_price_detail'),
(12578,'65cc087d3945e15edf48f78148cc49a4','surgical_price_detail'),
(12579,'3f09df987fa4767e0ab20e18b916ff10','surgical_price_detail'),
(12580,'a3eaf78280199f4808f1da8cf9c07641','surgical_price_detail'),
(12581,'827d9ebd9ca804cbdf9b1a7261da5b95','surgical_price_detail'),
(12582,'8b95d38f33fd9fba35ec7d31700bb3ae','surgical_price_detail'),
(12583,'a604307957461c5d3a9639b88ea229d8','surgical_price_detail'),
(12591,'066a9bb51f9b2160632d92b8dd03afb8','operation_price'),
(12592,'da03d8ef7cfca6d1b77a4c79cf0f9bbc','operation_price'),
(12593,'3b8507abfe96f67267aca1d8c3f4021a','operation_price'),
(12594,'582a42ebcb731353b1cf9182fabffe49','operation_price'),
(12595,'a7cd2aa40a3f1b71cf02ddbc23375ff2','operation_price'),
(12598,'642ef5b673116d573f7116d1fb3e4d2d','surgical_intervention_price'),
(12600,'02ec57b25f6e1fa9fec9118f2803b5c6','operative_correction_price'),
(12601,'1030eab27b2598e125288f0553378ce2','operative_correction_price'),
(12602,'b1c181de0f40ae9e8e990fc60afab728','operation_price'),
(12610,'1788a5b70385ecbb25158b4c140bc67d','surgical_intervention_price'),
(12626,'e08134b4d408c79cead0e06d1546cbca','surgical_intervention_price'),
(12627,'86a12e5745a919cb7d3a8ba51a94f0b8','surgical_intervention_price'),
(12727,'fe3f4e9bf0ba07915981f5d4007eeff9','surgical_intervention_price'),
(12729,'f426443ffa8c86647eb48ae5f83feca2','surgical_intervention_price'),
(12840,'5b293748b6ef04c64c394166805c2695','operative_correction_price'),
(12841,'8be46f399179f835662529083f43f3cc','operation_price'),
(12844,'40047e838889951a5b84405a3315c88f','operation_price'),
(12847,'327a7c71308e620511455bb627da38b3','operation_price');
DO $$
BEGIN
IF (SELECT count(*) FROM price_publication_targets t JOIN price_items p USING(id) JOIN price_sections s ON s.id=p.section_id
WHERE p.published AND md5(p.name || '|' || p.price::text || '|' || s.slug)=t.fingerprint) <> 575 THEN
RAISE EXCEPTION 'Price data changed since review; aborting without changes';
END IF;
IF NOT EXISTS (SELECT 1 FROM price_sections WHERE slug='kosmetologiya-price' AND published) THEN
RAISE EXCEPTION 'Cosmetology section state changed; inspect before applying';
END IF;
END $$;
UPDATE price_items p SET published=false,updated_at=now() FROM price_publication_targets t WHERE p.id=t.id;
UPDATE price_sections SET published=false WHERE slug='kosmetologiya-price';
SELECT t.reason,count(*) AS hidden_rows FROM price_publication_targets t GROUP BY t.reason ORDER BY t.reason;
COMMIT;

View File

@ -0,0 +1,53 @@
# Public price corrections, 23 October 2026
Clinic instruction: hide operations and the unoffered cosmetology direction.
Keep the rest of the current price list and consultation prices. Do not publish
an invented minimum for plastic surgery; wait for the clinic's approved amount.
## Provenance
The original workbooks D:/ЦОМ.xlsx, D:/Остео.xlsx and D:/Дана Дети.xls were
read without modification. All 2439 database price names/amounts matched source
rows (ignoring only the importer-added source labels). The legacy importers
`migrations/import/build_price_map.py` and `rebuild_price_from_1c_only.py`
generated INSERT statements with `published=true`. An internal nomenclature
item was incorrectly treated as approved public website content. Heading-based
grouping also placed surgical entries under unrelated categories such as US.
## Applied scope
`hide-nonpublic-prices-20261023.sql` is a one-time visibility-only correction.
It identifies 575 reviewed existing rows by ID plus a fingerprint of the exact
name, amount and section. It aborts on drift. No prices are changed and no rows
are deleted. The cosmetology section is unpublished. The raw catalog remains
available to administrators. Other sections, doctor profiles, appointments and
clinical records are unchanged.
The selection rules in `price_policy.py` are an audit helper, not a medical
classifier or an ongoing automatic publication rule. New or ambiguous entries
require human review; do not blindly apply these expressions to future imports.
The two legacy generators now produce draft prices and draft new sections.
They are not executed by this correction. The full-rebuild generator still
contains a historical DELETE and must never be run against production without
a separately approved replacement/backup plan.
## Verification and rollback
Run the standard-library tests from deploy/:
```sh
python3 -m unittest -v test_price_policy test_price_import_defaults
```
After the SQL change, compare every price with the pre-change snapshot. Only
the reviewed IDs may change their publication flags/updated timestamps. Public
`/api/v1/prices`, service pages and search must exclude hidden IDs; consultation
prices and unrelated price amounts must remain intact. SSR builds its cache
fingerprint from current public data, so no application restart is required.
The private task directory `/home/mikail/dana-price-publication-20261023/`
contains the before/after price snapshots and `rollback-price-visibility.sql`.
Use that rollback only after reviewing any newer manual publication edits. It
restores the previous publication flags and does not restore or overwrite
patients, appointments or price amounts. Do not restore the entire database.

47
deploy/price_policy.py Normal file
View File

@ -0,0 +1,47 @@
"""Publication-only policy; never changes prices, services or medical records."""
import re
CONSULT = re.compile(r"^(?:(?:первичная|повторная|первичный|повторный|послеоперационная)\s+)?(консультац|консултац|при[её]м\s+(врача|хирурга|пластического))", re.I)
OPERATION = re.compile(r"операци|оперативн|эктоми|ектоми|резекц|пластик|лапарос|лапаротом|лапараскоп|лапаратом|иссечени|кольпораф|перинеораф|конизаци|циркумци|лигирован|обрезани|гернио|экс[тц]ирпац|шунтир|хирургия|гастрошунт|аденотом|тонзиллотом|полипотом|френулотом|нефростом|остеосинтез|остеотом|варикоцеле|ампутац|гистероскоп|пекси|ректоцеле|вылущивани|дефлорац|эксцизи|перевязка\s+маточных\s+труб", re.I)
ANCILLARY = re.compile(r"послеоперац|предоперац|дооперац|после\s+операц|реабилитац|курсы\s+по|анестези|капельниц|восстановлени", re.I)
SURGICAL_REMOVAL = re.compile(r"^(удалени|вскрыти|рассечени|эксцизи|ушивани|разрез|энуклеац)", re.I)
SURGICAL_SECTIONS = {"hirurgiya-price", "urologiya-price", "proktologiya-price", "gyne-price", "travmatologiya-price", "lor"}
EXPLICIT_OPERATION_IDS = {10470, 10518, 10726, 11125, 11127, 11958, 12193, 12195, 12451, 12452, 12453, 12454, 12455, 12600, 12601, 12840}
COSMETIC = re.compile(r"биоревитализац|омоложени|моделирование\s+формы\s+половых\s+губ|увеличение\s+точки\s+g|введение\s+гиалуроновой\s+кислоты", re.I)
def reason(item):
name = item["name"].strip()
section = item["section"]
if section == "kosmetologiya-price":
return "cosmetology_not_offered"
if CONSULT.search(name):
return None
# The user explicitly requested consultation-only public surgical pricing.
if section in {"plasticheskaya_hirurgiya", "hirurgiya-price"}:
return "surgical_price_detail"
if item["id"] in EXPLICIT_OPERATION_IDS:
return "operative_correction_price"
if COSMETIC.search(name):
return "cosmetic_procedure_price"
if ANCILLARY.search(name) and not re.match(r"^(операци|оперативн|иссечени|удалени)", name, re.I):
return None
if OPERATION.search(name):
return "operation_price"
if SURGICAL_REMOVAL.search(name):
return "surgical_intervention_price"
if section == "gyne-price" and re.search(r"выскабливани|вакуумная\s+аспирац|гистерорезек|рассечени\s+синехи", name, re.I):
return "gynecological_surgery_price"
return None
if __name__ == "__main__":
import json
from collections import Counter
from pathlib import Path
root = Path(__file__).resolve().parent
prices = json.loads((root / "prices-before.json").read_text(encoding="utf-8-sig"))
targets = [{**item, "reason": reason(item)} for item in prices if item["published"] and reason(item)]
(root / "targets-review.json").write_text(json.dumps(targets, ensure_ascii=False, indent=2), encoding="utf-8")
print("TARGETS", len(targets), dict(Counter(item["section"] for item in targets)))
for item in targets:
if item["section"] not in {"kosmetologiya-price", "hirurgiya-price", "plasticheskaya_hirurgiya"}:
print(item["id"], item["section"], item["name"])

View File

@ -0,0 +1,19 @@
"""Inspect legacy generators without importing them (they have file-writing side effects)."""
import ast
from pathlib import Path
import unittest
class ImportDefaultsTest(unittest.TestCase):
def test_imported_items_are_draft(self):
root = Path(__file__).resolve().parents[1] / 'migrations' / 'import'
for name in ('build_price_map.py', 'rebuild_price_from_1c_only.py'):
with self.subTest(script=name):
tree = ast.parse((root / name).read_text(encoding='utf-8'))
inserts = [node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value,str) and 'INSERT INTO price_items' in node.value]
self.assertTrue(inserts)
for sql in inserts:
self.assertIn('SELECT id,%s,%s,%s,false',sql)
self.assertNotIn('%s,true FROM price_sections',sql)
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,26 @@
import unittest
from price_policy import reason
def item(name, section="gyne-price", item_id=1):
return {"id": item_id, "name": name, "section": section}
class PricePolicyTest(unittest.TestCase):
def test_consultations_are_preserved(self):
for name in ("Консультация хирурга", "Консультация врача хирурга (Тест)", "Прием хирурга/ортопеда (Тест)"):
self.assertIsNone(reason(item(name, "hirurgiya-price")))
def test_gynaecological_and_misfiled_operations_are_hidden(self):
for name in ("Лапароскопическая миомэктомия", "Надвлагалищная ампутация матки (лапараскопия)", "Оперативная коррекция послеоперационного рубца", "Радиоволновая эксцизия ш/м", "Кольпорафия", "Вылущивание кисты шейки матки", "Вагинопластика 3 кат. сложности"):
self.assertIsNotNone(reason(item(name)))
self.assertIsNotNone(reason(item("Операция на опухоль спинного мозга", "uzi-price")))
def test_nonoperative_prices_are_unchanged(self):
for name, section in (("Кольпоскопия", "gyne-price"), ("Кардиотокография (КТГ) плода", "gyne-price"), ("УЗИ органов брюшной полости", "uzi-price"), ("Кариес (детям)", "stomatologiya"), ("Керамические коронки", "stomatologiya"), ("Капельница Восстановление после операций", "protsedurny-kabinet"), ("Реабилитация после операции", "lech_bol")):
self.assertIsNone(reason(item(name, section)))
def test_whole_unoffered_cosmetology_section_is_hidden(self):
self.assertEqual(reason(item("Чистка лица", "kosmetologiya-price")), "cosmetology_not_offered")
self.assertEqual(reason(item("Ошибочно включённая консультация", "kosmetologiya-price")), "cosmetology_not_offered")
if __name__ == "__main__":
unittest.main()

View File

@ -9,7 +9,7 @@ import (
"dana/internal/storage" "dana/internal/storage"
) )
var bookingProviders = map[string]bool{"none": true, "native": true, "medflex": true} var bookingProviders = map[string]bool{"none": true, "native": true}
var bioKinds = map[string]bool{ var bioKinds = map[string]bool{
"education": true, "experience": true, "training": true, "education": true, "experience": true, "training": true,
"award": true, "conference": true, "award": true, "conference": true,
@ -54,12 +54,6 @@ func (a *API) adminUpdateDoctor(w http.ResponseWriter, r *http.Request, u *stora
fail(w, http.StatusUnprocessableEntity, "validation", "Рейтинг должен быть от 1,0 до 5,0") fail(w, http.StatusUnprocessableEntity, "validation", "Рейтинг должен быть от 1,0 до 5,0")
return return
} }
// Medflex без ссылки работать не будет — не даём сохранить заведомо мёртвую кнопку.
if in.Booking == "medflex" && strings.TrimSpace(in.MedflexUser) == "" {
fail(w, http.StatusUnprocessableEntity, "validation",
"Для записи через Medflex нужна ссылка")
return
}
oldSlug, err := a.store.PublishedDoctorSlug(r.Context(), id) oldSlug, err := a.store.PublishedDoctorSlug(r.Context(), id)
if err != nil { if err != nil {
handleErr(w, err) handleErr(w, err)

View File

@ -29,6 +29,7 @@ func pageParams(r *http.Request) (limit, offset int) {
// ---------- Заявки ---------- // ---------- Заявки ----------
func (a *API) adminLeads(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { func (a *API) adminLeads(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
w.Header().Set("Cache-Control", "no-store")
limit, offset := pageParams(r) limit, offset := pageParams(r)
q := r.URL.Query() q := r.URL.Query()
var from, to *time.Time var from, to *time.Time

View File

@ -0,0 +1,20 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAdminLeadsResponseIsNotCacheable(t *testing.T) {
// Invalid dates return before storage access; no database or patient data needed.
a := &API{}
w := httptest.NewRecorder()
a.adminLeads(w, httptest.NewRequest(http.MethodGet, "/api/v1/admin/leads?from=invalid", nil), nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if got := w.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
}

View File

@ -93,7 +93,7 @@ const DoctorDash = {
['Основные сведения', ({ d }) => !!(d.last_name && d.first_name && d.position && d.specialties)], ['Основные сведения', ({ d }) => !!(d.last_name && d.first_name && d.position && d.specialties)],
['Фото', ({ d }) => !!d.photo], ['Фото', ({ d }) => !!d.photo],
['Стаж', ({ d }) => d.practice_start_year != null], ['Стаж', ({ d }) => d.practice_start_year != null],
['Онлайн-запись', ({ d }) => d.booking_provider === 'medflex'], ['Онлайн-запись', ({ d }) => d.booking_provider === 'native'],
['Категория', ({ d }) => !!d.category], ['Категория', ({ d }) => !!d.category],
['«О враче»', ({ d }) => !!d.bio], ['«О враче»', ({ d }) => !!d.bio],
], ],
@ -172,7 +172,7 @@ const DoctorDash = {
['Образование', cnt(({ q }) => q.education > 0), (v) => `заполнено у ${v}`], ['Образование', cnt(({ q }) => q.education > 0), (v) => `заполнено у ${v}`],
['Награды', cnt(({ q }) => q.awards > 0), (v) => `заполнено у ${v}`], ['Награды', cnt(({ q }) => q.awards > 0), (v) => `заполнено у ${v}`],
['Конференции', cnt(({ q }) => q.conferences > 0), (v) => `заполнено у ${v}`], ['Конференции', cnt(({ q }) => q.conferences > 0), (v) => `заполнено у ${v}`],
['Онлайн-запись Medflex', cnt(({ d }) => d.booking_provider === 'medflex'), (v) => `подключена у ${v}`, 'no-medflex'], ['Онлайн-запись 1С', cnt(({ d }) => d.booking_provider === 'native'), (v) => `подключена у ${v}`, 'no-booking'],
['Услуги врача', cnt(({ d }) => d.service_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не указаны', 'no-services'], ['Услуги врача', cnt(({ d }) => d.service_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не указаны', 'no-services'],
['Филиалы приёма', cnt(({ d }) => d.branch_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не заполнены', 'no-branches'], ['Филиалы приёма', cnt(({ d }) => d.branch_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не заполнены', 'no-branches'],
['Отзывы', withReviews, () => (pending + live ['Отзывы', withReviews, () => (pending + live
@ -208,7 +208,7 @@ const DoctorDash = {
'Онлайн-запись', 'Услуг', 'Филиалов', 'Отзывов на проверке', 'Отзывов опубликовано', 'Заявок']]; 'Онлайн-запись', 'Услуг', 'Филиалов', 'Отзывов на проверке', 'Отзывов опубликовано', 'Заявок']];
for (const { d, q } of DoctorDash.rows()) { for (const { d, q } of DoctorDash.rows()) {
rows.push([fullName(d), d.specialties, yes(d.photo), d.practice_start_year ? new Date().getFullYear() - d.practice_start_year + ' лет' : 'нет', rows.push([fullName(d), d.specialties, yes(d.photo), d.practice_start_year ? new Date().getFullYear() - d.practice_start_year + ' лет' : 'нет',
d.category || 'нет', yes(d.bio), q.education || 0, q.awards || 0, q.conferences || 0, d.booking_provider === 'medflex' ? 'Medflex' : 'заявка', d.category || 'нет', yes(d.bio), q.education || 0, q.awards || 0, q.conferences || 0, d.booking_provider === 'native' ? '1С' : 'заявка',
d.service_ids.length, d.branch_ids.length, q.reviews_pending || 0, q.reviews_published || 0, q.leads || 0]); d.service_ids.length, d.branch_ids.length, q.reviews_pending || 0, q.reviews_published || 0, q.leads || 0]);
} }
const esc = (v) => { const x = String(v ?? ''); return /[";\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; }; const esc = (v) => { const x = String(v ?? ''); return /[";\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; };
@ -224,7 +224,7 @@ $('#dd-spec').onchange = () => DoctorDash.render();
window.addEventListener('resize', debounce(() => { if (DoctorDash.q && !$('#tab-doctors-dash').hidden) DoctorDash.chart(DoctorDash.rows()); }, 150)); window.addEventListener('resize', debounce(() => { if (DoctorDash.q && !$('#tab-doctors-dash').hidden) DoctorDash.chart(DoctorDash.rows()); }, 150));
{ {
const acts = [['users', 'Все врачи', () => DoctorDash.toList('')], ['check', 'Проверить карточки', () => DoctorDash.toList('incomplete')], const acts = [['users', 'Все врачи', () => DoctorDash.toList('')], ['check', 'Проверить карточки', () => DoctorDash.toList('incomplete')],
['chat', 'Отзывы', () => go('reviews')], ['calendar', 'Онлайн-запись', () => DoctorDash.toList('no-medflex')], ['chat', 'Отзывы', () => go('reviews')], ['calendar', 'Онлайн-запись', () => DoctorDash.toList('no-booking')],
['download', 'Экспорт отчёта', () => DoctorDash.exportCSV()]]; ['download', 'Экспорт отчёта', () => DoctorDash.exportCSV()]];
const bar = $('#dd-actions'); const bar = $('#dd-actions');
for (const [icon, label, fn] of acts) { for (const [icon, label, fn] of acts) {

View File

@ -44,7 +44,7 @@ const Doctors = {
(f !== 'no-services' || !d.service_ids.length) && (f !== 'no-branches' || !d.branch_ids.length) && (f !== 'no-services' || !d.service_ids.length) && (f !== 'no-branches' || !d.branch_ids.length) &&
(f !== 'no-photo' || !d.photo) && (f !== 'no-photo' || !d.photo) &&
(f !== 'no-experience' || d.practice_start_year == null) && (f !== 'no-category' || !d.category) && (f !== 'no-experience' || d.practice_start_year == null) && (f !== 'no-category' || !d.category) &&
(f !== 'no-bio' || !d.bio) && (f !== 'no-medflex' || d.booking_provider !== 'medflex') && (f !== 'no-bio' || !d.bio) && (f !== 'no-booking' || d.booking_provider !== 'native') &&
(f !== 'incomplete' || !d.photo || d.practice_start_year == null || (!d.bio && !d.bio_count)) && (f !== 'incomplete' || !d.photo || d.practice_start_year == null || (!d.bio && !d.bio_count)) &&
(!f.startsWith('sp:') || String(d.specialty_id) === f.slice(3))); (!f.startsWith('sp:') || String(d.specialty_id) === f.slice(3)));
const { key, dir } = Doctors.sort; const { key, dir } = Doctors.sort;
@ -150,7 +150,7 @@ const Doctors = {
const body = { const body = {
slug: d.slug, last_name: d.last_name, first_name: d.first_name, middle_name: d.middle_name, slug: d.slug, last_name: d.last_name, first_name: d.first_name, middle_name: d.middle_name,
position: d.position, category: d.category, degree: d.degree, practice_start_year: d.practice_start_year, rating: d.rating, position: d.position, category: d.category, degree: d.degree, practice_start_year: d.practice_start_year, rating: d.rating,
photo_id: d.photo_id, quote: d.quote, bio: d.bio, booking_provider: d.booking_provider, medflex_user: d.medflex_user, photo_id: d.photo_id, quote: d.quote, bio: d.bio, booking_provider: d.booking_provider,
sort: d.sort, published: d.published, absent: d.absent, show_on_home: d.show_on_home, sort: d.sort, published: d.published, absent: d.absent, show_on_home: d.show_on_home,
seo_title: d.seo_title, seo_description: d.seo_description, seo_h1: d.seo_h1, ...change, seo_title: d.seo_title, seo_description: d.seo_description, seo_h1: d.seo_h1, ...change,
}; };
@ -192,7 +192,7 @@ const Doctors = {
const base = [['', 'Все врачи'], ['published', 'На сайте'], ['hidden', 'Скрытые'], ['absent', 'Не принимают'], const base = [['', 'Все врачи'], ['published', 'На сайте'], ['hidden', 'Скрытые'], ['absent', 'Не принимают'],
['no-services', 'Без услуг'], ['no-branches', 'Без филиала'], ['no-photo', 'Без фото'], ['no-services', 'Без услуг'], ['no-branches', 'Без филиала'], ['no-photo', 'Без фото'],
['incomplete', 'Неполные карточки'], ['no-experience', 'Без стажа'], ['no-category', 'Без категории'], ['incomplete', 'Неполные карточки'], ['no-experience', 'Без стажа'], ['no-category', 'Без категории'],
['no-bio', 'Без «О враче»'], ['no-medflex', 'Без онлайн-записи']]; ['no-bio', 'Без «О враче»'], ['no-booking', 'Без онлайн-записи 1С']];
for (const [v, t] of base) sel.append(el('option', { value: v, textContent: t })); for (const [v, t] of base) sel.append(el('option', { value: v, textContent: t }));
const grp = el('optgroup', { label: 'Специальность' }); const grp = el('optgroup', { label: 'Специальность' });
for (const s of specialtiesCache.filter((s) => s.doctors)) grp.append(el('option', { value: 'sp:' + s.id, textContent: `${s.name_plural} (${s.doctors})` })); for (const s of specialtiesCache.filter((s) => s.doctors)) grp.append(el('option', { value: 'sp:' + s.id, textContent: `${s.name_plural} (${s.doctors})` }));

View File

@ -6,7 +6,7 @@
<title>DANA — админка</title> <title>DANA — админка</title>
<link rel="stylesheet" href="admin.css?v=20261014-share"> <link rel="stylesheet" href="admin.css?v=20261014-share">
<link rel="stylesheet" href="weekly-experiment.css?v=20261005-corners2"> <link rel="stylesheet" href="weekly-experiment.css?v=20261005-corners2">
<link rel="stylesheet" href="leads.css"> <link rel="stylesheet" href="leads.css?v=20261023-refresh">
<link rel="stylesheet" href="onec.css"> <link rel="stylesheet" href="onec.css">
<link rel="stylesheet" href="reports-history.css"> <link rel="stylesheet" href="reports-history.css">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
@ -93,8 +93,9 @@
<label class="ld-search"><span aria-hidden="true">⌕</span><input type="search" id="ld-q" placeholder="Поиск по имени, телефону или сообщению…" aria-label="Поиск заявок"></label> <label class="ld-search"><span aria-hidden="true">⌕</span><input type="search" id="ld-q" placeholder="Поиск по имени, телефону или сообщению…" aria-label="Поиск заявок"></label>
<div class="ld-date"><label><span>С</span><input id="ld-from" type="date" aria-label="Дата с"></label><label><span>По</span><input id="ld-to" type="date" aria-label="Дата по"></label></div> <div class="ld-date"><label><span>С</span><input id="ld-from" type="date" aria-label="Дата с"></label><label><span>По</span><input id="ld-to" type="date" aria-label="Дата по"></label></div>
<button class="ld-export" id="ld-export" type="button" title="Экспортировать показанные заявки в CSV"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 17v3h16v-3"/></svg><span>Экспорт страницы</span></button> <button class="ld-export" id="ld-export" type="button" title="Экспортировать показанные заявки в CSV"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 17v3h16v-3"/></svg><span>Экспорт страницы</span></button>
<button class="ld-refresh" id="ld-refresh" type="button" aria-label="Обновить заявки" title="Обновить заявки">↻</button> <button class="ld-refresh" id="ld-refresh" type="button" aria-label="Обновить заявки" title="Обновить заявки"><span aria-hidden="true">↻</span></button>
</div> </div>
<p id="ld-updated" class="ld-updated" role="status" aria-live="polite"></p>
<div class="ld-table-wrap"><table class="ld-table"> <div class="ld-table-wrap"><table class="ld-table">
<thead><tr><th>Дата</th><th>Имя</th><th>Телефон</th><th>Форма</th><th>Сообщение</th><th>Статус</th><th><span class="visually-hidden">Действие</span></th></tr></thead> <thead><tr><th>Дата</th><th>Имя</th><th>Телефон</th><th>Форма</th><th>Сообщение</th><th>Статус</th><th><span class="visually-hidden">Действие</span></th></tr></thead>
<tbody id="ld-body"></tbody> <tbody id="ld-body"></tbody>
@ -1717,8 +1718,7 @@ function editDoctor(d) {
{ key: 'bio', label: 'О враче — абзацы через пустую строку, пункты списка начинайте с «•»', value: d.bio, type: 'textarea', rows: 8 }, { key: 'bio', label: 'О враче — абзацы через пустую строку, пункты списка начинайте с «•»', value: d.bio, type: 'textarea', rows: 8 },
{ key: 'quote', label: 'Цитата врача (необязательно)', value: d.quote, type: 'textarea', rows: 2 }, { key: 'quote', label: 'Цитата врача (необязательно)', value: d.quote, type: 'textarea', rows: 2 },
{ key: 'booking_provider', label: 'Запись на приём', value: d.booking_provider || 'none', type: 'select', { key: 'booking_provider', label: 'Запись на приём', value: d.booking_provider || 'none', type: 'select',
options: [{ value: 'none', label: 'Заявка на сайте (перезвонит администратор)' }, { value: 'medflex', label: 'Онлайн-запись Medflex' }, { value: 'native', label: 'Своя запись' }] }, options: [{ value: 'none', label: 'Заявка на сайте (перезвонит администратор)' }, { value: 'native', label: 'Онлайн-запись через 1С' }] },
{ key: 'medflex_user', label: 'Ссылка Medflex (для онлайн-записи)', value: d.medflex_user },
{ key: 'absent', label: 'Временно не принимает (кнопка записи скрывается)', value: d.absent, type: 'checkbox' }, { key: 'absent', label: 'Временно не принимает (кнопка записи скрывается)', value: d.absent, type: 'checkbox' },
{ key: 'show_on_home', label: 'Показывать на главной', value: d.show_on_home, type: 'checkbox' }, { key: 'show_on_home', label: 'Показывать на главной', value: d.show_on_home, type: 'checkbox' },
{ key: 'sort', label: 'Порядок (меньше — выше в списке)', value: d.sort ?? 100, type: 'number' }, { key: 'sort', label: 'Порядок (меньше — выше в списке)', value: d.sort ?? 100, type: 'number' },
@ -2409,7 +2409,7 @@ function debounce(fn, ms) {
</script> </script>
<script src="dashboard.js"></script> <script src="dashboard.js"></script>
<script src="leads.js?v=20261014-wish"></script> <script src="leads.js?v=20261023-refresh"></script>
<script src="doctors.js?v=20261006-rating"></script> <script src="doctors.js?v=20261006-rating"></script>
<script src="doctors-dash.js"></script> <script src="doctors-dash.js"></script>
<script src="banners.js"></script> <script src="banners.js"></script>

View File

@ -43,6 +43,14 @@ body.leads-view .page-title, body.leads-view .page-sub, body.leads-view .subtabs
.ld-export svg { width: 21px; height: 21px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } .ld-export svg { width: 21px; height: 21px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.ld-export:disabled { opacity: .5; cursor: not-allowed; } .ld-export:disabled { opacity: .5; cursor: not-allowed; }
.ld-refresh { color: #324f70; background: #f2f7ff; border: 1px solid #d7e4f5; font-size: 26px; } .ld-refresh { color: #324f70; background: #f2f7ff; border: 1px solid #d7e4f5; font-size: 26px; }
.ld-refresh span { display: inline-block; }
.ld-refresh[aria-busy="true"] span { animation: ld-refresh-spin .8s linear infinite; }
.ld-refresh:disabled { opacity: .65; cursor: wait; }
@keyframes ld-refresh-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .ld-refresh[aria-busy="true"] span { animation: none; } }
.ld-updated { margin: -9px 0 13px; color: #637891; font-size: 12px; }
.ld-updated:empty { display: none; }
.ld-updated--error { padding: 10px 12px; color: #92291f; background: #ffe3df; border-radius: 9px; }
.ld-table-wrap { overflow-x: auto; border: 1px solid #e3edfa; border-radius: 11px; } .ld-table-wrap { overflow-x: auto; border: 1px solid #e3edfa; border-radius: 11px; }
.ld-table { min-width: 830px; } .ld-table { min-width: 830px; }
.ld-table th { padding: 13px 19px; background: #f2f7ff; color: #526782; font-size: 12px; font-weight: 600; } .ld-table th { padding: 13px 19px; background: #f2f7ff; color: #526782; font-size: 12px; font-weight: 600; }

View File

@ -2,17 +2,52 @@
Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */ Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */
const Leads = { const Leads = {
offset: 0, limit: 30, total: 0, loaded: false, visible: [], offset: 0, limit: 30, total: 0, loaded: false, visible: [], requestId: 0, controller: null,
async load() { async load({ notify = false } = {}) {
const requestId = ++Leads.requestId;
Leads.controller?.abort();
const controller = new AbortController();
Leads.controller = controller;
const refresh = $('#ld-refresh'), updated = $('#ld-updated');
refresh.disabled = true;
refresh.setAttribute('aria-busy', 'true');
$('#ld-body').setAttribute('aria-busy', 'true');
updated.classList.remove('ld-updated--error');
updated.textContent = 'Обновляем заявки…';
let timedOut = false;
const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 20000);
const status = $('#ld-status').value; const status = $('#ld-status').value;
const params = new URLSearchParams({ status, q: $('#ld-q').value.trim(), limit: Leads.limit, offset: Leads.offset }); const params = new URLSearchParams({ status, q: $('#ld-q').value.trim(), limit: Leads.limit, offset: Leads.offset });
if ($('#ld-from').value) params.set('from', $('#ld-from').value); if ($('#ld-from').value) params.set('from', $('#ld-from').value);
if ($('#ld-to').value) params.set('to', $('#ld-to').value); if ($('#ld-to').value) params.set('to', $('#ld-to').value);
let r;
try { try {
r = await api(`/admin/leads?${params}`); const r = await api(`/admin/leads?${params}`, { signal: controller.signal, cache: 'no-store' });
} catch (e) { toast(e.message, true); return; } if (requestId !== Leads.requestId || controller.signal.aborted) return false;
Leads.render(r, status);
Leads.loaded = true;
updated.textContent = 'Обновлено в ' + new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (notify) toast('Список заявок обновлён');
return true;
} catch (e) {
if (requestId !== Leads.requestId) return false;
const message = timedOut ? 'Истекло время ожидания. Попробуйте обновить заявки ещё раз.' : e.message;
updated.classList.add('ld-updated--error');
updated.textContent = `Не удалось обновить заявки. ${message}${Leads.loaded ? ' Показаны предыдущие данные.' : ''}`;
toast(message, true);
return false;
} finally {
clearTimeout(timeout);
if (requestId === Leads.requestId) {
Leads.controller = null;
refresh.disabled = false;
refresh.setAttribute('aria-busy', 'false');
$('#ld-body').setAttribute('aria-busy', 'false');
}
}
},
render(r, status) {
Leads.total = r.meta?.total || 0; Leads.total = r.meta?.total || 0;
const counts = r.meta?.counts || {}; const counts = r.meta?.counts || {};
for (const [id, value] of [['ld-new', counts.new || 0], ['ld-new-badge', counts.new || 0], for (const [id, value] of [['ld-new', counts.new || 0], ['ld-new-badge', counts.new || 0],
@ -148,7 +183,7 @@ $('#ld-dlg').addEventListener('click', (e) => { if (e.target === e.currentTarget
$('#ld-status').onchange = () => { Leads.offset = 0; Leads.load(); }; $('#ld-status').onchange = () => { Leads.offset = 0; Leads.load(); };
$('#ld-q').addEventListener('input', debounce(() => { Leads.offset = 0; Leads.load(); }, 300)); $('#ld-q').addEventListener('input', debounce(() => { Leads.offset = 0; Leads.load(); }, 300));
for (const id of ['ld-from', 'ld-to']) $('#' + id).onchange = () => { Leads.offset = 0; Leads.load(); }; for (const id of ['ld-from', 'ld-to']) $('#' + id).onchange = () => { Leads.offset = 0; Leads.load(); };
$('#ld-refresh').onclick = () => Leads.load(); $('#ld-refresh').onclick = () => { Leads.offset = 0; return Leads.load({ notify: true }); };
$('#ld-export').onclick = () => Leads.exportVisible(); $('#ld-export').onclick = () => Leads.exportVisible();
$('#ld-prev').onclick = () => { Leads.offset = Math.max(0, Leads.offset - Leads.limit); Leads.load(); }; $('#ld-prev').onclick = () => { Leads.offset = Math.max(0, Leads.offset - Leads.limit); Leads.load(); };
$('#ld-next').onclick = () => { Leads.offset += Leads.limit; Leads.load(); }; $('#ld-next').onclick = () => { Leads.offset += Leads.limit; Leads.load(); };

View File

@ -165,7 +165,7 @@ const Onec = {
if (!r.doctor_id) { if (!r.doctor_id) {
way.append(ocEl('span', { className: 'muted', textContent: 'сначала привяжите карточку' })); way.append(ocEl('span', { className: 'muted', textContent: 'сначала привяжите карточку' }));
} else { } else {
const label = { native: '1С', medflex: 'Medflex', none: 'заявка', '': 'заявка' }[r.booking] || r.booking; const label = { native: '1С', none: 'заявка', '': 'заявка' }[r.booking] || 'заявка';
way.append(ocEl('span', { className: 'pill ' + (bookingNative ? '' : 'grey'), textContent: label })); way.append(ocEl('span', { className: 'pill ' + (bookingNative ? '' : 'grey'), textContent: label }));
const b = ocEl('button', { type: 'button', className: 'btn ghost oc-small', textContent: bookingNative ? 'Отключить' : 'Включить запись через 1С' }); const b = ocEl('button', { type: 'button', className: 'btn ghost oc-small', textContent: bookingNative ? 'Отключить' : 'Включить запись через 1С' });
b.onclick = () => Onec.setBooking(r, !bookingNative); b.onclick = () => Onec.setBooking(r, !bookingNative);

View File

@ -90,6 +90,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/doctors", a.doctors) mux.HandleFunc("GET /api/v1/doctors", a.doctors)
mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor) mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor)
mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots) mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots)
mux.HandleFunc("GET /api/v1/booking/availability", a.bookingAvailability)
mux.HandleFunc("GET /api/v1/prices", a.prices) mux.HandleFunc("GET /api/v1/prices", a.prices)
mux.HandleFunc("GET /api/v1/price-sections", a.priceSections) mux.HandleFunc("GET /api/v1/price-sections", a.priceSections)

View File

@ -0,0 +1,21 @@
package api
import (
"net/http"
"dana/internal/storage"
)
func (a *API) bookingAvailability(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !a.onecAutonomousEnabled() || !a.store.OnecFresh(r.Context(), onecStaleAfter) {
ok(w, map[string]any{"enabled": false, "days": []storage.BookingDay{}}, nil)
return
}
days, err := a.store.PublicBookingDays(r.Context())
if err != nil {
handleErr(w, err)
return
}
ok(w, map[string]any{"enabled": true, "days": days}, nil)
}

View File

@ -0,0 +1,24 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"dana/internal/storage"
)
func TestDoctorRejectsRemovedExternalBookingProvider(t *testing.T) {
app := &API{}
r := httptest.NewRequest(http.MethodPut, "/api/v1/admin/doctors/1", strings.NewReader(`{"last_name":"Тест","slug":"test","booking_provider":"medflex","medflex_user":"https://booking.medflex.ru/example"}`))
r.SetPathValue("id", "1")
w := httptest.NewRecorder()
app.adminUpdateDoctor(w, r, &storage.AdminUser{})
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("removed provider accepted: %d %s", w.Code, w.Body.String())
}
if len(bookingProviders) != 2 || !bookingProviders["native"] || !bookingProviders["none"] {
t.Fatal("only on-site appointment methods should remain")
}
}

View File

@ -62,18 +62,18 @@ func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) {
off() off()
return return
} }
from, to := onecWindow() from, _ := onecWindow()
slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, to) // Показываем все реальные будущие окна из принятых снимков 1С.
// Нулевая верхняя граница не ограничивает даты в SQL.
slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, time.Time{})
if err != nil { if err != nil {
handleErr(w, err) handleErr(w, err)
return return
} }
services, err := a.store.OnecServices(r.Context(), onecID) // The current import duplicates one mixed catalog across doctors of unrelated
if err != nil { // specialties. Keep it in storage for reconciliation/history, but never present
handleErr(w, err) // it as a verified list of this doctor's procedures (including to old clients).
return ok(w, map[string]any{"enabled": true, "slots": slots, "services": []onec.Service{}}, map[string]any{"total": len(slots)})
}
ok(w, map[string]any{"enabled": true, "slots": slots, "services": services}, map[string]any{"total": len(slots)})
} }
type appointmentInput struct { type appointmentInput struct {
@ -139,10 +139,16 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
return return
} }
start, err := time.Parse(time.RFC3339, in.Start) start, err := time.Parse(time.RFC3339, in.Start)
if err != nil || start.Before(time.Now()) || start.After(time.Now().AddDate(0, 0, onecHorizonDays+2)) { if err != nil || start.Before(time.Now()) {
fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз") fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз")
return return
} }
// A previously loaded form must not attach a procedure from the unverified
// catalog. Do not silently discard a choice the patient believes was recorded.
if in.ServiceID != "" {
fail(w, http.StatusUnprocessableEntity, "service_unavailable", "Список услуг обновлён. Закройте и снова откройте форму, чтобы записаться к врачу без выбора услуги.")
return
}
if !a.onecAutonomousEnabled() { if !a.onecAutonomousEnabled() {
fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку") fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку")
return return
@ -269,8 +275,8 @@ func (a *API) repeatAppointment(w http.ResponseWriter, rec storage.OnecAppointme
// slotTaken — окно заняли: отдаём 409 и свежий список окон, чтобы пациент выбрал другое. // slotTaken — окно заняли: отдаём 409 и свежий список окон, чтобы пациент выбрал другое.
func (a *API) slotTaken(w http.ResponseWriter, r *http.Request, rec storage.OnecAppointment, onecID, reason string) { func (a *API) slotTaken(w http.ResponseWriter, r *http.Request, rec storage.OnecAppointment, onecID, reason string) {
_ = a.store.FinishOnecAppointment(context.Background(), rec.ID, "failed", "", "slot_busy: "+reason, nil, nil) _ = a.store.FinishOnecAppointment(context.Background(), rec.ID, "failed", "", "slot_busy: "+reason, nil, nil)
from, to := onecWindow() from, _ := onecWindow()
slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, to) slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, time.Time{})
if slots == nil { if slots == nil {
slots = []storage.OnecSlot{} slots = []storage.OnecSlot{}
} }

View File

@ -0,0 +1,120 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"os"
"slices"
"testing"
"time"
"dana/internal/onec"
"dana/internal/storage"
)
// Uses only a disposable database; no real 1C calls or patient records.
func TestAutonomousAllActualFutureDates(t *testing.T) {
dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN")
if dsn == "" {
t.Skip("requires disposable autonomy DB")
}
ctx := context.Background()
st, err := storage.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err = st.Migrate(ctx); err != nil {
t.Fatal(err)
}
app := &API{store: st, limiter: newRateLimiter(), onecHook: "all-dates-test"}
mux := app.Routes()
call := func(method, path string, body any, want int) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(body)
r := httptest.NewRequest(method, path, bytes.NewReader(b))
r.Header.Set("X-Api-Key", "all-dates-test")
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
if w.Code != want {
t.Fatalf("%s %s: %d, want %d: %s", method, path, w.Code, want, w.Body.String())
}
return w
}
doctor := fmt.Sprintf("all-dates-%d", time.Now().UnixNano())
var doctorID int64
if err = st.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider) VALUES($1,'Тест','Все даты','native') RETURNING id`, doctor).Scan(&doctorID); err != nil {
t.Fatal(err)
}
if _, err = st.Pool().Exec(ctx, `INSERT INTO onec_doctors(onec_id,full_name,doctor_id) VALUES($1,'Тест Все даты',$2)`, doctor, doctorID); err != nil {
t.Fatal(err)
}
if err = st.SetBookingSettings(ctx, storage.BookingSettings{}); err != nil {
t.Fatal(err)
}
from := time.Now().UTC().Truncate(time.Hour)
far := from.AddDate(0, 0, 90)
end := far.Add(30 * time.Minute)
to := end.Add(time.Hour)
slot := onec.Slot{DoctorID: doctor, BranchID: "branch", Start: far, End: end, Status: "free"}
payload := onecSyncPayload{From: &from, To: &to, Branches: []onec.Branch{}, Doctors: []onec.Doctor{}, Services: []onecDoctorServices{}, Slots: []onec.Slot{slot}}
call("POST", "/api/v1/onec/sync/all", payload, 200)
shown := call("GET", "/api/v1/doctors/"+doctor+"/slots", nil, 200)
var result struct {
Data struct {
Enabled bool `json:"enabled"`
Slots []storage.OnecSlot `json:"slots"`
} `json:"data"`
}
if err = json.Unmarshal(shown.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if !result.Data.Enabled || len(result.Data.Slots) != 1 || !result.Data.Slots[0].Start.Equal(far) {
t.Fatalf("actual distant slot hidden: %s", shown.Body.String())
}
checkAvailability := func(want bool) {
t.Helper()
w := call("GET", "/api/v1/booking/availability", nil, 200)
var availability struct {
Data struct {
Enabled bool `json:"enabled"`
Days []storage.BookingDay `json:"days"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &availability); err != nil {
t.Fatal(err)
}
found := false
for _, day := range availability.Data.Days {
if day.Date == far.In(moscow).Format("2006-01-02") && slices.Contains(day.DoctorIDs, doctorID) {
found = true
}
}
if !availability.Data.Enabled || found != want {
t.Fatalf("date-to-doctor availability mismatch: %s", w.Body.String())
}
}
checkAvailability(true)
creation := appointmentInput{DoctorSlug: doctor, Start: far.Format(time.RFC3339), Name: "Test", Phone: "79990000001", Consent: true, RequestID: doctor}
made := call("POST", "/api/v1/appointments", creation, 201)
if !bytes.Contains(made.Body.Bytes(), []byte(`"mode":"pending"`)) {
t.Fatal("distant actual slot not queued")
}
// A push claiming free must not reopen the pending reservation.
call("POST", "/api/v1/onec/sync/all", payload, 200)
hidden := call("GET", "/api/v1/doctors/"+doctor+"/slots", nil, 200)
if err = json.Unmarshal(hidden.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if len(result.Data.Slots) != 0 {
t.Fatal("reserved distant slot reopened")
}
checkAvailability(false)
// Removing the calendar cutoff must not permit invented dates.
creation.Start = far.Add(time.Hour).Format(time.RFC3339)
creation.RequestID = doctor + "-invented"
call("POST", "/api/v1/appointments", creation, 409)
}

View File

@ -72,7 +72,9 @@ func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) {
} else if len(payload.Slots) > 0 { } else if len(payload.Slots) > 0 {
from, to = findSlotBounds(payload.Slots) from, to = findSlotBounds(payload.Slots)
} }
if !from.Before(to) || to.Sub(from) > 32*24*time.Hour { // Даты определяет фактический снимок 1С, а не произвольный горизонт.
// Авторизация, лимит тела и проверка каждого слота остаются обязательными.
if !from.Before(to) {
fail(w, 400, "validation", "Некорректный диапазон снимка") fail(w, 400, "validation", "Некорректный диапазон снимка")
return return
} }

View File

@ -0,0 +1,115 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"dana/internal/onec"
"dana/internal/storage"
)
func TestAppointmentRejectsUnverifiedService(t *testing.T) {
a := &API{limiter: newRateLimiter()}
in := appointmentInput{DoctorSlug: "fictional-dentist", Start: time.Now().Add(48 * time.Hour).Format(time.RFC3339), Name: "Fictional patient", Phone: "79990000000", Consent: true, RequestID: "fictional-request", ServiceID: "unverified-service"}
body, _ := json.Marshal(in)
w := httptest.NewRecorder()
a.createAppointment(w, httptest.NewRequest(http.MethodPost, "/api/v1/appointments", bytes.NewReader(body)))
if w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), `"code":"service_unavailable"`) {
t.Fatalf("unverified service was not rejected before storage access: %d %s", w.Code, w.Body.String())
}
in.ServiceID = ""
body, _ = json.Marshal(in)
w = httptest.NewRecorder()
a.createAppointment(w, httptest.NewRequest(http.MethodPost, "/api/v1/appointments", bytes.NewReader(body)))
if w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), `"code":"onec_disabled"`) {
t.Fatalf("booking without service must continue to normal availability checks: %d %s", w.Code, w.Body.String())
}
}
// Uses only a disposable database, never production or a real 1C server.
func TestDoctorServicesQuarantine(t *testing.T) {
dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN")
if dsn == "" {
t.Skip("requires disposable autonomy DB")
}
ctx := context.Background()
st, err := storage.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err = st.Migrate(ctx); err != nil {
t.Fatal(err)
}
doctor := fmt.Sprintf("service-guard-%d", time.Now().UnixNano())
var doctorID int64
if err = st.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider) VALUES($1,'Test','Dentist','native') RETURNING id`, doctor).Scan(&doctorID); err != nil {
t.Fatal(err)
}
if _, err = st.Pool().Exec(ctx, `INSERT INTO onec_doctors(onec_id,full_name,doctor_id) VALUES($1,'Test Dentist',$2)`, doctor, doctorID); err != nil {
t.Fatal(err)
}
if err = st.SaveOnecServices(ctx, doctor, []onec.Service{{ID: "unverified-lab", Name: "Fictional unrelated lab", DurationMin: 30, Price: 100}}); err != nil {
t.Fatal(err)
}
if err = st.SetBookingSettings(ctx, storage.BookingSettings{}); err != nil {
t.Fatal(err)
}
start := time.Now().UTC().Truncate(time.Hour).Add(72 * time.Hour)
end := start.Add(30 * time.Minute)
if err = st.AcceptOnecSlots(ctx, start.Add(-time.Hour), end.Add(time.Hour), []onec.Slot{{DoctorID: doctor, BranchID: "branch", Start: start, End: end, Status: "free"}}); err != nil {
t.Fatal(err)
}
app := &API{store: st, limiter: newRateLimiter(), onecHook: "fictional-service-guard"}
mux := app.Routes()
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/doctors/"+doctor+"/slots", nil))
var result struct {
Data struct {
Enabled bool `json:"enabled"`
Slots []storage.OnecSlot `json:"slots"`
Services []onec.Service `json:"services"`
} `json:"data"`
}
if err = json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if w.Code != 200 || !result.Data.Enabled || len(result.Data.Slots) != 1 || result.Data.Services == nil || len(result.Data.Services) != 0 {
t.Fatalf("unverified catalog leaked or real slot disappeared: %d %s", w.Code, w.Body.String())
}
raw, err := st.OnecServices(ctx, doctor)
if err != nil || len(raw) != 1 {
t.Fatalf("raw import must remain intact for reconciliation: %+v %v", raw, err)
}
in := appointmentInput{DoctorSlug: doctor, Start: start.Format(time.RFC3339), Name: "Fictional patient", Phone: "79990000000", Consent: true, RequestID: doctor, BranchID: "branch"}
body, _ := json.Marshal(in)
w = httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v1/appointments", bytes.NewReader(body)))
if w.Code != http.StatusCreated || !bytes.Contains(w.Body.Bytes(), []byte(`"mode":"pending"`)) {
t.Fatalf("actual slot cannot be booked without service: %d %s", w.Code, w.Body.String())
}
pending, err := st.PendingOnecAppointments(ctx, 50)
if err != nil {
t.Fatal(err)
}
found := false
for _, record := range pending {
if record.RequestID == doctor {
found = true
if record.ServiceID != "" {
t.Fatal("unverified service was attached to pending appointment")
}
}
}
if !found {
t.Fatal("booking missing from test pending queue")
}
}

View File

@ -80,8 +80,7 @@ type DoctorService struct {
} }
type Booking struct { type Booking struct {
Provider string `json:"provider"` // none | native | medflex Provider string `json:"provider"` // none | native
URL string `json:"url,omitempty"`
} }
type BioEntry struct { type BioEntry struct {

View File

@ -22,7 +22,6 @@ type AdminDoctor struct {
SpecialtyID *int64 `json:"specialty_id"` SpecialtyID *int64 `json:"specialty_id"`
SpecialtySlug string `json:"specialty_slug"` SpecialtySlug string `json:"specialty_slug"`
Booking string `json:"booking_provider"` Booking string `json:"booking_provider"`
MedflexUser string `json:"medflex_user"`
Sort int `json:"sort"` Sort int `json:"sort"`
Published bool `json:"published"` Published bool `json:"published"`
Absent bool `json:"absent"` Absent bool `json:"absent"`
@ -48,7 +47,7 @@ func (s *Storage) AdminDoctors(ctx context.Context, q string) ([]AdminDoctor, er
WHERE ds.doctor_id = d.id WHERE ds.doctor_id = d.id
), ''), ), ''),
ps.id, COALESCE(ps.slug, ''), ps.id, COALESCE(ps.slug, ''),
d.booking_provider, d.medflex_user, d.sort, d.published, d.booking_provider, d.sort, d.published,
d.absent, d.show_on_home, d.absent, d.show_on_home,
(SELECT count(*) FROM doctor_bio_entries e WHERE e.doctor_id = d.id), (SELECT count(*) FROM doctor_bio_entries e WHERE e.doctor_id = d.id),
d.seo_title, d.seo_description, d.seo_h1, d.seo_title, d.seo_description, d.seo_h1,
@ -79,11 +78,14 @@ func (s *Storage) AdminDoctors(ctx context.Context, q string) ([]AdminDoctor, er
&a.Position, &a.Category, &a.Degree, &a.PracticeYear, &a.Rating, &a.Position, &a.Category, &a.Degree, &a.PracticeYear, &a.Rating,
&a.PhotoID, &a.Photo, &a.Quote, &a.Bio, &a.Specialties, &a.PhotoID, &a.Photo, &a.Quote, &a.Bio, &a.Specialties,
&a.SpecialtyID, &a.SpecialtySlug, &a.SpecialtyID, &a.SpecialtySlug,
&a.Booking, &a.MedflexUser, &a.Sort, &a.Published, &a.Booking, &a.Sort, &a.Published,
&a.Absent, &a.ShowOnHome, &a.BioCount, &a.Absent, &a.ShowOnHome, &a.BioCount,
&a.SEOTitle, &a.SEODesc, &a.SEOH1, &a.ServiceIDs, &a.BranchIDs); err != nil { &a.SEOTitle, &a.SEODesc, &a.SEOH1, &a.ServiceIDs, &a.BranchIDs); err != nil {
return nil, err return nil, err
} }
if a.Booking != "native" {
a.Booking = "none"
}
out = append(out, a) out = append(out, a)
} }
return out, rows.Err() return out, rows.Err()
@ -103,7 +105,6 @@ type DoctorUpdate struct {
Quote string `json:"quote"` Quote string `json:"quote"`
Bio string `json:"bio"` Bio string `json:"bio"`
Booking string `json:"booking_provider"` Booking string `json:"booking_provider"`
MedflexUser string `json:"medflex_user"`
Sort int `json:"sort"` Sort int `json:"sort"`
Published bool `json:"published"` Published bool `json:"published"`
Absent bool `json:"absent"` Absent bool `json:"absent"`
@ -123,7 +124,7 @@ func (s *Storage) AdminUpdateDoctor(ctx context.Context, id int64, in DoctorUpda
WHERE id=$1 AND deleted_at IS NULL`, WHERE id=$1 AND deleted_at IS NULL`,
id, in.Slug, in.LastName, in.FirstName, in.MiddleName, id, in.Slug, in.LastName, in.FirstName, in.MiddleName,
in.Position, in.Category, in.Degree, in.PracticeYear, in.Rating, in.Position, in.Category, in.Degree, in.PracticeYear, in.Rating,
in.Quote, in.Bio, in.Booking, in.MedflexUser, in.Quote, in.Bio, in.Booking, "",
in.Sort, in.Published, in.Absent, in.ShowOnHome, in.Sort, in.Published, in.Absent, in.ShowOnHome,
in.SEOTitle, in.SEODesc, in.SEOH1, in.PhotoID) in.SEOTitle, in.SEODesc, in.SEOH1, in.PhotoID)
if err != nil { if err != nil {

View File

@ -0,0 +1,37 @@
package storage
import "context"
type BookingDay struct {
Date string `json:"date"`
DoctorIDs []int64 `json:"doctor_ids"`
}
// PublicBookingDays returns only dates and public doctor IDs, never patient data.
// Keep the occupancy rules identical to FreeOnecSlots, including cross-branch reservations.
func (s *Storage) PublicBookingDays(ctx context.Context) ([]BookingDay, error) {
rows, err := s.pool.Query(ctx, `
SELECT to_char(sl.start_at AT TIME ZONE 'Europe/Moscow','YYYY-MM-DD'),
array_agg(DISTINCT d.id ORDER BY d.id)
FROM onec_slots sl JOIN onec_doctors od ON od.onec_id=sl.doctor_onec_id
JOIN doctors d ON d.id=od.doctor_id
WHERE sl.status='free' AND sl.start_at>now() AND od.active
AND d.published AND d.deleted_at IS NULL AND NOT d.absent AND d.booking_provider='native'
AND NOT EXISTS(SELECT 1 FROM onec_appointments a
WHERE a.doctor_onec_id=sl.doctor_onec_id AND a.status IN('pending','booked','arrived')
AND a.start_at<sl.end_at AND COALESCE(a.end_at,a.start_at+interval '30 minutes')>sl.start_at)
GROUP BY 1 ORDER BY 1`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []BookingDay{}
for rows.Next() {
var day BookingDay
if err := rows.Scan(&day.Date, &day.DoctorIDs); err != nil {
return nil, err
}
out = append(out, day)
}
return out, rows.Err()
}

View File

@ -0,0 +1,74 @@
package storage
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
)
func TestDoctorExternalBookingCannotLeakOrReturn(t *testing.T) {
dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN")
if dsn == "" {
t.Skip("requires disposable autonomy database")
}
ctx := context.Background()
s, err := New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err = s.Migrate(ctx); err != nil {
t.Fatal(err)
}
slug := fmt.Sprintf("legacy-external-%d", time.Now().UnixNano())
var id int64
if err = s.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider,medflex_user) VALUES($1,'Тест','Врач','medflex','https://booking.medflex.ru/legacy') RETURNING id`, slug).Scan(&id); err != nil {
t.Fatal(err)
}
d, err := s.DoctorBySlug(ctx, slug)
if err != nil {
t.Fatal(err)
}
public, _ := json.Marshal(d)
if d.Booking.Provider != "none" || strings.Contains(string(public), "medflex") {
t.Fatalf("legacy link leaked: %s", public)
}
rows, err := s.AdminDoctors(ctx, slug)
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatal("slug must not match the name filter")
}
rows, err = s.AdminDoctors(ctx, "")
if err != nil {
t.Fatal(err)
}
found := false
for _, row := range rows {
if row.ID == id {
found = true
admin, _ := json.Marshal(row)
if row.Booking != "none" || strings.Contains(string(admin), "medflex") {
t.Fatalf("legacy option leaked in admin: %s", admin)
}
}
}
if !found {
t.Fatal("doctor missing from admin")
}
if err = s.AdminUpdateDoctor(ctx, id, DoctorUpdate{Slug: slug, LastName: "Тест", FirstName: "Врач", Booking: "none", Published: true}); err != nil {
t.Fatal(err)
}
var link string
if err = s.Pool().QueryRow(ctx, `SELECT medflex_user FROM doctors WHERE id=$1`, id).Scan(&link); err != nil {
t.Fatal(err)
}
if link != "" {
t.Fatal("editing doctor restored an external link")
}
}

View File

@ -16,7 +16,7 @@ import (
// приоритет, а при пустом значении используется средняя оценка отзывов. // приоритет, а при пустом значении используется средняя оценка отзывов.
const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d.position, const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d.position,
d.category, d.degree, d.practice_start_year, COALESCE(m.path, ''), d.quote, d.category, d.degree, d.practice_start_year, COALESCE(m.path, ''), d.quote,
d.languages, d.accepts_kids_from, d.booking_provider, d.medflex_user, d.absent, d.languages, d.accepts_kids_from, d.booking_provider, d.absent,
d.seo_title, d.seo_description, d.seo_h1, d.seo_title, d.seo_description, d.seo_h1,
COALESCE(d.rating_override::float8, COALESCE(d.rating_override::float8,
(SELECT round(avg(r.rating)::numeric, 1)::float8 FROM reviews r (SELECT round(avg(r.rating)::numeric, 1)::float8 FROM reviews r
@ -24,15 +24,12 @@ const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d
(SELECT count(*) FROM reviews r (SELECT count(*) FROM reviews r
WHERE r.doctor_id = d.id AND r.status = 'published')` WHERE r.doctor_id = d.id AND r.status = 'published')`
const medflexBase = "https://booking.medflex.ru/?user="
func scanDoctor(row pgx.Row) (domain.Doctor, error) { func scanDoctor(row pgx.Row) (domain.Doctor, error) {
var d domain.Doctor var d domain.Doctor
var startYear *int var startYear *int
var medflexUser string
err := row.Scan(&d.ID, &d.Slug, &d.LastName, &d.FirstName, &d.MiddleName, &d.Position, err := row.Scan(&d.ID, &d.Slug, &d.LastName, &d.FirstName, &d.MiddleName, &d.Position,
&d.Category, &d.Degree, &startYear, &d.Photo, &d.Quote, &d.Category, &d.Degree, &startYear, &d.Photo, &d.Quote,
&d.Languages, &d.KidsFrom, &d.Booking.Provider, &medflexUser, &d.Absent, &d.Languages, &d.KidsFrom, &d.Booking.Provider, &d.Absent,
&d.SEO.Title, &d.SEO.Description, &d.SEO.H1, &d.SEO.Title, &d.SEO.Description, &d.SEO.H1,
&d.Rating, &d.ReviewCount) &d.Rating, &d.ReviewCount)
if err != nil { if err != nil {
@ -46,14 +43,8 @@ func scanDoctor(row pgx.Row) (domain.Doctor, error) {
d.Experience = &years d.Experience = &years
} }
} }
if d.Booking.Provider == "medflex" && medflexUser != "" { if d.Booking.Provider != "native" {
// При переносе со старого сайта в поле попала ссылка целиком; d.Booking.Provider = "none"
// через админку можно ввести и только идентификатор.
if strings.HasPrefix(medflexUser, "http://") || strings.HasPrefix(medflexUser, "https://") {
d.Booking.URL = medflexUser
} else {
d.Booking.URL = medflexBase + medflexUser
}
} }
return d, nil return d, nil
} }

View File

@ -253,14 +253,19 @@ func (s *Storage) OnecDoctorBySlug(ctx context.Context, slug string) (onecID str
// FreeOnecSlots — свободные окна врача в будущем; названия филиалов берутся из справочника 1С. // FreeOnecSlots — свободные окна врача в будущем; названия филиалов берутся из справочника 1С.
func (s *Storage) FreeOnecSlots(ctx context.Context, doctorOnecID string, from, to time.Time) ([]OnecSlot, error) { func (s *Storage) FreeOnecSlots(ctx context.Context, doctorOnecID string, from, to time.Time) ([]OnecSlot, error) {
var upper any
if !to.IsZero() {
upper = to
}
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT sl.start_at, sl.end_at, sl.branch_onec_id, COALESCE(NULLIF(b.address, ''), b.name, ''), sl.cabinet, sl.slot_id SELECT sl.start_at, sl.end_at, sl.branch_onec_id, COALESCE(NULLIF(b.address, ''), b.name, ''), sl.cabinet, sl.slot_id
FROM onec_slots sl LEFT JOIN onec_branches b ON b.onec_id = sl.branch_onec_id FROM onec_slots sl LEFT JOIN onec_branches b ON b.onec_id = sl.branch_onec_id
WHERE sl.doctor_onec_id=$1 AND sl.status='free' AND sl.start_at >= greatest($2::timestamptz, now()) AND sl.start_at < $3 AND NOT EXISTS ( WHERE sl.doctor_onec_id=$1 AND sl.status='free' AND sl.start_at >= greatest($2::timestamptz, now())
AND ($3::timestamptz IS NULL OR sl.start_at < $3) AND NOT EXISTS (
SELECT 1 FROM onec_appointments a WHERE a.doctor_onec_id=sl.doctor_onec_id SELECT 1 FROM onec_appointments a WHERE a.doctor_onec_id=sl.doctor_onec_id
AND a.status IN ('pending','booked','arrived') AND a.start_at < sl.end_at AND a.status IN ('pending','booked','arrived') AND a.start_at < sl.end_at
AND COALESCE(a.end_at,a.start_at+interval '30 minutes') > sl.start_at) AND COALESCE(a.end_at,a.start_at+interval '30 minutes') > sl.start_at)
ORDER BY sl.start_at, sl.branch_onec_id`, doctorOnecID, from, to) ORDER BY sl.start_at, sl.branch_onec_id`, doctorOnecID, from, upper)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -176,12 +176,14 @@ o = io.open('seed_price_1c.sql', 'w', encoding='utf-8')
o.write('BEGIN;\n') o.write('BEGIN;\n')
o.write("DELETE FROM price_items WHERE section_id=(SELECT id FROM price_sections WHERE slug='edinyy-svereno');\n") o.write("DELETE FROM price_items WHERE section_id=(SELECT id FROM price_sections WHERE slug='edinyy-svereno');\n")
o.write("DELETE FROM price_sections WHERE slug='edinyy-svereno';\n") o.write("DELETE FROM price_sections WHERE slug='edinyy-svereno';\n")
# Internal 1C nomenclature is not an approved public price list.
# New sections and prices stay draft until explicitly approved by the clinic.
for slug, name in NEW_SECTIONS: for slug, name in NEW_SECTIONS:
o.write("INSERT INTO price_sections (slug,name,sort,published) VALUES (%s,%s,100,true) " o.write("INSERT INTO price_sections (slug,name,sort,published) VALUES (%s,%s,100,false) "
"ON CONFLICT (slug) DO NOTHING;\n" % (esc(slug), esc(name))) "ON CONFLICT (slug) DO NOTHING;\n" % (esc(slug), esc(name)))
for i, (slug, name, price) in enumerate(mapped_rows): for i, (slug, name, price) in enumerate(mapped_rows):
o.write("INSERT INTO price_items (section_id,name,price,sort,published) " o.write("INSERT INTO price_items (section_id,name,price,sort,published) "
"SELECT id,%s,%s,%s,true FROM price_sections WHERE slug=%s;\n" % ( "SELECT id,%s,%s,%s,false FROM price_sections WHERE slug=%s;\n" % (
esc(name), price, i + 1, esc(slug))) esc(name), price, i + 1, esc(slug)))
o.write('COMMIT;\n') o.write('COMMIT;\n')
o.close() o.close()

View File

@ -56,10 +56,12 @@ for (slug, name), pricemap in groups.items():
o = io.open('seed_price_final.sql', 'w', encoding='utf-8') o = io.open('seed_price_final.sql', 'w', encoding='utf-8')
o.write('BEGIN;\n') o.write('BEGIN;\n')
o.write('DELETE FROM price_items;\n') # прайс теперь целиком из 3 файлов 1С o.write('DELETE FROM price_items;\n') # Legacy full replacement; never run without a separate approved plan.
# Importing an internal price is not permission to publish it on the website.
# Approval/publication must be separate from this offline SQL generator.
for i, (slug, name, price) in enumerate(rows_out): for i, (slug, name, price) in enumerate(rows_out):
o.write("INSERT INTO price_items (section_id,name,price,sort,published) " o.write("INSERT INTO price_items (section_id,name,price,sort,published) "
"SELECT id,%s,%s,%s,true FROM price_sections WHERE slug=%s;\n" % ( "SELECT id,%s,%s,%s,false FROM price_sections WHERE slug=%s;\n" % (
esc(name), price, i + 1, esc(slug))) esc(name), price, i + 1, esc(slug)))
o.write('COMMIT;\n') o.write('COMMIT;\n')
o.close() o.close()

View File

@ -0,0 +1,82 @@
// Embedded admin UI with a fake API and fictional leads; never connects to a server.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import { parseHTML } from 'linkedom';
const html = await readFile(new URL('../../internal/api/adminui/index.html', import.meta.url), 'utf8');
const source = await readFile(new URL('../../internal/api/adminui/leads.js', import.meta.url), 'utf8');
const { document } = parseHTML(html);
const $ = (selector) => document.querySelector(selector);
const select = $('#ld-status');
Object.defineProperty(select, 'options', { get: () => [...select.querySelectorAll('option')] });
function selectStatus(status) { for (const option of select.options) { if (option.value === status) option.setAttribute('selected', ''); else option.removeAttribute('selected'); } }
selectStatus('new');
const pending = [], notifications = [], timers = new Map();
let timerId = 0;
const context = vm.createContext({ document, $, URLSearchParams, AbortController, Date, Intl, console,
el: (tag, props = {}) => Object.assign(document.createElement(tag), props),
api: (path, options) => new Promise((resolve, reject) => pending.push({ path, options, resolve, reject })),
toast: (message, bad) => notifications.push({ message, bad }),
debounce: (fn) => fn, plural: () => 'заявок',
LEAD_STATUS: { new: ['Новая', 'blue'], done: ['Обработана', 'green'] },
setTimeout: (fn) => { const id = ++timerId; timers.set(id, fn); return id; },
clearTimeout: (id) => timers.delete(id),
});
vm.runInContext(source + '\nglobalThis.testLeads = Leads;', context);
const leads = context.testLeads;
const response = (name = 'Тестовая заявка') => ({ data: [{ id: 1, created_at: '2026-10-23T10:00:00+03:00', name, phone: '+79990000000', status: 'new', form_title: 'Тестовая форма', message: 'Вымышленные данные' }], meta: { total: 1, counts: { new: 1 }, today: 1 } });
leads.offset = 60;
const first = $('#ld-refresh').onclick();
assert.equal($('#ld-refresh').disabled, true, 'Refresh must visibly enter loading state');
assert.equal($('#ld-refresh').getAttribute('aria-busy'), 'true');
assert.match($('#ld-updated').textContent, /Обновляем/);
assert.equal(new URLSearchParams(pending[0].path.split('?')[1]).get('offset'), '0', 'Manual refresh returns to newest results');
assert.equal(pending[0].options.cache, 'no-store');
pending[0].resolve(response());
await first;
assert.match($('#ld-body').textContent, /Тестовая заявка/);
assert.equal($('#ld-refresh').disabled, false);
assert.match($('#ld-updated').textContent, /Обновлено/);
assert.ok(notifications.some((n) => n.message === 'Список заявок обновлён'));
assert.equal(timers.size, 0);
// Filters win over an older request even if a transport ignores cancellation.
const older = leads.load();
selectStatus('done');
const newer = leads.load();
assert.equal(pending[1].options.signal.aborted, true);
pending[2].resolve(response('Свежий ответ'));
await newer;
pending[1].resolve(response('УСТАРЕВШИЙ ответ'));
await older;
assert.match($('#ld-body').textContent, /Свежий ответ/);
assert.doesNotMatch($('#ld-body').textContent, /УСТАРЕВШИЙ/);
// Preserve filters, but reset pagination for an explicit refresh.
$('#ld-q').value = 'проверка'; $('#ld-from').value = '2026-10-01'; $('#ld-to').value = '2026-10-23';
leads.offset = 30;
const refresh = $('#ld-refresh').onclick();
const params = new URLSearchParams(pending[3].path.split('?')[1]);
assert.equal(params.get('status'), 'done'); assert.equal(params.get('q'), 'проверка');
assert.equal(params.get('from'), '2026-10-01'); assert.equal(params.get('to'), '2026-10-23');
pending[3].reject(new Error('Тестовая ошибка сервера'));
await refresh;
assert.equal($('#ld-refresh').disabled, false, 'Errors must release the button');
assert.match($('#ld-updated').textContent, /Не удалось/);
assert.match($('#ld-body').textContent, /Свежий ответ/, 'Failure must not erase the previous results');
assert.ok(notifications.some((n) => n.bad && n.message.includes('Тестовая ошибка')));
const retry = $('#ld-refresh').onclick();
pending.at(-1).resolve({ data: [], meta: { total: 0, counts: {} } });
await retry;
assert.match($('#ld-body').textContent, /Заявки не найдены/);
assert.equal($('#ld-refresh').disabled, false);
const timeout = $('#ld-refresh').onclick();
for (const fn of [...timers.values()]) fn();
pending.at(-1).reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
await timeout;
assert.equal($('#ld-refresh').disabled, false, 'Timeout cannot leave refresh stuck');
assert.match($('#ld-updated').textContent, /время ожидания/i);
assert.equal(timers.size, 0);
console.log('PASS: actual refresh handler, loading/success/error/timeout feedback, no-store requests, first page, retained filters and stale-response protection; no live lead changes');

View File

@ -0,0 +1,222 @@
// Изолированные UI-регрессии: только вымышленные врачи и окна, без сети и заявок.
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://booking-test.invalid/');
// linkedom is a modern DOM but does not advertise input-event support to React's detector.
document.oninput = null;
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { Simulate } = await import('react-dom/test-utils');
const { MemoryRouter } = await import('react-router-dom');
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx');
const { api } = await vite.ssrLoadModule('/src/api.js');
const { groupDoctors } = await vite.ssrLoadModule('/src/lib/doctorGroups.js');
const doctors = [
{ id: 1, slug: 'first', full_name: 'Первый врач', booking: { provider: 'native' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] },
{ id: 2, slug: 'second', full_name: 'Второй врач', booking: { provider: 'native' }, specialties: [{slug:'gyn',name_plural:'Гинекологи'}] },
{ id: 3, slug: 'fallback', full_name: 'Врач без расписания', booking: { provider: 'none' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] },
];
const pending = [];
let doctorCatalogRequests = 0;
api.doctors = async () => { doctorCatalogRequests++; return { data: doctors }; };
api.bookingAvailability = async () => ({ data: { enabled: true, days: [
{ date: '2070-10-23', doctor_ids: [1, 2] },
{ date: '2070-10-24', doctor_ids: [2] },
] } });
api.doctorSlots = (slug) => new Promise((resolve, reject) => pending.push({ slug, resolve, reject }));
api.createLead = api.createAppointment = () => { throw new Error('Real submissions are forbidden in this test'); };
const container = document.getElementById('root');
const root = createRoot(container);
let closeCalls = 0;
const render = async (props = {}) => act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(LeadModal, { ...props, onClose() { closeCalls++; } }))));
const text = () => container.textContent;
const times = () => [...container.querySelectorAll('.slot-chip--time')].map((e) => e.textContent);
const click = async (element) => act(async () => element.dispatchEvent(new window.Event('click', { bubbles: true })));
async function openPicker() {
if (container.querySelector('.doctor-picker__trigger').getAttribute('aria-expanded') !== 'true') await click(container.querySelector('.doctor-picker__trigger'));
}
async function availableDoctorIDs() {
await openPicker();
const ids = [...new Set([...container.querySelectorAll('[data-doctor-id]')].map((element) => element.dataset.doctorId))].sort();
await click(container.querySelector('.doctor-picker__trigger'));
return ids;
}
async function chooseDoctor(id) {
await openPicker();
if (id === '') { await click(container.querySelector('.doctor-picker__reset')); return; }
const doctor = doctors.find((entry) => entry.id === id);
await act(async () => Simulate.input(container.querySelector('.doctor-picker__search input'), {target:{value:doctor.full_name}}));
await click(container.querySelector(`[data-doctor-id="${id}"]`));
}
const free = (time) => ({ start: `2070-10-23T${time}:00+03:00`, status: 'free', branch_id: 'test', branch: 'Тестовый филиал' });
async function reply(request, slots, enabled = true) {
await act(async () => request.resolve({ data: { enabled, slots, services: [] } }));
}
try {
assert.deepEqual(groupDoctors(doctors).map((group) => group.name), ['Гинекологи','Детские врачи']);
assert.equal(groupDoctors(doctors, 'детские')[0].doctors.length, 2, 'Search can find a whole specialty');
assert.equal(groupDoctors(doctors, 'врач первый')[0].doctors[0].id, 1, 'Name tokens can be entered in any order');
assert.equal(groupDoctors(doctors, 'несуществующий').length, 0);
assert.equal(groupDoctors([{...doctors[0],specialties:[...doctors[0].specialties,...doctors[0].specialties]}])[0].doctors.length, 1, 'Duplicate specialty entries cannot duplicate a doctor');
await render();
assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor');
assert.ok(container.querySelector('input[type="date"]'));
assert.equal(container.querySelector('input[type="tel"]'), null);
assert.deepEqual(times(), []);
assert.doesNotMatch(text(), /Сначала выберите врача|Выберите дату или врача/);
assert.equal(container.querySelector('.lead-modal__availability'), null, 'The removed initial hint must not leave an empty panel');
assert.equal(container.querySelector('.lead-modal__availability--warning'), null, 'Selection instructions must not look like an unavailable-schedule warning');
assert.deepEqual(await availableDoctorIDs(), ['1','2','3']);
await openPicker();
assert.equal(container.querySelectorAll('.doctor-picker__direction').length, 2);
await click(container.querySelector('.doctor-picker__direction'));
assert.equal(container.querySelector('.doctor-picker__direction').getAttribute('aria-expanded'), 'true');
const escape = new window.Event('keydown', {bubbles:true});
escape.key = 'Escape';
await act(async () => container.querySelector('.doctor-picker__search input').dispatchEvent(escape));
assert.equal(container.querySelector('.doctor-picker__panel'), null, 'Escape closes the picker');
assert.equal(closeCalls, 0, 'Escape in the picker must not close the booking dialog');
await chooseDoctor(1);
assert.equal(pending[0].slug, 'first');
assert.match(text(), /Загружаем свободное время/);
await reply(pending[0], [free('09:15'), { ...free('10:00'), status: 'busy' }, { ...free('11:00'), start: '2000-01-01T11:00:00+03:00' }, { ...free('12:00'), start: 'invalid' }]);
assert.deepEqual(times(), ['09:15'], 'Only actual free future slots appear');
await act(async () => container.querySelector('.slot-chip--time').dispatchEvent(new window.Event('click', { bubbles: true })));
assert.ok(container.querySelector('input[type="tel"]'), 'Contacts follow an actual slot');
await chooseDoctor(2);
assert.deepEqual(times(), []);
assert.equal(container.querySelector('input[type="tel"]'), null, 'Switching doctor clears the chosen slot');
assert.equal(pending[1].slug, 'second');
await chooseDoctor(1);
assert.equal(pending[2].slug, 'first');
await reply(pending[1], [], false);
assert.match(text(), /Загружаем свободное время/, 'Late response for another doctor must be ignored');
await reply(pending[2], [free('15:45')]);
assert.deepEqual(times(), ['15:45']);
await chooseDoctor('');
assert.deepEqual(times(), []);
assert.equal(pending.length, 3, 'Clearing selection must not request an all-doctors schedule');
await chooseDoctor(3);
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
assert.match(container.querySelector('.lead-modal__availability--warning').textContent, /Свободные окна этого врача сейчас недоступны/, 'The unavailable-schedule message has its own prominent warning style');
assert.deepEqual(times(), []);
assert.equal(container.querySelectorAll('.slot-chip--time').length, 0, 'Fallback cannot offer invented times');
assert.ok(container.querySelector('input[type="tel"]'));
await chooseDoctor(2);
await act(async () => pending[3].reject(new Error('Schedule temporarily unavailable')));
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
assert.deepEqual(times(), []);
await act(async () => root.render(null));
const beforeCardCatalogRequests = doctorCatalogRequests;
await render({ doctor: doctors[0], slot: free('10:00') });
assert.equal(doctorCatalogRequests, beforeCardCatalogRequests, 'A doctor card must not load the all-doctors catalog');
assert.equal(container.querySelector('.doctor-picker__trigger'), null);
assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1');
await reply(pending[4], [free('16:15')]);
assert.deepEqual(times(), ['16:15']);
assert.equal(container.querySelector('input[type="tel"]'), null, 'A stale initial slot must not be preselected');
await act(async () => root.render(null));
await render();
await act(async () => Simulate.input(container.querySelector('input[type="date"]'), { target: { value: '2070-10-24' } }));
assert.deepEqual(await availableDoctorIDs(), ['2'], 'Only doctors free on the selected date appear');
await chooseDoctor(2);
await reply(pending.at(-1), [free('09:00'), { ...free('14:15'), start: '2070-10-24T14:15:00+03:00' }]);
assert.deepEqual(times(), ['14:15'], 'Selected date restricts the actual time list too');
await click(container.querySelector('.slot-chip--time'));
assert.ok(container.querySelector('input[type="tel"]'));
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-25' } }));
assert.deepEqual(times(), []);
assert.equal(container.querySelector('input[type="tel"]'), null, 'Changing date clears the chosen time and contacts step');
assert.match(container.querySelector('.doctor-picker__trigger .doctor-picker__name').textContent, /Второй врач/, 'Changing date preserves the doctor even when no windows are available');
assert.deepEqual(await availableDoctorIDs(), ['2']);
assert.match(text(), /У выбранного врача на эту дату свободных окон нет/);
assert.ok(container.querySelector('.lead-modal__availability--warning'));
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-23' } }));
assert.match(container.querySelector('.doctor-picker__trigger .doctor-picker__name').textContent, /Второй врач/);
assert.equal(pending.at(-1).slug, 'second');
await reply(pending.at(-1), [free('11:45')]);
assert.deepEqual(times(), ['11:45'], 'Available date opens the preserved doctor schedule without a second selection');
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } }));
await reply(pending.at(-1), [free('12:45')]);
assert.deepEqual(times(), ['12:45'], 'Clearing date keeps the doctor and restores all their dates');
assert.deepEqual(await availableDoctorIDs(), ['1','2','3'], 'Clearing date restores the doctor-first flow');
await act(async () => root.render(null));
const beforeLockedCatalogRequests = doctorCatalogRequests;
await render({ doctor: doctors[0] });
await reply(pending.at(-1), [free('10:30')]);
await act(async () => Simulate.input(container.querySelector('input[type="date"]'), { target: { value: '2070-10-23' } }));
await reply(pending.at(-1), [free('13:30')]);
assert.match(container.querySelector('.doctor-picker__fixed .doctor-picker__name').textContent, /Первый врач/);
assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'Doctor-card booking has no doctor selector');
assert.deepEqual(times(), ['13:30'], 'Doctor preselected from a card also survives calendar selection');
assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests);
await click(container.querySelector('.doctor-picker__fixed'));
assert.equal(container.querySelector('.doctor-picker__panel'), null, 'Static doctor summary cannot open a dropdown');
assert.equal(container.querySelector('.doctor-picker__chevron'), null, 'No misleading dropdown arrow for a fixed doctor');
await click(container.querySelector('.slot-chip--time'));
assert.ok(container.querySelector('input[type="tel"]'));
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-24' } }));
assert.deepEqual(times(), []);
assert.equal(container.querySelector('input[type="tel"]'), null, 'Date changes clear the time, not the fixed doctor');
assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1');
assert.match(text(), /У выбранного врача на эту дату свободных окон нет/);
assert.doesNotMatch(text(), /другого специалиста/);
assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'An unavailable date cannot unlock the doctor');
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } }));
await reply(pending.at(-1), [free('14:30')]);
assert.deepEqual(times(), ['14:30']);
assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1');
assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests);
await act(async () => root.render(null));
await render({ doctor: doctors[2] });
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '3');
assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'Fallback requests also keep the card doctor fixed');
assert.ok(container.querySelector('input[type="tel"]'));
assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests);
await act(async () => root.render(null));
await render({ doctor: doctors[0] });
await reply(pending.at(-1), [], false);
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1');
assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'A failed native schedule cannot unlock the doctor');
assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests);
const originalDoctorLoader = api.doctors;
for (const fail of [false, true]) {
await act(async () => root.render(null));
api.doctors = async () => {
if (fail) throw new Error('Fictional catalog outage');
return { data: [] };
};
await render();
assert.match(container.querySelector('.lead-modal__availability--warning').textContent, /Список врачей сейчас недоступен/, 'An empty or failed doctor catalog has the prominent red warning style');
}
api.doctors = originalDoctorLoader;
await act(async () => root.render(null));
await render({ form: 'callback' });
assert.ok(container.querySelector('input[type="tel"]'));
assert.equal(container.querySelector('select'), null);
await act(async () => root.render(null));
await render({ form: 'review' });
assert.equal(container.querySelectorAll('.lead-rating__star').length, 5);
assert.ok(container.querySelector('select'));
console.log('PASS: specialty grouping/search/selection, picker keyboard handling, date-to-doctor filtering, actual times, resets, stale responses, fixed doctor from card without catalog requests, date/fallback lock, callback and review');
} finally {
await act(async () => root.unmount());
await vite.close();
}

View File

@ -0,0 +1,49 @@
// Only fictional data and a mocked API. No live requests or appointments.
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://booking-test.invalid/');
document.oninput = null;
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { Simulate } = await import('react-dom/test-utils');
const { MemoryRouter } = await import('react-router-dom');
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
const { default: NativeBooking } = await vite.ssrLoadModule('/src/components/NativeBooking.jsx');
const { api } = await vite.ssrLoadModule('/src/api.js');
const slot = { start: '2070-10-23T12:00:00+03:00', end: '2070-10-23T12:30:00+03:00', status: 'free', branch_id: 'test', branch: 'Тестовый филиал' };
api.doctorSlots = async () => ({ data: { enabled: true, slots: [slot], services: [
{ id: 'unverified-lab', name: 'Непроверенный анализ из общего справочника' },
{ id: 'unverified-procedure', name: 'Непроверенная процедура другого направления' },
] } });
let submitted;
api.createAppointment = async (payload) => { submitted = payload; return { data: { message: 'Тестовая заявка принята' } }; };
const container = document.getElementById('root');
const root = createRoot(container);
try {
await act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(NativeBooking, {
doctor: { slug: 'fictional-dentist' }, initialSlot: slot,
onClose() {}, onUnavailable() { throw new Error('Actual free slot must stay bookable'); },
}))));
assert.ok(!container.querySelector('select'), 'Unverified imported services must not appear in the booking form');
assert.doesNotMatch(container.textContent, /Непроверенный анализ|Непроверенная процедура/);
assert.match(container.textContent, /23 октября/);
await act(async () => {
Simulate.change(container.querySelector('input[type="text"]'), { target: { type: 'text', value: 'Вымышленный пациент' } });
Simulate.change(container.querySelector('input[type="tel"]'), { target: { type: 'tel', value: '+79990000000' } });
Simulate.change(container.querySelector('input[type="checkbox"]'), { target: { type: 'checkbox', checked: true } });
});
await act(async () => Simulate.submit(container.querySelector('form')));
assert.equal(submitted.doctor_slug, 'fictional-dentist');
assert.equal(submitted.start, slot.start);
assert.equal(submitted.branch_id, 'test');
assert.equal(Object.hasOwn(submitted, 'service_id'), false, 'No unverified service identifier may be sent');
assert.match(container.textContent, /Тестовая заявка принята/);
console.log('PASS: unrelated imported services hidden, real chosen slot retained, booking submitted without service_id; fictional data only');
} finally {
await act(async () => root.unmount());
await vite.close();
}

View File

@ -0,0 +1,77 @@
// In-memory browser history and fictional programs only. No network calls.
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/checkup/');
window.history = { scrollRestoration: 'auto' };
const scrolls = [];
window.scrollTo = (...args) => scrolls.push(['top', ...args]);
window.HTMLElement.prototype.scrollIntoView = function(options) { scrolls.push(['list', this.textContent, options]); };
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { Simulate } = await import('react-dom/test-utils');
const { MemoryRouter, Routes, Route, useNavigate, useLocation } = await import('react-router-dom');
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
const { default: Layout } = await vite.ssrLoadModule('/src/components/Layout.jsx');
const { default: CheckupsPage } = await vite.ssrLoadModule('/src/pages/CheckupsPage.jsx');
const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx');
const { api } = await vite.ssrLoadModule('/src/api.js');
const programs = [{ id: 1, slug: 'child', name: 'Детский тест', audience: 'kids' }, { id: 2, slug: 'adult', name: 'Взрослый тест', audience: 'adults' }];
api.checkups = async () => ({ data: programs });
api.branches = api.settings = async () => ({ data: [] });
let navigate;
let currentLocation;
function HistoryProbe() { navigate = useNavigate(); currentLocation = useLocation(); return null; }
const container = document.getElementById('root');
const root = createRoot(container);
const click = async (node) => { assert.ok(node); await act(async () => Simulate.click(node, { button: 0 })); };
async function mount(url) {
await act(async () => root.render(React.createElement(MemoryRouter, { key: url, initialEntries: ['/previous/', url] },
React.createElement(ApiDataProvider, { value: {} }, React.createElement(HistoryProbe), React.createElement(Routes, {},
React.createElement(Route, { element: React.createElement(Layout) },
React.createElement(Route, { path: '/checkup/', element: React.createElement(CheckupsPage) }),
React.createElement(Route, { path: '/checkup/:slug/', element: React.createElement('p', {}, 'Detail page') }),
React.createElement(Route, { path: '/previous/', element: React.createElement('p', {}, 'Previous page') })))))));
}
const selected = () => container.querySelector('[role="tab"][aria-selected="true"]').textContent;
try {
await mount('/checkup/');
assert.equal(window.history.scrollRestoration, 'manual');
assert.equal(selected(), 'Взрослые');
scrolls.length = 0;
await click([...container.querySelectorAll('[role="tab"]')].find((node) => node.textContent === 'Детские'));
assert.equal(selected(), 'Детские');
assert.equal(currentLocation.search, '?audience=kids');
assert.equal(scrolls.length, 0, 'Tab selection must not jump');
await click(container.querySelector('.checkup-card__more'));
assert.match(container.textContent, /Detail page/);
assert.ok(scrolls.some((s) => s[0] === 'top'), 'New detail page starts at top');
scrolls.length = 0;
await act(async () => navigate(-1));
assert.equal(selected(), 'Детские');
assert.match(container.querySelector('.checkup-grid').textContent, /Детский тест/);
assert.equal(scrolls.length, 1);
assert.equal(scrolls[0][0], 'list');
assert.equal(scrolls[0][1], 'Программы чек-апов');
await act(async () => navigate(1));
scrolls.length = 0;
await act(async () => navigate(-1));
assert.equal(selected(), 'Детские');
assert.equal(scrolls[0][0], 'list');
await act(async () => navigate(-1));
assert.equal(currentLocation.pathname, '/previous/', 'Tab changes do not pollute history');
await mount('/checkup/?audience=kids');
assert.equal(selected(), 'Детские', 'Direct links and reloads preserve audience');
await click([...container.querySelectorAll('[role="tab"]')].find((node) => node.textContent === 'Взрослые'));
assert.equal(currentLocation.search, '');
await click(container.querySelector('.checkup-card__more'));
await act(async () => navigate(-1));
assert.equal(selected(), 'Взрослые');
console.log('PASS: kids/adult tab persists, Back returns to list heading, Forward/Back works, no extra tab history, new details start at top');
} finally {
await act(async () => root.unmount());
assert.equal(window.history.scrollRestoration, 'auto', 'Unmount restores browser policy');
await vite.close();
}

View File

@ -0,0 +1,90 @@
// Fictional contact data and mocked submissions only. No external requests.
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/sales/');
document.oninput = null;
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { Simulate } = await import('react-dom/test-utils');
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 { LeadModalProvider } = await vite.ssrLoadModule('/src/context/LeadModalContext.jsx');
const { default: PromosPage } = await vite.ssrLoadModule('/src/pages/PromosPage.jsx');
const { default: CheckupPage } = await vite.ssrLoadModule('/src/pages/CheckupPage.jsx');
const program = { id: 1, slug: 'chek-ap-ya-v-sad', name: 'Я в сад', audience: 'kids', blocks: [], seo: {} };
api.promos = async () => ({ data: [
{ id: 1, slug: 'ya-v-sad-sale', title: 'Я в сад' },
{ id: 2, slug: 'unrelated-promo', title: 'Другая акция', checkup_slug: program.slug },
] });
api.checkups = async () => ({ data: [program] });
api.checkup = async () => ({ data: program });
let bookingCalls = 0;
api.doctors = api.bookingAvailability = api.doctorSlots = async () => {
bookingCalls++;
throw new Error('Checkup request must not load doctors or schedules');
};
const submissions = [];
api.createLead = async (payload) => { submissions.push(payload); return { data: { message: 'Тестовая заявка принята' } }; };
api.createAppointment = () => { throw new Error('Checkup must not create an appointment in 1C'); };
const container = document.getElementById('root');
const root = createRoot(container);
const click = async (node) => {
assert.ok(node, 'Expected actionable element');
await act(async () => node.dispatchEvent(new window.Event('click', { bubbles: true })));
};
const mount = async (url, Page, route) => act(async () => root.render(
React.createElement(MemoryRouter, { key: url, initialEntries: [url] },
React.createElement(LeadModalProvider, {}, React.createElement(Routes, {},
React.createElement(Route, { path: route, element: React.createElement(Page) }))))));
function checkSimpleForm() {
const modal = container.querySelector('[role="dialog"]');
assert.ok(modal);
assert.match(modal.textContent, /Записаться на чек-ап/);
assert.match(modal.textContent, /Программа: Я в сад/);
assert.equal(modal.querySelectorAll('input').length, 3, 'Only name, phone and consent');
assert.equal(modal.querySelector('select, textarea, input[type="date"], .doctor-picker, .native-booking'), null);
assert.equal(bookingCalls, 0);
return modal;
}
try {
await mount('/sales/', PromosPage, '/sales/');
for (const card of container.querySelectorAll('.promo-card')) {
await click(card.querySelector('button'));
checkSimpleForm();
await click(container.querySelector('[aria-label="Закрыть"]'));
}
await click(container.querySelector('.promo-card button'));
let modal = checkSimpleForm();
await act(async () => Simulate.submit(modal.querySelector('form')));
assert.equal(submissions.length, 0, 'Empty form rejected');
await act(async () => Simulate.change(modal.querySelector('input[type="text"]'), { target: { value: 'Тестовая заявка' } }));
await act(async () => Simulate.change(modal.querySelector('input[type="tel"]'), { target: { value: '79990000000' } }));
await act(async () => Simulate.submit(modal.querySelector('form')));
assert.equal(submissions.length, 0, 'Consent required');
await act(async () => Simulate.change(modal.querySelector('input[type="checkbox"]'), { target: { type: 'checkbox', checked: true } }));
await act(async () => Simulate.submit(modal.querySelector('form')));
assert.equal(submissions.length, 1);
assert.equal(submissions[0].form, 'appointment', 'Use existing lead endpoint form');
assert.deepEqual(submissions[0].extra, { checkup_slug: program.slug, checkup_name: program.name });
assert.match(submissions[0].message, /Чек-ап: Я в сад/);
assert.match(submissions[0].message, /Акция: Я в сад/);
assert.equal(submissions[0].doctor_id, undefined);
assert.equal(submissions[0].slot, undefined);
assert.equal(submissions[0].consent, true);
assert.match(container.querySelector('[role="dialog"]').textContent, /Тестовая заявка принята/);
await mount(`/checkup/${program.slug}/`, CheckupPage, '/checkup/:slug/');
await click(container.querySelector('.checkup-hero__btn'));
checkSimpleForm();
await click(container.querySelector('[aria-label="Закрыть"]'));
const bottomCta = [...container.querySelectorAll('button')].find((node) => node.textContent === 'Оставить заявку');
await click(bottomCta);
checkSimpleForm();
console.log('PASS: promotion links (slug fallback and explicit), checkup hero/bottom CTAs, contact validation and program metadata; no doctors, schedules or 1C appointment calls');
} finally {
await act(async () => root.unmount());
await vite.close();
}

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,92 @@
// Regression for cross-menu data leaks. All catalog responses are fictional;
// no network calls or patient data are used.
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://menu-test.invalid/');
document.oninput = null;
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { Simulate } = await import('react-dom/test-utils');
const { MemoryRouter } = await import('react-router-dom');
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
const { default: Header } = await vite.ssrLoadModule('/src/components/Header.jsx');
const { api } = await vite.ssrLoadModule('/src/api.js');
const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx');
const pending = [];
for (const [method, kind] of [['services', 'services'], ['specialties', 'doctors'], ['priceSections', 'prices']]) {
api[method] = ({ signal }) => new Promise((resolve, reject) => pending.push({ kind, signal, resolve, reject }));
}
const container = document.getElementById('root');
const root = createRoot(container);
const prices = [{ id: 1, name: 'Тестовая консультация', slug: 'test-price', count: 411 }];
const specialties = [{ id: 2, name: 'Тестовые педиатры', slug: 'test-kids', count: 3 }];
const services = [{ id: 3, name: 'Тестовая услуга', path: 'test-service', children: [] }];
const panel = () => container.querySelector('.mega');
const panelText = () => panel()?.textContent || '';
const links = () => [...(panel()?.querySelectorAll('.mega__item') || [])].map((link) => link.getAttribute('href'));
const trigger = (title) => [...container.querySelectorAll('.nav__item')].find((node) => node.textContent === title);
async function open(title, focus = false) {
await act(async () => Simulate[focus ? 'focus' : 'mouseEnter'](trigger(title)));
return pending.at(-1);
}
async function reply(request, data) { await act(async () => request.resolve({ data })); }
async function render(initial = {}) {
await act(async () => root.render(React.createElement(MemoryRouter, {},
React.createElement(ApiDataProvider, { value: initial }, React.createElement(Header)))));
}
try {
await render();
const priceRequest = await open('Цены');
await reply(priceRequest, prices);
assert.match(panelText(), /411 услуг/);
const doctorRequest = await open('Врачи');
assert.equal(panel().getAttribute('aria-label'), 'Меню «Врачи»');
assert.deepEqual(links(), [], 'Price sections must disappear immediately when switching to doctors');
assert.match(panelText(), /Загружаем/);
assert.equal(priceRequest.signal.aborted, true, 'Previous menu request is cancelled on switch');
await reply(doctorRequest, specialties);
assert.match(panelText(), /3 врача/);
assert.deepEqual(links(), ['/company/staff/test-kids/']);
assert.doesNotMatch(panelText(), /411|Тестовая консультация/);
const delayedPrice = await open('Цены');
assert.deepEqual(links(), [], 'Doctor specialties must not leak into the price menu');
const delayedServices = await open('Услуги');
const latestDoctor = await open('Врачи', true);
assert.equal(delayedPrice.signal.aborted, true);
assert.equal(delayedServices.signal.aborted, true);
await reply(latestDoctor, specialties);
// Deliberately resolve cancelled requests: even a transport ignoring AbortSignal
// must not overwrite a newly opened menu.
await reply(delayedPrice, prices);
await reply(delayedServices, services);
assert.deepEqual(links(), ['/company/staff/test-kids/']);
assert.match(panelText(), /3 врача/);
assert.doesNotMatch(panelText(), /411|Тестовая консультация|Тестовая услуга/);
const serviceRequest = await open('Услуги');
await reply(serviceRequest, services);
assert.deepEqual(links(), ['/services/test-service/']);
const failedPrice = await open('Цены');
await act(async () => failedPrice.reject(new Error('Fictional failure')));
assert.match(panelText(), /Не удалось загрузить список/);
assert.deepEqual(links(), [], 'An error must not leave links from another menu');
await act(async () => root.render(null));
await render({ specialties: { data: specialties, meta: {} } });
const initialPrices = await open('Цены');
await reply(initialPrices, prices);
const initialDoctors = await open('Врачи', true);
assert.deepEqual(links(), ['/company/staff/test-kids/'], 'SSR data belongs only to its own catalog');
assert.match(panelText(), /3 врача/);
await reply(initialDoctors, specialties);
console.log('PASS: hover/focus menu switching, delayed replies, cancellation, errors, and SSR data isolation');
} finally {
await act(async () => root.unmount());
await vite.close();
}

View File

@ -0,0 +1,67 @@
// Public fixtures only. No network or production mutations.
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/');
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { renderToString } = await import('react-dom/server');
const { MemoryRouter } = await import('react-router-dom');
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
const { useApi } = await vite.ssrLoadModule('/src/lib/useApi.js');
const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx');
const { SiteDataProvider } = await vite.ssrLoadModule('/src/context/SiteData.jsx');
const { default: HeroSlider } = await vite.ssrLoadModule('/src/components/HeroSlider.jsx');
const { api } = await vite.ssrLoadModule('/src/api.js');
let calls = 0;
let siteCalls = 0;
api.branches = api.settings = async () => { siteCalls++; return { data: [] }; };
const pending = [];
const load = (signal) => { calls++; return new Promise((resolve) => pending.push({ signal, resolve })); };
function Probe({ query }) {
const result = useApi(load, [query], query);
return React.createElement('output', {}, result.data || 'loading');
}
const container = document.getElementById('root');
const root = createRoot(container);
const snapshot = { first: { data: 'SSR first' }, second: { data: 'SSR second' }, site: { phone: 'test' }, __receivedAt: Date.now() };
async function mount(query, initial = snapshot, key = 'same') {
await act(async () => root.render(React.createElement(React.StrictMode, {},
React.createElement(ApiDataProvider, { value: initial }, React.createElement(SiteDataProvider, {},
React.createElement(Probe, { key, query }))))));
}
const banners = Array.from({ length: 6 }, (_, id) => ({ id, image: `/test-${id}.webp`, title: `Banner ${id}` }));
const images = () => [...container.querySelectorAll('.hero-slide img[src]')].map((node) => node.getAttribute('src'));
try {
await mount('first');
assert.equal(calls, 0, 'Hydration including StrictMode must not repeat catalog request');
assert.equal(siteCalls, 0, 'No redundant branch/settings requests');
assert.equal(container.querySelector('output').textContent, 'SSR first');
await mount('second');
assert.equal(calls, 0);
assert.equal(container.querySelector('output').textContent, 'SSR second');
await mount('uncached');
assert.equal(calls, 1, 'Client-only route still loads');
await mount('new-query');
await act(async () => pending.at(-2).resolve({ data: 'STALE' }));
assert.doesNotMatch(container.textContent, /STALE/);
await act(async () => pending.at(-1).resolve({ data: 'Fresh query' }));
assert.match(container.textContent, /Fresh query/);
await mount('first', { ...snapshot, __receivedAt: Date.now() - 31_000 }, 'expired');
assert.ok(calls >= 3, 'Old SSR snapshot must revalidate');
for (const request of pending) await act(async () => request.resolve({ data: 'Refreshed' }));
const ssr = renderToString(React.createElement(MemoryRouter, {}, React.createElement(HeroSlider, { banners })));
assert.equal(new Set([...ssr.matchAll(/src="(\/test-[^"]+)"/g)].map((m) => m[1])).size, 3, 'SSR exposes only current/neighbor images');
await act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(HeroSlider, { banners }))));
assert.deepEqual([...new Set(images())].sort(), ['/test-0.webp', '/test-1.webp', '/test-5.webp']);
assert.equal(container.querySelector('.hero__item--active picture img').getAttribute('fetchpriority'), 'high');
const next = container.querySelector('[aria-label="Следующий слайд"]');
for (let i = 0; i < 6; i++) await act(async () => next.dispatchEvent(new window.Event('click', { bubbles: true })));
assert.equal(new Set(images()).size, 6, 'All banners load on demand as the visitor browses');
console.log('PASS: SSR catalog/site requests 0, uncached/expired queries load, stale replies ignored; initial banner images 6 → 3, all slides reachable');
} finally {
await act(async () => root.unmount());
await vite.close();
}

View File

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

View File

@ -61,6 +61,7 @@ export const api = {
waitCallStatus: (sessionId, o) => request('GET', `/auth/wait-call/status?session_id=${encodeURIComponent(sessionId)}`, o), waitCallStatus: (sessionId, o) => request('GET', `/auth/wait-call/status?session_id=${encodeURIComponent(sessionId)}`, o),
// свободные окна врача из 1С и запись на конкретное окно // свободные окна врача из 1С и запись на конкретное окно
doctorSlots: (slug, o) => request('GET', `/doctors/${encodeURIComponent(slug)}/slots`, o), doctorSlots: (slug, o) => request('GET', `/doctors/${encodeURIComponent(slug)}/slots`, o),
bookingAvailability: (o) => request('GET', '/booking/availability', o),
createAppointment: (body) => createAppointment: (body) =>
request('POST', '/appointments', { body: { utm: attribution(), ...body } }).then((res) => { request('POST', '/appointments', { body: { utm: attribution(), ...body } }).then((res) => {
reachGoal('lead', { form: 'appointment' }); reachGoal('lead', { form: 'appointment' });

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

@ -14,6 +14,8 @@
.doctor-tile { .doctor-tile {
width: 209px; width: 209px;
flex-shrink: 0; flex-shrink: 0;
display: flex;
flex-direction: column;
} }
.doctor-tile__photo-link { .doctor-tile__photo-link {
@ -41,6 +43,7 @@
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: 8px;
margin-top: 16px; margin-top: 16px;
flex: 1;
} }
.doctor-tile__name { .doctor-tile__name {
@ -66,12 +69,14 @@
} }
.doctor-tile__exp { .doctor-tile__exp {
padding: 9px 8px 9px 12px; margin-top: auto;
padding: 8px 12px;
background: var(--chip); background: var(--chip);
border: 1px solid var(--chip-border); border: 1px solid var(--chip-border);
border-radius: var(--r-10); border-radius: var(--r-10);
font-weight: 500; font-weight: 500;
font-size: 12px; font-size: 14px;
line-height: 1.25;
color: var(--navy); color: var(--navy);
white-space: nowrap; white-space: nowrap;
} }
@ -187,6 +192,8 @@
border-radius: var(--r-16); border-radius: var(--r-16);
overflow: hidden; overflow: hidden;
background: var(--photo-bg); background: var(--photo-bg);
display: flex;
flex-direction: column;
} }
.doctor-tile__photo { .doctor-tile__photo {
height: 180px; height: 180px;
@ -196,6 +203,9 @@
gap: 0; gap: 0;
margin-top: 0; margin-top: 0;
padding: 12px; padding: 12px;
flex: 1;
display: flex;
flex-direction: column;
} }
.doctor-tile__name { .doctor-tile__name {
font-size: 11px; font-size: 11px;
@ -208,11 +218,12 @@
line-height: 12.375px; line-height: 12.375px;
} }
.doctor-tile__exp { .doctor-tile__exp {
padding: 9.6px 8px; margin-top: auto;
padding: 6px 10px;
border-width: 0.722px; border-width: 0.722px;
border-radius: var(--r-8); border-radius: var(--r-8);
font-size: 9px; font-size: 11px;
line-height: 13.5px; line-height: 14px;
} }
.doctor-row { .doctor-row {

View File

@ -9,16 +9,9 @@ export function roleLabel(doctor) {
return doctor.position || doctor.specialties?.[0]?.name || ''; return doctor.position || doctor.specialties?.[0]?.name || '';
} }
// Запись к врачу: у кого настроен Medflex — ведём туда, у остальных открываем форму заявки. // Запись всегда остаётся на сайте: окна 1С либо обычная форма заявки.
export function BookButton({ doctor, className = '', children = 'Записаться' }) { export function BookButton({ doctor, className = '', children = 'Записаться' }) {
const { open } = useLeadModal(); const { open } = useLeadModal();
if (doctor.booking?.provider === 'medflex' && doctor.booking.url) {
return (
<a className={className} href={doctor.booking.url} target="_blank" rel="noreferrer">
{children}
</a>
);
}
return ( return (
<button type="button" className={className} onClick={() => open({ form: 'appointment', doctor })}> <button type="button" className={className} onClick={() => open({ form: 'appointment', doctor })}>
{children} {children}

View File

@ -0,0 +1,99 @@
.doctor-picker__trigger, .doctor-picker__fixed {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 68px;
padding: 11px 15px;
border: 1px solid var(--chip-border);
border-radius: 14px;
background: #fff;
color: var(--navy);
text-align: left;
}
.doctor-picker__trigger {
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
}
.doctor-picker__trigger:hover:not(:disabled), .doctor-picker__trigger--open {
border-color: var(--coral);
box-shadow: 0 0 0 3px rgba(249, 94, 82, .08);
}
.doctor-picker__trigger:disabled { opacity: .65; cursor: wait; }
.doctor-picker button:focus-visible, .doctor-picker input:focus-visible {
outline: 2px solid var(--coral);
outline-offset: 3px;
}
.doctor-picker__avatar, .doctor-picker__symbol {
position: relative;
display: grid;
place-items: center;
flex: 0 0 44px;
width: 44px;
height: 44px;
overflow: hidden;
border-radius: 12px;
background: #edf2f7;
color: var(--navy);
font-size: 14px;
font-weight: 600;
}
.doctor-picker__avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center 24%; }
.doctor-picker__symbol { background: #fff0ed; color: var(--coral); font-size: 25px; }
.doctor-picker__selection { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 4px; }
.doctor-picker__name { color: var(--navy); font-size: 14px; font-weight: 600; line-height: 1.45; }
.doctor-picker__role { color: #788695; font-size: 11px; font-weight: 400; line-height: 1.5; }
.doctor-picker__chevron { flex-shrink: 0; transition: transform .15s; }
.doctor-picker__trigger--open .doctor-picker__chevron { transform: rotate(180deg); }
.doctor-picker__panel {
padding: 14px;
border: 1px solid #dce5ed;
border-radius: 18px;
background: #fff;
box-shadow: 0 10px 28px rgba(44, 73, 92, .08);
}
.doctor-picker__search { display: flex; align-items: center; gap: 10px; padding: 0 13px; border: 1px solid #e1e8ee; border-radius: 12px; background: #f6f8fb; }
.doctor-picker__search input { width: 100%; min-width: 0; height: 43px; border: 0; background: transparent; color: var(--navy); font: inherit; font-size: 13px; outline: none; }
.doctor-picker__search input::placeholder { color: #81909d; }
.doctor-picker__search:focus-within { border-color: var(--coral); box-shadow: 0 0 0 3px rgba(249,94,82,.08); }
.doctor-picker__search input:focus-visible { outline: none; }
.doctor-picker__summary { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 2px 10px; color: #7a8792; font-size: 11px; }
.doctor-picker__total { display: inline-block; margin-left: 4px; color: var(--navy); font-weight: 600; }
.doctor-picker__reset { padding: 2px 0; color: var(--coral); font: inherit; cursor: pointer; }
.doctor-picker__results { max-height: min(360px, 42vh); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #cdd9e3 transparent; }
.doctor-picker__group + .doctor-picker__group { margin-top: 6px; }
.doctor-picker__group h3 { margin: 0; }
.doctor-picker__direction { display: flex; align-items: center; gap: 9px; width: 100%; padding: 12px; border-radius: 10px; background: #f2f6f9; text-align: left; color: var(--navy); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
.doctor-picker__direction:hover:not(:disabled) { background: #eaf0f6; }
.doctor-picker__direction:disabled { cursor: default; opacity: 1; }
.doctor-picker__direction-dot { width: 7px; height: 7px; flex-shrink: 0; border-radius: 50%; background: var(--coral); }
.doctor-picker__count { display: grid; place-items: center; min-width: 24px; height: 24px; margin-left: auto; padding: 0 6px; border-radius: 8px; background: #fff; font-size: 11px; }
.doctor-picker__direction img { flex-shrink: 0; }
.doctor-picker__direction[aria-expanded='true'] img { transform: rotate(180deg); }
.doctor-picker__doctors { padding: 5px 0 3px; }
.doctor-picker__doctors[hidden] { display: none; }
.doctor-picker__doctor { display: flex; align-items: center; gap: 11px; width: 100%; padding: 10px; border: 1px solid transparent; border-radius: 12px; background: #fff; text-align: left; cursor: pointer; transition: background .15s, border-color .15s; }
.doctor-picker__doctor + .doctor-picker__doctor { margin-top: 3px; }
.doctor-picker__doctor:hover, .doctor-picker__doctor--selected { border-color: #f8d8d2; background: #fff6f3; }
.doctor-picker__pick { flex-shrink: 0; color: var(--coral); font-size: 19px; }
.doctor-picker__empty { padding: 20px 10px; color: #788695; font-size: 13px; line-height: 1.6; text-align: center; }
/* Keep the picker within the viewport; only its results scroll, not the entire form. */
.lead-modal__backdrop:has(.doctor-picker__panel) { padding-top: 24px; padding-bottom: 24px; }
.lead-modal:has(.doctor-picker__panel) { height: min(820px, calc(100dvh - 48px)); max-height: calc(100dvh - 48px); overflow: hidden; }
.lead-modal:has(.doctor-picker__panel) > :not(.doctor-picker) { flex-shrink: 0; }
.doctor-picker:has(.doctor-picker__panel) { flex: 1; min-height: 0; }
.doctor-picker__trigger { flex-shrink: 0; }
.doctor-picker__panel { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
.doctor-picker__search, .doctor-picker__summary { flex-shrink: 0; }
.doctor-picker__results { position: relative; flex: 1; min-height: 0; max-height: none; }
.doctor-picker:has(.doctor-picker__panel) ~ * { display: none; }
@media (max-width: 600px) {
.lead-modal__backdrop:has(.doctor-picker__panel) { padding-top: 12px; padding-bottom: 12px; }
.lead-modal:has(.doctor-picker__panel) { height: min(760px, calc(100dvh - 24px)); max-height: calc(100dvh - 24px); }
.doctor-picker__trigger, .doctor-picker__fixed { gap: 9px; padding: 10px; }
.doctor-picker__panel { padding: 10px; border-radius: 14px; }
.doctor-picker__name { font-size: 12px; }
.doctor-picker__role { font-size: 10px; }
.doctor-picker__direction { padding: 11px 9px; font-size: 12px; }
.doctor-picker__doctor { padding: 9px 5px; gap: 9px; }
}

View File

@ -0,0 +1,154 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import chevronIcon from '../assets/figma/icon-accordion-chevron.svg';
import searchIcon from '../assets/figma/icon-search-18.svg';
import { groupDoctors } from '../lib/doctorGroups.js';
import './DoctorPicker.css';
function Avatar({ doctor }) {
const initials = doctor.full_name.split(/\s+/).slice(0, 2).map((part) => part[0]).join('');
return (
<span className="doctor-picker__avatar" aria-hidden="true">
<span>{initials}</span>
{doctor.photo && <img src={doctor.photo} alt="" width="44" height="44" loading="lazy" onError={(event) => { event.currentTarget.style.display = 'none'; }} />}
</span>
);
}
// Не кнопка и не disabled-селект: выбранный в каталоге врач не меняется в этой записи.
export function DoctorSummary({ doctor }) {
return (
<div className="lead-field lead-field--doctor doctor-picker">
<span className="lead-field__label">Специалист</span>
<div className="doctor-picker__fixed" data-fixed-doctor-id={doctor.id}>
<Avatar doctor={doctor} />
<span className="doctor-picker__selection">
<span className="doctor-picker__name">{doctor.full_name}</span>
{doctor.position && <span className="doctor-picker__role">{doctor.position}</span>}
</span>
</div>
</div>
);
}
export default function DoctorPicker({ doctors, value, onChange, disabled = false, loading = false, date = '' }) {
const uid = useId();
const wrapper = useRef(null);
const trigger = useRef(null);
const search = useRef(null);
const results = useRef(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState('');
const selected = doctors.find((doctor) => String(doctor.id) === String(value));
const groups = useMemo(() => groupDoctors(doctors, query), [doctors, query]);
const count = new Set(groups.flatMap((group) => group.doctors.map((doctor) => doctor.id))).size;
useEffect(() => {
if (open) search.current?.focus();
}, [open]);
useEffect(() => {
if (disabled) setOpen(false);
}, [disabled]);
useEffect(() => {
if (!open || query.trim() || !expanded || !results.current) return;
const group = [...results.current.children].find((element) => element.dataset.group === expanded);
if (typeof group?.offsetTop === 'number') results.current.scrollTop = group.offsetTop;
}, [expanded, open, query]);
useEffect(() => {
if (!open) return undefined;
const outside = (event) => { if (!wrapper.current?.contains(event.target)) setOpen(false); };
document.addEventListener('mousedown', outside);
return () => document.removeEventListener('mousedown', outside);
}, [open]);
const choose = (id) => {
onChange(String(id));
setOpen(false);
setQuery('');
trigger.current?.focus();
};
return (
<div className="lead-field lead-field--doctor doctor-picker" ref={wrapper}>
<span className="lead-field__label" id={`${uid}-label`}>Специалист</span>
<button
ref={trigger}
type="button"
className={`doctor-picker__trigger${open ? ' doctor-picker__trigger--open' : ''}`}
aria-labelledby={`${uid}-label ${uid}-value`}
aria-expanded={open}
aria-controls={`${uid}-panel`}
disabled={disabled || loading}
onClick={() => {
setQuery('');
setExpanded('');
setOpen((previous) => !previous);
}}
>
{selected ? <Avatar doctor={selected} /> : <span className="doctor-picker__symbol" aria-hidden="true">✚</span>}
<span className="doctor-picker__selection">
<span id={`${uid}-value`} className="doctor-picker__name">{loading || disabled ? 'Загружаем список врачей…' : selected?.full_name || 'Выберите врача'}</span>
<span className="doctor-picker__role">{selected?.position || 'Поиск по имени и выбор по направлению'}</span>
</span>
<img className="doctor-picker__chevron" src={chevronIcon} alt="" width="16" height="16" />
</button>
{open && (
<div
id={`${uid}-panel`}
className="doctor-picker__panel"
role="region"
aria-label="Специалисты по направлениям"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
event.preventDefault();
setOpen(false);
trigger.current?.focus();
}
}}
>
<div className="doctor-picker__search">
<img src={searchIcon} alt="" width="18" height="18" />
<input ref={search} type="search" aria-label="Поиск врача или направления" placeholder="Фамилия, имя или направление" value={query} onInput={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className="doctor-picker__summary">
<span>{date ? 'Свободны на выбранную дату' : 'Специалисты по направлениям'} <span className="doctor-picker__total">{count}</span></span>
{value && <button type="button" className="doctor-picker__reset" onClick={() => choose('')}>Сбросить выбор</button>}
</div>
<div className="doctor-picker__results" ref={results}>
{!groups.length && <p className="doctor-picker__empty">Врачи не найдены. Измените поиск или выберите другую дату.</p>}
{groups.map((group, index) => {
const shown = Boolean(query.trim()) || expanded === group.key;
const listId = `${uid}-group-${index}`;
return (
<section className="doctor-picker__group" key={group.key} data-group={group.key}>
<h3>
<button type="button" className="doctor-picker__direction" aria-expanded={shown} aria-controls={listId} disabled={Boolean(query.trim())} onClick={() => setExpanded(shown ? '' : group.key)}>
<span className="doctor-picker__direction-dot" aria-hidden="true" />
<span>{group.name}</span>
<span className="doctor-picker__count">{group.doctors.length}</span>
<img src={chevronIcon} alt="" width="14" height="14" />
</button>
</h3>
<div className="doctor-picker__doctors" id={listId} hidden={!shown}>
{group.doctors.map((doctor) => (
<button key={doctor.id} type="button" data-doctor-id={doctor.id} className={`doctor-picker__doctor${String(doctor.id) === String(value) ? ' doctor-picker__doctor--selected' : ''}`} aria-label={`Выбрать: ${doctor.full_name}`} aria-pressed={String(doctor.id) === String(value)} onClick={() => choose(doctor.id)}>
<Avatar doctor={doctor} />
<span className="doctor-picker__selection">
<span className="doctor-picker__name">{doctor.full_name}</span>
<span className="doctor-picker__role">{doctor.position || group.name}</span>
</span>
<span className="doctor-picker__pick" aria-hidden="true">{String(doctor.id) === String(value) ? '✓' : '→'}</span>
</button>
))}
</div>
</section>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -115,7 +115,9 @@ export default function Header() {
{openMenu && ( {openMenu && (
<div className="header__mega"> <div className="header__mega">
<MegaMenu menu={openMenu} onNavigate={() => setOpenMenu(null)} /> {/* Каталоги имеют разную структуру: при смене меню сбрасываем
данные и отменяем запрос предыдущего, не меняя лишь заголовок. */}
<MegaMenu key={openMenu} menu={openMenu} onNavigate={() => setOpenMenu(null)} />
</div> </div>
)} )}
</div> </div>

View File

@ -45,7 +45,7 @@ function Chips({ chips }) {
} }
// Один слайд. Три вида: готовая картинка, текст на цветном фоне и слайд с фото врача. // Один слайд. Три вида: готовая картинка, текст на цветном фоне и слайд с фото врача.
function Slide({ banner, decorative = false }) { function Slide({ banner, decorative = false, loadImage = true }) {
const layout = banner.layout || 'image'; const layout = banner.layout || 'image';
const theme = THEMES.has(banner.theme) ? banner.theme : 'navy'; const theme = THEMES.has(banner.theme) ? banner.theme : 'navy';
const alt = decorative ? '' : banner.title || 'Предложение клиники DANA'; const alt = decorative ? '' : banner.title || 'Предложение клиники DANA';
@ -54,10 +54,10 @@ function Slide({ banner, decorative = false }) {
return ( return (
<div className="hero-slide hero-slide--image"> <div className="hero-slide hero-slide--image">
{/* размытая копия заполняет края, когда на телефоне баннер показан целиком */} {/* размытая копия заполняет края, когда на телефоне баннер показан целиком */}
{!banner.mobile_image && <img className="hero-slide__blur" src={banner.image} alt="" aria-hidden="true" />} {loadImage && !banner.mobile_image && <img className="hero-slide__blur" src={banner.image} alt="" aria-hidden="true" decoding="async" />}
<picture> <picture>
{banner.mobile_image && <source media="(max-width: 767px)" srcSet={banner.mobile_image} />} {loadImage && banner.mobile_image && <source media="(max-width: 767px)" srcSet={banner.mobile_image} />}
<img className={banner.mobile_image ? '' : 'hero-slide__fit'} src={banner.image} alt={alt} /> {loadImage && <img className={banner.mobile_image ? '' : 'hero-slide__fit'} src={banner.image} alt={alt} fetchpriority={decorative ? 'auto' : 'high'} decoding="async" />}
</picture> </picture>
</div> </div>
); );
@ -69,7 +69,7 @@ function Slide({ banner, decorative = false }) {
<span className="hero-slide__glow hero-slide__glow--warm" aria-hidden="true" /> <span className="hero-slide__glow hero-slide__glow--warm" aria-hidden="true" />
<span className="hero-slide__glow hero-slide__glow--deep" aria-hidden="true" /> <span className="hero-slide__glow hero-slide__glow--deep" aria-hidden="true" />
<img className="hero-slide__letters" src={lettersImage} alt="" aria-hidden="true" /> <img className="hero-slide__letters" src={lettersImage} alt="" aria-hidden="true" />
{banner.image && <img className="hero-slide__photo" src={banner.image} alt={decorative ? '' : banner.subtitle || ''} />} {loadImage && banner.image && <img className="hero-slide__photo" src={banner.image} alt={decorative ? '' : banner.subtitle || ''} fetchpriority={decorative ? 'auto' : 'high'} decoding="async" />}
<div className="hero-slide__copy"> <div className="hero-slide__copy">
{banner.badge && <p className="hero-slide__badge">{banner.badge}</p>} {banner.badge && <p className="hero-slide__badge">{banner.badge}</p>}
<p className="hero-slide__title"> <p className="hero-slide__title">
@ -113,6 +113,12 @@ export default function HeroSlider({ banners }) {
const touch = useRef(null); const touch = useRef(null);
const current = looped ? (((pos - CLONES) % count) + count) % count : 0; const current = looped ? (((pos - CLONES) % count) + count) % count : 0;
const [visited, setVisited] = useState(() => new Set());
useEffect(() => {
// Загружаем текущий баннер и двух видимых соседей. Уже загруженные сохраняем.
const ids = [current - 1, current, current + 1].map((index) => banners[(index + count) % count]?.id);
setVisited((previous) => ids.every((id) => previous.has(id)) ? previous : new Set([...previous, ...ids]));
}, [current, banners, count]);
const items = looped const items = looped
? [...banners.slice(-CLONES), ...banners, ...banners.slice(0, CLONES)].map((b, i) => ({ b, i })) ? [...banners.slice(-CLONES), ...banners, ...banners.slice(0, CLONES)].map((b, i) => ({ b, i }))
: banners.map((b, i) => ({ b, i })); : banners.map((b, i) => ({ b, i }));
@ -200,7 +206,7 @@ export default function HeroSlider({ banners }) {
onClickCapture={active ? undefined : (e) => { e.preventDefault(); e.stopPropagation(); go(i < pos ? -1 : 1); }} onClickCapture={active ? undefined : (e) => { e.preventDefault(); e.stopPropagation(); go(i < pos ? -1 : 1); }}
> >
<SlideLink href={b.link_url} tabIndex={active ? undefined : -1}> <SlideLink href={b.link_url} tabIndex={active ? undefined : -1}>
<Slide banner={b} decorative={!active} /> <Slide banner={b} decorative={!active} loadImage={Math.abs(i - pos) <= 1 || visited.has(b.id)} />
</SlideLink> </SlideLink>
</div> </div>
); );

View File

@ -11,6 +11,15 @@ import { getConsent, hit, startMetrika } from '../lib/metrika.js';
export default function Layout() { export default function Layout() {
const { pathname, search, state } = useLocation(); const { pathname, search, state } = useLocation();
// Иначе браузер после нашего scrollTo/scrollIntoView восстанавливает старую
// позицию документа и при «Назад» снова уводит посетителя к низу страницы.
useEffect(() => {
if (!window.history || !('scrollRestoration' in window.history)) return undefined;
const previous = window.history.scrollRestoration;
window.history.scrollRestoration = 'manual';
return () => { window.history.scrollRestoration = previous; };
}, []);
// Метрика — только если посетитель уже согласился раньше; иначе ждём выбора в баннере. // Метрика — только если посетитель уже согласился раньше; иначе ждём выбора в баннере.
useEffect(() => { useEffect(() => {
if (getConsent() === 'all') startMetrika(); if (getConsent() === 'all') startMetrika();

View File

@ -144,24 +144,6 @@
pointer-events: none; pointer-events: none;
} }
.lead-field__select--date img {
right: 14px;
}
/* нативный календарь оставляем, но его значок прячем под иконкой из макета */
.lead-field__select--date input::-webkit-calendar-picker-indicator {
position: absolute;
right: 10px;
width: 26px;
height: 26px;
opacity: 0;
cursor: pointer;
}
.lead-field__select--date input {
padding: 0 44px 0 14px;
}
.lead-rating { .lead-rating {
display: flex; display: flex;
gap: 6px; gap: 6px;
@ -301,124 +283,32 @@
} }
} }
/* слоты по часам: выбор удобного часа приёма */ .lead-modal__availability {
.lead-slots { margin: 0;
display: grid; padding: 14px 16px;
grid-template-columns: repeat(6, minmax(0, 1fr)); background: var(--chip);
gap: 8px; border-radius: var(--r-12);
font-size: 13px;
line-height: 1.6;
color: var(--navy);
} }
.lead-slot { .lead-modal__availability--warning {
height: 40px; border: 1px solid #f3a398;
padding: 0 4px; border-left: 4px solid var(--coral);
background: #fff; background: #ffe3df;
border: 1px solid var(--chip-border); color: #92291f;
border-radius: var(--r-12);
font: inherit;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--navy);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
} }
.lead-slot:hover:not(:disabled) { /* Расширяем окно только для реального расписания выбранного врача. */
border-color: var(--coral);
}
.lead-slot--on {
background: var(--coral);
border-color: var(--coral);
color: #fff;
}
.lead-slot:disabled {
opacity: 0.35;
cursor: not-allowed;
text-decoration: line-through;
}
.lead-slots__hint {
margin: 0;
font-size: 13px;
color: var(--muted, #7a8699);
}
@media (max-width: 600px) {
.lead-slots {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* ---------- запись на приём: широкое окно в две колонки ----------
24 слота по 30 минут в одну колонку не помещались на экран ноутбука — окно приходилось прокручивать. */
.lead-modal--wide { .lead-modal--wide {
width: 920px; width: 920px;
} }
.lead-modal__form--split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'contacts date'
'doctor time'
'comment time';
align-items: start;
column-gap: 32px;
row-gap: 18px;
}
/* всё остальное (согласие, кнопка, подсказки) — на всю ширину под колонками */
.lead-modal__form--split > * {
grid-column: 1 / -1;
}
.lead-modal__form--split > .lead-modal__contacts {
grid-area: contacts;
/* в половине окна имя и телефон рядом не помещаются — друг под другом */
grid-template-columns: 1fr;
row-gap: 18px;
}
.lead-modal__form--split > .lead-field--doctor {
grid-area: doctor;
}
.lead-modal__form--split > .lead-field--comment {
grid-area: comment;
}
.lead-modal__form--split > .lead-field--date {
grid-area: date;
}
.lead-modal__form--split > .lead-field--time {
grid-area: time;
}
.lead-modal__form--split .lead-slot {
height: 36px;
}
/* узкий экран: снова одна колонка */
@media (max-width: 960px) { @media (max-width: 960px) {
.lead-modal--wide { .lead-modal--wide {
width: 650px; width: 650px;
} }
.lead-modal__form--split {
display: flex;
flex-direction: column;
}
}
/* телефон: слоты по 6 в ряд — 4 ряда вместо 6 */
@media (max-width: 600px) {
.lead-modal__form--split .lead-slots {
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
}
.lead-modal__form--split .lead-slot {
height: 36px;
font-size: 13px;
}
} }

View File

@ -2,7 +2,6 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import closeIcon from '../assets/figma/icon-close.svg'; import closeIcon from '../assets/figma/icon-close.svg';
import chevronIcon from '../assets/figma/icon-accordion-chevron.svg'; import chevronIcon from '../assets/figma/icon-accordion-chevron.svg';
import calendarIcon from '../assets/figma/icon-calendar.svg';
import checkIcon from '../assets/figma/icon-check.svg'; import checkIcon from '../assets/figma/icon-check.svg';
import dotGreen from '../assets/figma/icon-dot-green.svg'; import dotGreen from '../assets/figma/icon-dot-green.svg';
import starIcon from '../assets/figma/icon-star.svg'; import starIcon from '../assets/figma/icon-star.svg';
@ -10,12 +9,18 @@ import { api } from '../api.js';
import { useSiteData } from '../context/SiteData.jsx'; import { useSiteData } from '../context/SiteData.jsx';
import { formatPhoneInput, phoneComplete } from '../lib/phone.js'; import { formatPhoneInput, phoneComplete } from '../lib/phone.js';
import NativeBooking from './NativeBooking.jsx'; import NativeBooking from './NativeBooking.jsx';
import DoctorPicker, { DoctorSummary } from './DoctorPicker.jsx';
import { todayKey } from '../lib/slots.js';
import './LeadModal.css'; import './LeadModal.css';
const COPY = { const COPY = {
checkup: {
title: 'Записаться на чек-ап',
subtitle: 'Оставьте контакты — администратор согласует дату и расскажет о подготовке',
},
appointment: { appointment: {
title: 'Записаться на приём', title: 'Записаться на приём',
subtitle: 'Оставьте контакты — администратор поможет выбрать врача и время', subtitle: 'Выберите дату — покажем врачей со свободными окнами на этот день',
}, },
callback: { callback: {
title: 'Обратный звонок', title: 'Обратный звонок',
@ -31,94 +36,102 @@ const COPY = {
}, },
}; };
// Часы работы из настроек сайта («Пн–Вс: 08:00–20:00») → слоты по часам: 08:00 … 19:00. export default function LeadModal({ form = 'appointment', doctor, slot, service, specialty, program, message = '', onClose }) {
// Пока нет расписания 1С, это пожелание пациента; администратор подтверждает время звонком.
function hourSlots(hours) {
const m = /(\d{1,2}):\d{2}\s*[–—-]\s*(\d{1,2}):\d{2}/.exec(hours || '');
let from = m ? Number(m[1]) : 8;
let to = m ? Number(m[2]) : 20;
if (!(from >= 0 && to <= 24 && from < to)) [from, to] = [8, 20];
const list = [];
for (let h = from; h < to; h++) {
const hh = String(h).padStart(2, '0');
list.push(`${hh}:00`);
list.push(`${hh}:30`);
}
return list;
}
function todayISO() {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 10);
}
export default function LeadModal({ form = 'appointment', doctor, slot, service, specialty, message = '', onClose }) {
const copy = COPY[form] || COPY.appointment; const copy = COPY[form] || COPY.appointment;
const { hours } = useSiteData(); const { hours } = useSiteData();
const slots = useMemo(() => hourSlots(hours), [hours]);
const uid = useId(); const uid = useId();
const dialogRef = useRef(null); const dialogRef = useRef(null);
const firstFieldRef = useRef(null); const firstFieldRef = useRef(null);
// врач с записью через 1С: сначала выбор окна, потом контакты; если окон нет или 1С не отвечает — обычная заявка
const [nativeOff, setNativeOff] = useState(false);
const native = form === 'appointment' && doctor?.booking?.provider === 'native' && !nativeOff;
const onNativeUnavailable = useCallback(() => setNativeOff(true), []);
const needsDoctor = form === 'appointment' || form === 'review'; const needsDoctor = form === 'appointment' || form === 'review';
// Карточка врача открывает запись только к нему; общая форма сохраняет выбор.
const fixedDoctor = form === 'appointment' && Boolean(doctor?.id);
// запись на конкретную услугу: в списке только её врачи и врачи её специальности // запись на конкретную услугу: в списке только её врачи и врачи её специальности
const scoped = Boolean(service && !doctor && form === 'appointment'); const scoped = Boolean(service && !doctor && form === 'appointment');
const [doctors, setDoctors] = useState([]); const [doctors, setDoctors] = useState([]);
const [doctorsLoaded, setDoctorsLoaded] = useState(false); const [doctorsLoaded, setDoctorsLoaded] = useState(false);
const [unavailableDoctorId, setUnavailableDoctorId] = useState(null);
const [requestedSlot, setRequestedSlot] = useState(slot);
const [date, setDate] = useState(slot?.start?.slice(0, 10) || '');
const [availability, setAvailability] = useState({ loading: true, days: [], error: false });
const [values, setValues] = useState({ const [values, setValues] = useState({
name: '', name: '',
phone: '', phone: '',
doctorId: doctor?.id ? String(doctor.id) : '', doctorId: doctor?.id ? String(doctor.id) : '',
date: '',
time: '',
message, message,
rating: 5, rating: 5,
consent: false, consent: false,
}); });
const [status, setStatus] = useState({ state: 'idle', message: '' }); const [status, setStatus] = useState({ state: 'idle', message: '' });
const chosenDoctor = doctors.find((d) => String(d.id) === values.doctorId)
|| (doctor && String(doctor.id) === values.doctorId ? doctor : null);
const selectedDoctor = fixedDoctor ? doctor : chosenDoctor;
const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native'
&& unavailableDoctorId !== selectedDoctor.id;
const onNativeUnavailable = useCallback(
() => setUnavailableDoctorId(selectedDoctor?.id ?? null),
[selectedDoctor?.id],
);
const set = (key) => (e) => { const set = (key) => (e) => {
const v = e?.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e; const v = e?.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e;
if (key === 'doctorId') {
if (fixedDoctor) return;
setUnavailableDoctorId(null);
setRequestedSlot(undefined);
setStatus({ state: 'idle', message: '' });
}
setValues((prev) => ({ setValues((prev) => ({
...prev, ...prev,
[key]: key === 'phone' ? formatPhoneInput(v) : v, [key]: key === 'phone' ? formatPhoneInput(v) : v,
// другая дата — выбранный час мог стать прошедшим
...(key === 'date' ? { time: '' } : {}),
})); }));
}; };
// на сегодня — только слоты в будущем const changeDate = (e) => {
const isPast = (slot) => { setDate(e.target.value);
if (values.date !== todayISO()) return false; setRequestedSlot(undefined);
const now = new Date(); setUnavailableDoctorId(null);
const [h, m] = slot.split(':').map(Number); setStatus({ state: 'idle', message: '' });
const slotMinutes = h * 60 + (m || 0);
const nowMinutes = now.getHours() * 60 + now.getMinutes();
return slotMinutes <= nowMinutes;
}; };
useEffect(() => {
if (form !== 'appointment') return undefined;
const ctrl = new AbortController();
setAvailability((previous) => ({ ...previous, loading: true, error: false }));
api.bookingAvailability({ signal: ctrl.signal })
.then(({ data }) => {
if (!ctrl.signal.aborted) setAvailability({ loading: false, days: data?.enabled ? data.days || [] : [], error: false });
})
.catch(() => {
if (!ctrl.signal.aborted) setAvailability({ loading: false, days: [], error: true });
});
return () => ctrl.abort();
}, [form, date]);
useEffect(() => { useEffect(() => {
if (!needsDoctor) return undefined; if (!needsDoctor) return undefined;
if (fixedDoctor) {
setDoctors([doctor]);
setDoctorsLoaded(true);
return undefined;
}
const ctrl = new AbortController(); const ctrl = new AbortController();
const own = scoped ? (service.doctors || []).filter((d) => !d.absent) : []; const own = scoped ? (service.doctors || []).filter((d) => !d.absent) : [];
const load = !scoped ? api.doctors({}, { signal: ctrl.signal }) const load = !scoped ? api.doctors({}, { signal: ctrl.signal })
: specialty ? api.doctors({ specialty }, { signal: ctrl.signal }) : Promise.resolve({ data: [] }); : specialty ? api.doctors({ specialty }, { signal: ctrl.signal }) : Promise.resolve({ data: [] });
load load
.then(({ data }) => { .then(({ data }) => {
if (ctrl.signal.aborted) return;
const byId = new Map(); const byId = new Map();
for (const d of [...own, ...(data || [])]) if (!byId.has(d.id)) byId.set(d.id, d); for (const d of [...(data || []), ...own]) {
if (!d.absent && !byId.has(d.id)) byId.set(d.id, d);
}
setDoctors([...byId.values()]); setDoctors([...byId.values()]);
}) })
.catch(() => setDoctors(own)) .catch(() => { if (!ctrl.signal.aborted) setDoctors(own); })
.finally(() => setDoctorsLoaded(true)); .finally(() => { if (!ctrl.signal.aborted) setDoctorsLoaded(true); });
return () => ctrl.abort(); return () => ctrl.abort();
}, [needsDoctor, scoped, service, specialty]); }, [needsDoctor, fixedDoctor, doctor, scoped, service, specialty]);
// Escape, фокус внутри окна, блокировка прокрутки страницы под ним // Escape, фокус внутри окна, блокировка прокрутки страницы под ним
useEffect(() => { useEffect(() => {
@ -130,7 +143,9 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
const onKey = (e) => { const onKey = (e) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onClose();
if (e.key === 'Tab' && dialogRef.current) { if (e.key === 'Tab' && dialogRef.current) {
const focusable = dialogRef.current.querySelectorAll('button, [href], input, select, textarea'); const focusable = [...dialogRef.current.querySelectorAll('button, [href], input, select, textarea')]
.filter((element) => !element.disabled && !element.closest('[hidden]')
&& (typeof element.getClientRects !== 'function' || element.getClientRects().length > 0));
if (!focusable.length) return; if (!focusable.length) return;
const first = focusable[0]; const first = focusable[0];
const last = focusable[focusable.length - 1]; const last = focusable[focusable.length - 1];
@ -152,15 +167,41 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
}, [onClose]); }, [onClose]);
const doctorOptions = useMemo( const doctorOptions = useMemo(
() => [...doctors].sort((a, b) => a.full_name.localeCompare(b.full_name, 'ru')), () => {
[doctors], const allowed = date && form === 'appointment'
? new Set(availability.days.find((day) => day.date === date)?.doctor_ids || []) : null;
return [...doctors].filter((d) => !allowed || allowed.has(d.id))
.sort((a, b) => a.full_name.localeCompare(b.full_name, 'ru'));
},
[doctors, date, form, availability.days],
); );
const nameRequired = form !== 'callback'; const nameRequired = form !== 'callback';
const textRequired = form === 'review'; const textRequired = form === 'review';
const availabilityMessage = date && availability.error
? 'Не удалось проверить расписание. Попробуйте выбрать дату ещё раз.'
: date && !availability.loading && doctorsLoaded && doctorOptions.length === 0
? 'На эту дату свободных окон нет. Выберите другую дату.'
: doctorsLoaded && doctorOptions.length === 0
? 'Список врачей сейчас недоступен. Позвоните в клинику, чтобы уточнить запись.'
: date ? 'Выберите врача из списка — покажем его свободные часы на выбранную дату.' : '';
// Дата фильтрует окна, но не отменяет сделанный посетителем выбор врача.
const selectedDateMessage = selectedDoctor && date
? availability.loading ? 'Проверяем свободные окна выбранного врача…'
: availability.error ? 'Не удалось проверить расписание. Попробуйте выбрать дату ещё раз.'
: !availability.days.find((day) => day.date === date)?.doctor_ids.includes(selectedDoctor.id)
? fixedDoctor
? 'У выбранного врача на эту дату свободных окон нет. Выберите другую дату.'
: 'У выбранного врача на эту дату свободных окон нет. Выберите другую дату или другого специалиста.' : ''
: '';
const appointmentMessage = selectedDoctor ? selectedDateMessage : availabilityMessage;
const submit = async (e) => { const submit = async (e) => {
e.preventDefault(); e.preventDefault();
if (form === 'appointment' && !selectedDoctor) {
setStatus({ state: 'error', message: 'Сначала выберите врача' });
return;
}
if (nameRequired && !values.name.trim()) { if (nameRequired && !values.name.trim()) {
setStatus({ state: 'error', message: 'Укажите, как к вам обращаться' }); setStatus({ state: 'error', message: 'Укажите, как к вам обращаться' });
return; return;
@ -179,17 +220,21 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
} }
const extra = {}; const extra = {};
if (values.date) extra.date = values.date;
if (values.time) extra.time = values.time;
if (form === 'review') extra.rating = values.rating; if (form === 'review') extra.rating = values.rating;
if (form === 'checkup' && program) {
extra.checkup_slug = program.slug;
extra.checkup_name = program.name;
}
setStatus({ state: 'sending', message: '' }); setStatus({ state: 'sending', message: '' });
try { try {
const { data } = await api.createLead({ const { data } = await api.createLead({
form, // Заявка на программу использует существующую форму записи,
// а не создание приёма конкретного врача в 1С.
form: form === 'checkup' ? 'appointment' : form,
name: values.name.trim(), name: values.name.trim(),
phone: values.phone, phone: values.phone,
doctor_id: values.doctorId ? Number(values.doctorId) : undefined, doctor_id: fixedDoctor ? doctor.id : values.doctorId ? Number(values.doctorId) : undefined,
service_id: service?.id, service_id: service?.id,
message: values.message.trim(), message: values.message.trim(),
extra, extra,
@ -203,10 +248,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
}; };
const titleId = `${uid}-title`; const titleId = `${uid}-title`;
// запись на приём: окно шире, чтобы дни и время помещались без прокрутки; форма заявки — в две колонки const wide = native;
// (контакты слева, дата и время справа)
const wide = form === 'appointment' && status.state !== 'done';
const split = wide && !native;
return ( return (
<div className="lead-modal__backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}> <div className="lead-modal__backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
@ -223,7 +265,8 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
{copy.title} {copy.title}
</h2> </h2>
<p className="lead-modal__subtitle"> <p className="lead-modal__subtitle">
{doctor ? `Врач: ${doctor.full_name}` : copy.subtitle} {(form === 'appointment' ? selectedDoctor : doctor)
? `Врач: ${(form === 'appointment' ? selectedDoctor : doctor).full_name}` : copy.subtitle}
</p> </p>
</div> </div>
<button type="button" className="lead-modal__close" onClick={onClose} aria-label="Закрыть"> <button type="button" className="lead-modal__close" onClick={onClose} aria-label="Закрыть">
@ -231,8 +274,43 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</button> </button>
</div> </div>
{native ? ( {form === 'appointment' && status.state !== 'done' && (
<NativeBooking doctor={doctor} initialSlot={slot} onClose={onClose} onUnavailable={onNativeUnavailable} /> <label className="lead-field">
<span className="lead-field__label">Дата приёма</span>
<input
ref={firstFieldRef}
className="lead-field__control"
type="date"
min={todayKey()}
value={date}
onInput={changeDate}
onChange={changeDate}
/>
<span className="lead-modal__subtitle">{selectedDoctor ? 'Выберите дату — покажем свободные окна выбранного врача.' : 'Можно выбрать дату и увидеть только свободных в этот день врачей.'}</span>
</label>
)}
{form === 'appointment' && status.state !== 'done' && (fixedDoctor ? (
<DoctorSummary doctor={selectedDoctor} />
) : (
<DoctorPicker
doctors={selectedDoctor && !doctorOptions.some((d) => d.id === selectedDoctor.id) ? [selectedDoctor, ...doctorOptions] : doctorOptions}
value={values.doctorId}
onChange={set('doctorId')}
date={date}
loading={!doctorsLoaded}
disabled={Boolean(date && availability.loading)}
/>
))}
{form === 'appointment' && (!selectedDoctor || selectedDateMessage) ? (
appointmentMessage ? (
<p className={`lead-modal__availability${(selectedDoctor && date && !availability.loading) || (doctorsLoaded && !date && doctorOptions.length === 0) ? ' lead-modal__availability--warning' : ''}`} role="status">
{appointmentMessage}
</p>
) : null
) : native ? (
<NativeBooking key={`${selectedDoctor.id}:${date}`} doctor={selectedDoctor} filterDate={date} initialSlot={requestedSlot} onClose={onClose} onUnavailable={onNativeUnavailable} />
) : status.state === 'done' ? ( ) : status.state === 'done' ? (
<div className="lead-modal__done" role="status"> <div className="lead-modal__done" role="status">
<p>{status.message}</p> <p>{status.message}</p>
@ -241,12 +319,20 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</button> </button>
</div> </div>
) : ( ) : (
<form className={`lead-modal__form${split ? ' lead-modal__form--split' : ''}`} onSubmit={submit} noValidate> <form className="lead-modal__form" onSubmit={submit} noValidate>
{form === 'appointment' && (
<p className="lead-modal__availability lead-modal__availability--warning" role="status">
Свободные окна этого врача сейчас недоступны для онлайн-записи. Оставьте контакты — администратор уточнит расписание.
</p>
)}
{form === 'checkup' && program?.name && (
<p className="lead-modal__availability">Программа: <strong>{program.name}</strong></p>
)}
<div className="lead-modal__row lead-modal__contacts"> <div className="lead-modal__row lead-modal__contacts">
<label className="lead-field"> <label className="lead-field">
<span className="lead-field__label">Ваше имя</span> <span className="lead-field__label">Ваше имя</span>
<input <input
ref={firstFieldRef} ref={form === 'appointment' ? undefined : firstFieldRef}
className="lead-field__control" className="lead-field__control"
type="text" type="text"
autoComplete="name" autoComplete="name"
@ -269,13 +355,13 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</label> </label>
</div> </div>
{needsDoctor && !(scoped && doctorsLoaded && doctorOptions.length === 0) && ( {form === 'review' && (
<label className="lead-field lead-field--doctor"> <label className="lead-field lead-field--doctor">
<span className="lead-field__label">Специалист</span> <span className="lead-field__label">Специалист</span>
<span className="lead-field__select"> <span className="lead-field__select">
<select className="lead-field__control" value={values.doctorId} onChange={set('doctorId')}> <select className="lead-field__control" value={values.doctorId} onChange={set('doctorId')}>
<option value=""> <option value="">
{form === 'review' ? 'Выберите врача, если отзыв о нём' : scoped ? 'Любой врач по этой услуге' : 'Выберите направление или врача'} Выберите врача, если отзыв о нём
</option> </option>
{doctor && !doctorOptions.some((d) => d.id === doctor.id) && ( {doctor && !doctorOptions.some((d) => d.id === doctor.id) && (
<option value={doctor.id}>{doctor.full_name}</option> <option value={doctor.id}>{doctor.full_name}</option>
@ -292,55 +378,6 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</label> </label>
)} )}
{form === 'appointment' && (
<>
<label className="lead-field lead-field--date">
<span className="lead-field__label">Дата</span>
<span className="lead-field__select lead-field__select--date">
<input
className="lead-field__control"
type="date"
min={todayISO()}
value={values.date}
onChange={set('date')}
/>
<img src={calendarIcon} alt="" width="18" height="18" />
</span>
</label>
<div className="lead-field lead-field--time">
<span className="lead-field__label" id={`${uid}-time`}>
Удобное время
</span>
{values.date ? (
<div className="lead-slots" role="radiogroup" aria-labelledby={`${uid}-time`}>
{slots
.filter((t) => !isPast(t))
.map((t) => {
const on = values.time === t;
return (
<button
key={t}
type="button"
role="radio"
aria-checked={on}
className={`lead-slot${on ? ' lead-slot--on' : ''}`}
onClick={() => setValues((p) => ({ ...p, time: on ? '' : t }))}
>
{t}
</button>
);
})}
{values.date === todayISO() && slots.filter((t) => !isPast(t)).length === 0 && (
<p className="lead-slots__hint">На сегодня свободного времени уже нет. Пожалуйста, выберите другую дату.</p>
)}
</div>
) : (
<p className="lead-slots__hint">Сначала выберите дату — покажем время приёма</p>
)}
</div>
</>
)}
{form === 'review' && ( {form === 'review' && (
<div className="lead-field"> <div className="lead-field">
<span className="lead-field__label">Оценка</span> <span className="lead-field__label">Оценка</span>
@ -362,7 +399,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</div> </div>
)} )}
{form !== 'callback' && ( {form !== 'callback' && form !== 'checkup' && (
<label className="lead-field lead-field--comment"> <label className="lead-field lead-field--comment">
<span className="lead-field__label">{form === 'review' ? 'Текст отзыва' : 'Комментарий'}</span> <span className="lead-field__label">{form === 'review' ? 'Текст отзыва' : 'Комментарий'}</span>
<textarea <textarea

View File

@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import checkIcon from '../assets/figma/icon-check.svg'; import checkIcon from '../assets/figma/icon-check.svg';
import chevronIcon from '../assets/figma/icon-accordion-chevron.svg';
import { api } from '../api.js'; import { api } from '../api.js';
import { dayLabel, groupByDay, longDate, newRequestId, slotDate, slotTime, todayKey } from '../lib/slots.js'; import { dayLabel, groupByDay, longDate, newRequestId, slotDate, slotTime, todayKey } from '../lib/slots.js';
import { formatPhoneInput, phoneComplete } from '../lib/phone.js'; import { formatPhoneInput, phoneComplete } from '../lib/phone.js';
@ -53,14 +52,12 @@ export function NextSlots({ doctor, onPick, limit = 6 }) {
// Запись в два шага: 1) окно, 2) контакты, подтверждение звонком и согласие. // Запись в два шага: 1) окно, 2) контакты, подтверждение звонком и согласие.
// onUnavailable — 1С не отвечает или окон нет: окно заявки возвращается к обычной форме. // onUnavailable — 1С не отвечает или окон нет: окно заявки возвращается к обычной форме.
export default function NativeBooking({ doctor, initialSlot, onClose, onUnavailable }) { export default function NativeBooking({ doctor, initialSlot, filterDate = '', onClose, onUnavailable }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [slots, setSlots] = useState([]); const [slots, setSlots] = useState([]);
const [services, setServices] = useState([]);
const [day, setDay] = useState(initialSlot ? slotDate(initialSlot) : ''); const [day, setDay] = useState(initialSlot ? slotDate(initialSlot) : '');
const [branch, setBranch] = useState(''); const [branch, setBranch] = useState('');
const [chosen, setChosen] = useState(initialSlot || null); const [chosen, setChosen] = useState(initialSlot || null);
const [serviceId, setServiceId] = useState('');
const [values, setValues] = useState({ name: '', phone: '', comment: '', consent: false }); const [values, setValues] = useState({ name: '', phone: '', comment: '', consent: false });
const [status, setStatus] = useState({ state: 'idle', message: '' }); const [status, setStatus] = useState({ state: 'idle', message: '' });
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
@ -75,19 +72,25 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
api api
.doctorSlots(doctor.slug, { signal: ctrl.signal }) .doctorSlots(doctor.slug, { signal: ctrl.signal })
.then(({ data }) => { .then(({ data }) => {
if (!data?.enabled || !data.slots?.length) { if (ctrl.signal.aborted) return;
const actualSlots = (data?.slots || []).filter((s) => s.status === 'free' && new Date(s.start).getTime() > Date.now()
&& (!filterDate || slotDate(s) === filterDate));
if (!data?.enabled || !actualSlots.length) {
unavailable.current(); unavailable.current();
return; return;
} }
setSlots(data.slots); setSlots(actualSlots);
setServices(data.services || []); // Окно из карточки тоже должно оставаться свободным в свежем ответе 1С.
setChosen(initialSlot ? actualSlots.find((s) => s.start === initialSlot.start && s.branch_id === initialSlot.branch_id) || null : null);
// Imported catalog has no verified doctor-to-service bindings.
// Book the real doctor/slot without assigning an arbitrary procedure.
setLoaded(true); setLoaded(true);
}) })
.catch((e) => { .catch((e) => {
if (e.name !== 'AbortError') unavailable.current(); if (!ctrl.signal.aborted && e.name !== 'AbortError') unavailable.current();
}); });
return () => ctrl.abort(); return () => ctrl.abort();
}, [doctor.slug]); }, [doctor.slug, filterDate]);
const branches = useMemo(() => { const branches = useMemo(() => {
const m = new Map(); const m = new Map();
@ -100,7 +103,7 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
return slots.filter((s) => { return slots.filter((s) => {
if (branch && s.branch_id !== branch) return false; if (branch && s.branch_id !== branch) return false;
const t = new Date(s.start).getTime(); const t = new Date(s.start).getTime();
return isNaN(t) || t > now; return s.status === 'free' && t > now;
}); });
}, [slots, branch]); }, [slots, branch]);
const days = useMemo(() => groupByDay(visible), [visible]); const days = useMemo(() => groupByDay(visible), [visible]);
@ -123,7 +126,6 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
doctor_slug: doctor.slug, doctor_slug: doctor.slug,
start: chosen.start, start: chosen.start,
branch_id: chosen.branch_id, branch_id: chosen.branch_id,
service_id: serviceId || undefined,
name: values.name.trim(), name: values.name.trim(),
phone: values.phone, phone: values.phone,
verification_id: verificationId, verification_id: verificationId,
@ -275,7 +277,7 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
))} ))}
</div> </div>
)} )}
<p className="native-booking__hint">Выберите удобное время — на следующем шаге укажете контакты.</p> <p className="native-booking__hint">Выберите свободное окно врача — на следующем шаге укажете контакты.</p>
</div> </div>
); );
} }
@ -364,23 +366,6 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
</label> </label>
</div> </div>
{services.length > 1 && (
<label className="lead-field">
<span className="lead-field__label">Услуга</span>
<span className="lead-field__select">
<select className="lead-field__control" value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
<option value="">Выберите, если знаете</option>
{services.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<img src={chevronIcon} alt="" width="16" height="16" />
</span>
</label>
)}
<label className="lead-consent"> <label className="lead-consent">
<input type="checkbox" checked={values.consent} onChange={set('consent')} /> <input type="checkbox" checked={values.consent} onChange={set('consent')} />
<span className="lead-consent__box" aria-hidden="true"> <span className="lead-consent__box" aria-hidden="true">

View File

@ -26,9 +26,11 @@ function normalizeAddress(address) {
export function SiteDataProvider({ children }) { export function SiteDataProvider({ children }) {
const initial = useInitialApiData('site'); const initial = useInitialApiData('site');
const receivedAt = useInitialApiData('__receivedAt');
const [value, setValue] = useState(initial || FALLBACK); const [value, setValue] = useState(initial || FALLBACK);
useEffect(() => { useEffect(() => {
if (initial && receivedAt && Date.now() - receivedAt < 30_000) return undefined;
const ctrl = new AbortController(); const ctrl = new AbortController();
const opts = { signal: ctrl.signal }; const opts = { signal: ctrl.signal };

View File

@ -4,7 +4,7 @@ import App from './App.jsx';
import { ApiDataProvider } from './context/ApiData.jsx'; import { ApiDataProvider } from './context/ApiData.jsx';
import { parseBlocks } from './lib/serviceContent.js'; import { parseBlocks } from './lib/serviceContent.js';
import { serviceMetadata, doctorMetadata, priceMetadata, pageMetadata, jsonForHTML } from './lib/seo.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 { parseBlocks };
export function siteFromCommon(common) { export function siteFromCommon(common) {
@ -27,7 +27,7 @@ export function renderPage(url, data, siteURL) {
const service = initial[`service:${servicePath}`]?.data; const service = initial[`service:${servicePath}`]?.data;
if (service) { if (service) {
service.content_sections = parseBlocks(service.blocks); 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: {} }; initial[`doctors:${specialty}`] = { data: (initial.doctors?.data || []).filter((d) => d.specialties?.some((s) => s.slug === specialty)), meta: {} };
} }
const parts = pathname.split('/').filter(Boolean); const parts = pathname.split('/').filter(Boolean);
@ -82,7 +82,7 @@ export function renderPage(url, data, siteURL) {
export function renderService(service, parent, common, siteURL) { export function renderService(service, parent, common, siteURL) {
service.content_sections = parseBlocks(service.blocks); service.content_sections = parseBlocks(service.blocks);
const specialty = DIRECTION_MEDIA[directionOf(service.path)]?.specialty; const specialty = serviceSpecialty(service.path);
const initial = { const initial = {
prerendered: true, siteURL, site: common.site, prerendered: true, siteURL, site: common.site,
[`service:${service.path}`]: { data: service, meta: {} }, [`service:${service.path}`]: { data: service, meta: {} },

View File

@ -0,0 +1,22 @@
const normalize = (value) => String(value || '').toLocaleLowerCase('ru').replaceAll('ё', 'е').trim();
// Group by the catalog's real specialties, not by guesses from a doctor's name or role.
export function groupDoctors(doctors, query = '') {
const terms = normalize(query).split(/\s+/).filter(Boolean);
const groups = new Map();
for (const doctor of doctors) {
const specialties = doctor.specialties?.length ? doctor.specialties : [{ slug: 'other', name: 'Другие специалисты' }];
for (const specialty of specialties) {
const name = specialty.name_plural || specialty.name || 'Другие специалисты';
const key = String(specialty.slug || specialty.id || name);
const searchable = normalize(`${doctor.full_name} ${doctor.position || ''} ${name}`);
if (!terms.every((term) => searchable.includes(term))) continue;
if (!groups.has(key)) groups.set(key, { key, name, doctors: [] });
const group = groups.get(key);
if (!group.doctors.some((entry) => entry.id === doctor.id)) group.doctors.push(doctor);
}
}
return [...groups.values()]
.sort((a, b) => (a.key === 'other') - (b.key === 'other') || a.name.localeCompare(b.name, 'ru'))
.map((group) => ({ ...group, doctors: group.doctors.sort((a, b) => a.full_name.localeCompare(b.full_name, 'ru')) }));
}

View File

@ -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 },
}; };
// Отдельные услуги, для которых в макете нарисован свой герой. // Отдельные услуги, для которых в макете нарисован свой герой.
@ -91,6 +94,15 @@ export function directionOf(path) {
return String(path || '').split('/')[0]; 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/uploads/…), главнее картинки макета — как у чек-апов.
// Старые картинки, перенесённые из Битрикса (/media/iblock/…), макет не перебивают. // Старые картинки, перенесённые из Битрикса (/media/iblock/…), макет не перебивают.
const isUploaded = (src) => typeof src === 'string' && src.includes('/media/uploads/'); const isUploaded = (src) => typeof src === 'string' && src.includes('/media/uploads/');

View File

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

View File

@ -5,22 +5,31 @@ import { useInitialApiData } from '../context/ApiData.jsx';
// и не перетирает результат нового. // и не перетирает результат нового.
export function useApi(load, deps, key) { export function useApi(load, deps, key) {
const initial = useInitialApiData(key); const initial = useInitialApiData(key);
const receivedAt = useInitialApiData('__receivedAt');
const [state, setState] = useState(() => initial const [state, setState] = useState(() => initial
? { ...initial, loading: false, error: null } ? { ...initial, loading: false, error: null }
: { data: null, meta: {}, loading: true, error: null }); : { data: null, meta: {}, loading: true, error: null });
useEffect(() => { useEffect(() => {
// Сервер уже прислал эти публичные данные: при гидратации второй запрос не нужен.
// Через 30 секунд при следующем открытии компонента снова идём в API.
if (initial && receivedAt && Date.now() - receivedAt < 30_000) {
setState({ ...initial, loading: false, error: null });
return undefined;
}
const ctrl = new AbortController(); const ctrl = new AbortController();
setState((s) => ({ ...s, loading: true, error: null })); setState((s) => ({ ...s, loading: true, error: null }));
load(ctrl.signal) load(ctrl.signal)
.then(({ data, meta }) => setState({ data, meta, loading: false, error: null })) .then(({ data, meta }) => {
if (!ctrl.signal.aborted) setState({ data, meta, loading: false, error: null });
})
.catch((error) => { .catch((error) => {
if (error.name === 'AbortError') return; if (ctrl.signal.aborted || error.name === 'AbortError') return;
setState({ data: null, meta: {}, loading: false, error }); setState({ data: null, meta: {}, loading: false, error });
}); });
return () => ctrl.abort(); return () => ctrl.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, deps); }, [key, ...deps]);
return state; return state;
} }

View File

@ -17,6 +17,8 @@ import { ApiDataProvider } from './context/ApiData.jsx';
const root = document.getElementById('root'); const root = document.getElementById('root');
const initialData = JSON.parse(document.getElementById('page-data')?.textContent || '{}'); const initialData = JSON.parse(document.getElementById('page-data')?.textContent || '{}');
// Время получения публичного SSR-снимка. Не кэшируем данные админки и расписание.
initialData.__receivedAt = Date.now();
const app = ( const app = (
<React.StrictMode> <React.StrictMode>
<ApiDataProvider value={initialData}> <ApiDataProvider value={initialData}>

View File

@ -32,7 +32,7 @@ export default function CheckupPage() {
const intro = introText(c.blocks); const intro = introText(c.blocks);
const lead = cleanText(c.lead); const lead = cleanText(c.lead);
const book = () => open({ form: 'appointment', message: `Чек-ап: ${name}` }); const book = () => open({ form: 'checkup', program: { name, slug: c.slug }, message: `Чек-ап: ${name}` });
const askPrice = () => open({ form: 'service_order', message: `Стоимость чек-апа: ${name}` }); const askPrice = () => open({ form: 'service_order', message: `Стоимость чек-апа: ${name}` });
// первый столбец списка заполняется сверху вниз, затем второй — как в макете // первый столбец списка заполняется сверху вниз, затем второй — как в макете

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useEffect, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link, useLocation, useNavigationType, useSearchParams } from 'react-router-dom';
import heroImage from '../assets/figma/checkups-hero-3d.webp'; import heroImage from '../assets/figma/checkups-hero-3d.webp';
import { api } from '../api.js'; import { api } from '../api.js';
import Breadcrumbs from '../components/Breadcrumbs.jsx'; import Breadcrumbs from '../components/Breadcrumbs.jsx';
@ -30,12 +30,29 @@ export default function CheckupsPage() {
usePageTitle('Чек-апы'); usePageTitle('Чек-апы');
const { open } = useLeadModal(); const { open } = useLeadModal();
const checkups = useApi((signal) => api.checkups({ signal }), [], 'checkups'); const checkups = useApi((signal) => api.checkups({ signal }), [], 'checkups');
const [active, setActive] = useState('adults'); const [params, setParams] = useSearchParams();
const location = useLocation();
const navigationType = useNavigationType();
const listStart = useRef(null);
const active = params.get('audience') === 'kids' ? 'kids' : 'adults';
useEffect(() => {
if (navigationType === 'POP' && location.state?.checkupList) {
listStart.current?.scrollIntoView({ block: 'start', behavior: 'instant' });
}
}, [location.key, navigationType]);
const pick = () => open({ form: 'callback', message: 'Помогите подобрать чек-ап' }); const pick = () => open({ form: 'callback', message: 'Помогите подобрать чек-ап' });
// вкладки переключают группу: показываются программы только для взрослых или только детские // вкладки переключают группу: показываются программы только для взрослых или только детские
const selectTab = (id) => setActive(id); const selectTab = (id) => {
const next = new URLSearchParams(params);
if (id === 'kids') next.set('audience', 'kids');
else next.delete('audience');
// Меняем текущую запись истории, чтобы «Назад» возвращал к списку,
// а не перебирал ранее выбранные вкладки.
setParams(next, { replace: true, state: { ...location.state, keepScroll: true, checkupList: true } });
};
return ( return (
<div className="checkups-page"> <div className="checkups-page">
@ -56,7 +73,7 @@ export default function CheckupsPage() {
<section className="checkups-content"> <section className="checkups-content">
<div className="container checkups-content__inner"> <div className="container checkups-content__inner">
<h2 className="checkups-content__title">Программы чек-апов</h2> <h2 ref={listStart} className="checkups-content__title" style={{ scrollMarginTop: 24 }}>Программы чек-апов</h2>
<p className="checkups-content__text"> <p className="checkups-content__text">
Выберите программу по возрасту и задаче. Полный состав обследования и актуальную стоимость уточнит Выберите программу по возрасту и задаче. Полный состав обследования и актуальную стоимость уточнит
администратор. администратор.

View File

@ -47,6 +47,7 @@ export default function PromosPage() {
{(promos.data || []).map((p) => { {(promos.data || []).map((p) => {
// связь с чек-апом задаётся в админке; сопоставление по адресу — запасной вариант // связь с чек-апом задаётся в админке; сопоставление по адресу — запасной вариант
const checkup = p.checkup_slug ? { slug: p.checkup_slug } : checkupBySlug.get(baseSlug(p.slug)); const checkup = p.checkup_slug ? { slug: p.checkup_slug } : checkupBySlug.get(baseSlug(p.slug));
const programName = checkup ? cleanText(checkupBySlug.get(baseSlug(checkup.slug))?.name || p.title) : '';
const image = promoImage(p); const image = promoImage(p);
const lead = cleanText(p.lead); const lead = cleanText(p.lead);
return ( return (
@ -71,7 +72,11 @@ export default function PromosPage() {
<button <button
type="button" type="button"
className="btn btn--coral promo-card__btn" className="btn btn--coral promo-card__btn"
onClick={() => open({ form: 'appointment', message: `Акция: ${p.title}` })} onClick={() => open(checkup ? {
form: 'checkup',
program: { slug: checkup.slug, name: programName },
message: `Чек-ап: ${programName}. Акция: ${cleanText(p.title)}`,
} : { form: 'appointment', message: `Акция: ${p.title}` })}
> >
Записаться Записаться
</button> </button>

View File

@ -11,7 +11,7 @@ import { useSiteData } from '../context/SiteData.jsx';
import { priceLabel, telHref } from '../lib/format.js'; import { priceLabel, telHref } from '../lib/format.js';
import { servicePath, specialtyPath, priceSectionPath } from '../lib/links.js'; import { servicePath, specialtyPath, priceSectionPath } from '../lib/links.js';
import { servicePriceSection } from '../lib/serviceNavigation.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 { URGENT_TITLE, parseBlocks } from '../lib/serviceContent.js';
import { cleanText } from '../lib/text.js'; import { cleanText } from '../lib/text.js';
import { useApi } from '../lib/useApi.js'; import { useApi } from '../lib/useApi.js';
@ -76,7 +76,7 @@ function DirectionView({ direction }) {
className="btn btn--coral direction-card__btn" className="btn btn--coral direction-card__btn"
onClick={() => open({ onClick={() => open({
form: 'appointment', service: c, message: `Услуга: ${c.name}`, 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 media = DIRECTION_MEDIA[directionOf(service.path)];
const image = SERVICE_IMAGES[service.path] || service.image || (!parent ? media?.image : ''); const image = SERVICE_IMAGES[service.path] || service.image || (!parent ? media?.image : '');
const lead = cleanText(service.lead); 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 = [ const crumbs = [
{ to: '/', label: 'Главная' }, { to: '/', label: 'Главная' },
@ -165,7 +165,7 @@ function ServiceView({ service, parent }) {
{(service.prices?.length > 0 || service.path.startsWith('estetic/') || priceSection) && <PricesSection prices={service.prices || []} service={service} priceSection={priceSection} />} {(service.prices?.length > 0 || service.path.startsWith('estetic/') || priceSection) && <PricesSection prices={service.prices || []} service={service} priceSection={priceSection} />}
<ServiceDoctors service={service} specialtySlug={media?.specialty} /> <ServiceDoctors service={service} specialtySlug={serviceSpecialty(service.path)} />
<BookingSection service={service} /> <BookingSection service={service} />
<script id="service-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonForHTML(serviceSchema(service, parent, site, siteURL, <script id="service-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonForHTML(serviceSchema(service, parent, site, siteURL,
(service.doctors || []).filter((d) => !d.absent).sort((a, b) => Number(Boolean(b.photo)) - Number(Boolean(a.photo))).slice(0, 4))) }} /> (service.doctors || []).filter((d) => !d.absent).sort((a, b) => Number(Boolean(b.photo)) - Number(Boolean(a.photo))).slice(0, 4))) }} />