Merge branch 'main' of https://git.pirogov.ai/arazor72/mobile-app-prototip
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
# Приоритеты развития мобильного приложения — встреча 23 апр 2026
|
||||
|
||||
Клиника УГН (ЛОР, сурдология, аллергология, хирургия). Цель встречи — определить порядок развития функций: что закрываем как базу для всех пациентов, какие сегменты углубляем и в каком порядке.
|
||||
|
||||
## 1. Контекст и критерии приоритизации
|
||||
|
||||
**Текущие функции:** список врачей, запись на приём, ближайший приём, чат с оператором, профиль, семейный профиль, контакты.
|
||||
|
||||
### Ключевые факты о потоке
|
||||
|
||||
- **2 из 3 пациентов клиники — повторные.** Значительная доля — **повторные внутри одного лечения**: после первого визита (острое заболевание или обострение хронического) ещё 1–3+ приёма, процедуры, контроль.
|
||||
- **Бизнес-сегментация (из аналитики клиники)** — 10 сегментов, лидеры по выручке: амбулаторный поток (~120 млн), взрослая хирургия «заблокированный нос» (~30 млн), детская аденоидная хирургия (~20–30 млн), сурдология (~20 млн).
|
||||
|
||||
### Продуктовая логика: что делает приложение
|
||||
|
||||
**Приложение — инструмент удержания и углубления, а не привлечения.** Первичного пациента клиника получает через сайт, SEO, рекламу, сарафан. Приложение устанавливают уже пришедшие — в момент записи или на первом визите. Поэтому приоритет развития — **повторные во всех сегментах**.
|
||||
|
||||
Это совпадает с фактом «2 из 3 — повторные» и означает, что:
|
||||
- **Первичные сценарии в приложении** закрываются универсальным минимумом (запись + контакты + цены + AI-помощник, который знает услуги). Нет смысла строить специализированные первичные потоки для каждого из 10 сегментов.
|
||||
- **Повторные сценарии** — специфичны по сегменту и требуют отдельных модулей (бегунок, аудиограмма, АСИТ-дневник и т.п.).
|
||||
- **Исключения** — сегменты, где «первичный в приложении» ≠ «первый визит»: после первого визита пациент уходит, и вернуть его в клинику может только приложение.
|
||||
- **Сурдология** — после визита и демо пациент уходит думать 2–3 месяца о покупке аппарата.
|
||||
- **Хирургия (FESS, детские аденоиды, вазотомия)** — после пред-операционного приёма у хирурга пациент часто уходит **думать, решаться ли на операцию вообще**. Эти «сомневающиеся» — отдельная работа по возврату. Вторая часть — те, кто решился, уходят в 6-недельную подготовку (бегунок).
|
||||
- **АСИТ** — после назначения впереди 3–5-летний курс, есть сценарии «не начал» и «бросил в первые месяцы».
|
||||
|
||||
### Три слоя работы
|
||||
|
||||
1. **Фаза 1 · Транзакционная база** — детерминированные функции для любого пациента: запись, ближайший приём, чат с оператором, **план лечения, результаты/медкарта, заказ справок**. Без LLM и прямого чата с врачом. Минимум рисков, быстрый запуск.
|
||||
2. **Фаза 1.5 · Коммуникационная надстройка** — чат с медицинским консьержем (дежурным врачом/фельдшером) и AI-помощник в shadow-mode. Отделено от Фазы 1, чтобы регламенты и безопасность не задерживали релиз базы.
|
||||
3. **Фаза 2 · Сегментные модули** — углубление по приоритетным группам сегментов (A → C → B → D).
|
||||
|
||||
### Критерии приоритета
|
||||
|
||||
| Критерий | Что меряем |
|
||||
|---|---|
|
||||
| **Охват** | Сколько пациентов клиники затронуто (в абсолюте) |
|
||||
| **Глубина пользы** | Насколько закрывает реальную боль (есть ли альтернатива без нас) |
|
||||
| **Частота касаний** | Как часто функция возвращает пользователя в приложение |
|
||||
| **Бизнес-эффект** | Влияние на возвратность, средний чек, удержание, вклад в выручку |
|
||||
| **Сложность** | Вторичная ось: усилия (вкл. зависимости от МИС и контента) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Текущие функции — утилита и приоритет
|
||||
|
||||
### 🟢 Высокий приоритет (ядро ценности)
|
||||
|
||||
**Запись на приём** — главный транзакционный поток. Экономит 3–8 минут разговора, 24/7, ближайшие окна. Единственная функция, которая приводит новых пациентов через приложение. Усилить: запись из плана лечения в 1 клик (§3), запись из маршрутной карты пред-опа (§4).
|
||||
|
||||
**Ближайший приём** — «что со мной сейчас». Снимает тревогу «когда, куда, к кому». Перед визитом открывается 3–10 раз. Усилить: чек-лист подготовки, маршрут до кабинета, тригеры 24ч/2ч/15мин.
|
||||
|
||||
### 🟡 Средний приоритет (поддержка)
|
||||
|
||||
**Список врачей / карточка врача** — инструмент выбора при первом визите. Повторный пациент почти не открывает. Усилить: соцдоказательства, фильтр «по моему диагнозу».
|
||||
|
||||
**Чат с оператором** — замена звонка в регистратуру. Ограничен одним собеседником. Основное развитие — в §3.
|
||||
|
||||
**Профиль** — точка идентификации. Редко открывается, но критичен для персонализации.
|
||||
|
||||
### 🔴 Низкий приоритет (ниша)
|
||||
|
||||
**Семейный профиль** — высокая польза для 20–30% (родители с детьми, взрослые с пожилыми), становится критичным для сегмента «дети с аденоидами» и «сложные хроники — ЧБД».
|
||||
|
||||
**Контакты** — функция «галочка», 1–2 открытия за всё время.
|
||||
|
||||
### Вывод
|
||||
|
||||
Ядро «Запись + Ближайший приём» работает. Слабое место — **нет причины открывать приложение между визитами**. Это и закрывает джентльменский набор.
|
||||
|
||||
---
|
||||
|
||||
## 3. Джентльменский набор — базовые функции для всех пациентов
|
||||
|
||||
Минимум, который должен быть у любого пациента клиники, вне зависимости от диагноза и сегмента. **Приоритет до любых сегментных модулей.**
|
||||
|
||||
Набор сознательно разделён на две подфазы по риску и зависимостям: **транзакционная база** (детерминированные функции с понятным MVP) и **коммуникационная надстройка** (LLM и асинхронные каналы, требующие регламентов и безопасности). Это позволяет выпустить базу быстро и безопасно, а надстройку строить поверх уже стабильного фундамента.
|
||||
|
||||
### Фаза 1 · Транзакционная база
|
||||
|
||||
Функции, где пациент **что-то получает или делает** — без LLM и без диалога. Минимизируют риски, быстрее в запуск, дают те самые «не удалю это приложение»-причины.
|
||||
|
||||
| Функция | Статус | Что в ней |
|
||||
|---|---|---|
|
||||
| Запись на приём | ✅ есть | Запись 24/7, ближайшие окна. |
|
||||
| Ближайший приём | ✅ есть | Что, где, когда — снимает тревогу перед визитом. |
|
||||
| Чат с оператором | ✅ есть | Справки, переносы, счета, расписание (человеческий канал). |
|
||||
| Статьи / база знаний | ✅ есть (Спринт 1) | Уже есть. Источник правды для будущего RAG. |
|
||||
| **План лечения с приёма** | ❌ нет — фундамент | После каждого приёма структурированный чеклист: диагноз, назначения (препарат + доза + курс + календарь), ссылки на процедуры самопомощи (Группа A), контрольный приём, запись на следующий визит в 1 клик. Живой объект с напоминаниями. Источник правды для всех следующих надстроек. |
|
||||
| **Результаты обследований и медкарта** | ❌ нет | Пациент видит свои анализы, аудиограммы, снимки, заключения **без звонка в клинику**. Статус каждого результата: готов / в работе / годен до [дата]. Критично для Группы C (пред-оп): срок годности анализов виден в одном месте. |
|
||||
| **Заказ справок и финансовых документов** | ❌ нет | Справка для налогового вычета (13%), справки работодателю, копии заключений, счета. Заказ в 1 клик, готовая в приложении или с самовывозом. Снимает нагрузку с администраторов и даёт сильный retention-якорь даже у разовых пациентов. |
|
||||
|
||||
### Фаза 1.5 · Коммуникационная надстройка
|
||||
|
||||
Функции, где идёт **живой или AI-диалог** по поводу лечения. Строятся поверх уже работающей базы (плана лечения, медкарты, статей) и требуют отдельных регламентов — по ответственности, SLA, проверке качества.
|
||||
|
||||
| Функция | Статус | Что в ней | Ключевой риск |
|
||||
|---|---|---|---|
|
||||
| **Чат с медицинским консьержем** | ❌ нет | **Не прямой чат с лечащим врачом.** Дежурный врач / фельдшер / медсестра отвечает по протоколам на 80% рутины («можно ли совмещать с X», «нормально ли, что третий день болит», «когда повторно сдать анализ»), эскалирует клинически значимое лечащему. Асинхронно, SLA — X часов в рабочее время. | Перегрузка врачей и SLA-хаос, если пустить пациентов напрямую к хирургу. Консьерж-слой — основной буфер. Нужен регламент тарификации/компенсации врачам за эскалированные вопросы. |
|
||||
| **AI-помощник (RAG 24/7)** | ❌ нет | Знает базу знаний клиники, статьи, план лечения пациента, историю приёмов. Объясняет назначение, ищет ответ в статьях, оценивает «норма или срочно» и эскалирует к консьержу/врачу при тревожных признаках. | Галлюцинация LLM в медицинском контексте = юридический и репутационный риск. **Запускаем в shadow-mode**: ответы сначала идут через консьержа для валидации, метрики точности собираются, и только после набора статистики — переход в прямой режим для безопасных категорий вопросов. |
|
||||
|
||||
### Почему это ядро, а не сегмент
|
||||
|
||||
- **Работает на 100% пациентов** — без разделения по сегменту.
|
||||
- **Усиливает любое сегментное направление**: бегунок, слухопротезирование, АСИТ — всё опирается на план лечения, медкарту и тот же чат-канал.
|
||||
- **Закрывает главный пробел транзакционной модели** — «что делать между визитами».
|
||||
- **Частично закрывает самый массовый сегмент** (амбулаторный + хроники). Полностью закрыть их может только отдельный модуль «Процедуры самопомощи» (Группа A, §4.3) — это первый сегментный модуль после Фазы 1.5.
|
||||
|
||||
### Порядок работ внутри набора
|
||||
|
||||
**Фаза 1:**
|
||||
1. **План лечения с приёма** — фундамент. Без структурированных назначений не работает ничего поверх (ни AI, ни напоминания, ни процедуры самопомощи).
|
||||
2. **Результаты / медкарта** — следующий retention-якорь.
|
||||
3. **Заказ справок** — быстрый win для большой части пациентов, снимает нагрузку с администраторов.
|
||||
|
||||
**Фаза 1.5** (после стабилизации Фазы 1):
|
||||
4. **Чат с медицинским консьержем** — инфраструктура канала + регламент ответов.
|
||||
5. **AI-помощник на RAG (shadow-mode)** — сначала ответы валидируются консьержем, потом постепенный переход в прямой режим для безопасных категорий.
|
||||
|
||||
### Критическая зависимость от МИС
|
||||
|
||||
«План лечения» — это структурированные данные (препарат, доза, частота, курс, процедура). Два сценария:
|
||||
- **МИС отдаёт назначения по API структурированно** — план лечения собирается автоматически из данных приёма. Предпочтительно.
|
||||
- **МИС отдаёт только PDF-заключение** — план лечения MVP стартует с **ручного ввода врачом в виджет** (шаблоны по нозологиям + чеклисты + автоподстановка из предыдущего приёма). Интеграция с МИС — отдельной задачей позже.
|
||||
|
||||
Ответ на этот вопрос определяет сроки и стоимость Фазы 1. Добавлен в §6 как первоочередной.
|
||||
|
||||
---
|
||||
|
||||
## 4. Сегменты пациентов через призму приложения
|
||||
|
||||
### 4.1. Фундаментальное деление: первичный vs повторный
|
||||
|
||||
| Линза | Первичный в приложении | Повторный в приложении |
|
||||
|---|---|---|
|
||||
| **Главный job** | «Куда мне обратиться и как записаться» | «Что мне делать сейчас по моему лечению» |
|
||||
| **Источник** | Попадает через сайт/рекламу/сарафан | Приложение уже установлено, уведомление/сам открывает |
|
||||
| **Частота открытия** | 1–3 раза до визита | Ежедневно в активном эпизоде |
|
||||
| **Что нужно в приложении** | Базовый минимум (запись, цены, контакты, врачи) | Специализированный модуль сегмента |
|
||||
| **Приоритет развития** | Низкий (веб и реклама работают лучше) | ● Высокий (2/3 потока) |
|
||||
|
||||
**Ключевое следствие.** Специализированных «первичных» модулей под каждый сегмент строить не нужно. Исключения — сегменты, где «после первого визита» начинается длинный путь: сурдология, хирургия, АСИТ. Там «первичный» в терминах клиники ≠ «первичный» в терминах приложения.
|
||||
|
||||
### 4.2. Матрица 10 сегментов × первичный / повторный
|
||||
|
||||
Из 10 сегментов бизнес-аналитики выделяем: объём × чек × что нужно в приложении для первичного × что нужно для повторного × итоговый приоритет приложения.
|
||||
|
||||
| # | Сегмент (из аналитики) | Объём / Вклад | Первичный в приложении | Повторный в приложении (главная работа) | Приоритет в App |
|
||||
|---|---|---|---|---|:-:|
|
||||
| 1 | **Взрослые «заблокированный нос»** (полипы, перегородка, FESS) | 300 оп/год · 100т · 30 млн | Джентльменский + материалы о методах (FESS/лазер), кейсы пациентов, калькулятор, **возвратные push для сомневающихся после пред-оп приёма** | Пред-оп бегунок (6 нед) → восстановление → контроль 3/6/12 мес | ● **Высокий** (Группа C) |
|
||||
| 2 | **Амбулаторный поток** (острые и хроники ЛОР) | Тысячи/мес · 120 млн | Джентльменский | План лечения + эпизод лечения + дневник симптомов (§3) **+ модуль процедур самопомощи: библиотека техник (промывание носа физраствором, полоскание горла, ингаляции), напоминания, трекер комплаенса** | ● **Высший** (Группа A — отдельный модуль) |
|
||||
| 3 | **Родители детей с аденоидами** | 400–500 оп/год · 60т · 20–30 млн | Джентльменский + семейный профиль + подготовка ребёнка + **материалы «нужна ли операция» и возврат сомневающихся родителей** | Пред-оп бегунок (детская версия) → восстановление. Далее — редко. | ● **Высокий** (Группа C) |
|
||||
| 4 | **Потеря слуха (сурдология)** | 20 млн/год | **Нестандартный первичный:** аудиограмма в профиле, аудио-демо, каталог моделей, калькулятор, шеринг близкому, возвратные push | Паспорт аппарата, сервисный календарь, расходники, калибровка раз в год, дневник адаптации | ● **Высокий** (Группа B — уникальный модуль) |
|
||||
| 5 | **Сложные хроники** (иммунология/аллергология, ЧБД) | Высокий, часть 120 млн · длинный LTV | Джентльменский + семейный (ЧБД) + запись на консилиум | Модуль процедур самопомощи (Группа A): промывания, полоскания, ингаляции у ЧБД. Плюс специализация: **АСИТ-трекер** (дневник симптомов + пыльцевой календарь + навигатор побочки) | ● **Высокий** (Группа A + Группа D) |
|
||||
| 6 | **Зависимость от капель (вазотомия)** | Высокий объём · входной в хирургию | Джентльменский + материалы «как слезть с капель» + **возврат сомневающихся после пред-оп приёма** | Пред-оп бегунок (компактная версия) → восстановление | ● **Высокий** (Группа C) |
|
||||
| 7 | **Пульмонология** (кашель/астма) | Средний · сезонный | Джентльменский | Дневник астмы, напоминания об ингаляторах, контроль триггеров (пересекается с АСИТ) | ◐ **Средний** (частично Группа D) |
|
||||
| 8 | **Социально активные храпуны** | Низкий · высокая платёжеспособность | Джентльменский + образовательный контент о сомнологии | Сомнологический чек-up, СИПАП-трекер (если назначен) | ○ **Низкий** |
|
||||
| 9 | **Фониатрия** (голос) | Очень низкий · срочный | Джентльменский + срочная запись | Короткое окно, низкая повторность | ○ **Низкий** |
|
||||
| 10 | **Check-up и Второе мнение** | Единичный · высокая маржа | Джентльменский + позиционирование бренда Оленевой | Разовая услуга, минимальная повторность | ○ **Низкий** |
|
||||
|
||||
### 4.3. Четыре группы сегментов для повторных
|
||||
|
||||
**Группа A. Амбулаторный поток + хроники — процедуры самопомощи** — сегменты 2 + 5 (повторная часть). Самый массовый сегмент × ежедневная повторность.
|
||||
|
||||
Джентльменский набор (план лечения + чат с врачом + AI) закрывает коммуникацию и напоминания, **но не закрывает саму регулярную работу пациента между приёмами**:
|
||||
- Промывание носа физраствором (при хр. рините, синусите, после операций)
|
||||
- Полоскание горла (при хр. тонзиллите)
|
||||
- Ингаляции
|
||||
- Гимнастика слуховой трубы (при евстахиите)
|
||||
- Голосовые упражнения (фониатрия)
|
||||
- Туалет уха (при хр. отите)
|
||||
|
||||
**Что нужно в модуле:**
|
||||
- **Библиотека техник** — короткие видео и пошаговые инструкции «как правильно». Ошибки в технике (сильный напор при промывании, не тот раствор) — частая причина, почему «не помогает».
|
||||
- **Напоминания** — утро/вечер по схеме, привязаны к плану лечения.
|
||||
- **Трекер комплаенса** — ежедневные отметки, стрики, сводка «сколько дней подряд».
|
||||
- **Дневник симптомов в связке** — «промываю 5 дней, насморк уменьшился с 4 до 2» — главный мотиватор продолжать.
|
||||
- **Сводка для врача** — перед контрольным приёмом врач видит, что пациент делал, как часто, с какой динамикой симптомов.
|
||||
|
||||
**Это отдельный модуль, не продолжение Фазы 1.** Он опирается на план лечения как на источник назначений, но требует собственного контента (библиотека техник) и UX (трекер).
|
||||
|
||||
**Группа B. Сурдология** — сегмент 4. Уникальный набор функций (аудиограмма, аудио-демо, каталог, паспорт аппарата). Пожизненная повторность. Высокий чек. **Отдельный модуль, не пересекается с другими.**
|
||||
|
||||
**Группа C. Пред-операционная подготовка + восстановление** — сегменты 1 + 3 + 6 (одна механика для трёх сегментов). **Две фазы, как у Группы B:**
|
||||
- **Кандидаты на операцию** — после пред-оп приёма ушли «думать». Возврат через материалы, кейсы пациентов, чат с хирургом, возвратные push-триггеры (3/7/21 день), прозрачность по цене/рассрочке.
|
||||
- **Решившиеся** — бегунок → чек-лист дня операции → операция → восстановление. Окно 6–12 недель.
|
||||
|
||||
**Один модуль закрывает три сегмента бизнес-аналитики** (FESS, детские аденоиды, вазотомия).
|
||||
|
||||
**Группа D. АСИТ + контроль астмы** — часть сегмента 5 + сегмент 7. Ежедневный трекер, дневник симптомов, навигатор побочки. **Самый сложный, требует верификации врачом.**
|
||||
|
||||
### 4.4. Что остаётся за кадром
|
||||
|
||||
Сегменты 8 (храпуны), 9 (фониатрия), 10 (check-up) — низкая повторность и/или низкий охват. Закрываются базовым джентльменским набором, специализированных модулей в ближайшем горизонте не требуют.
|
||||
|
||||
---
|
||||
|
||||
## 5. Порядок внедрения
|
||||
|
||||
### Фаза 1. Транзакционная база (§3)
|
||||
|
||||
План лечения → Результаты / медкарта → Заказ справок.
|
||||
|
||||
**Эффект:** пациент видит свои назначения, результаты и может получить документы без звонка в клинику. Это и есть базовые retention-якоря: «не удалю это приложение». Массовый амбулаторный + хроники покрыты в части информирования и документов.
|
||||
|
||||
**Критический параметр сроков** — структурированность назначений в МИС (см. §6 вопрос 1). От этого зависит, собирается ли план лечения автоматически или врач заполняет его вручную в виджете.
|
||||
|
||||
### Фаза 1.5. Коммуникационная надстройка (§3)
|
||||
|
||||
Чат с медицинским консьержем → AI-помощник (shadow-mode).
|
||||
|
||||
**Зачем отдельная подфаза:** эти функции несут организационные и юридические риски (SLA, выгорание врачей, галлюцинации LLM) и могут надолго задержать релиз, если класть в Фазу 1. Выделение в 1.5 позволяет: собрать транзакционную базу быстро и безопасно, отработать регламент консьержа отдельно, запустить AI сначала под валидацией человека.
|
||||
|
||||
### Фаза 2. Сегментные модули — порядок
|
||||
|
||||
Рекомендуемая последовательность:
|
||||
|
||||
**1. Группа A (Процедуры самопомощи хроников) — первым**
|
||||
- **Самый массовый охват** — тысячи пациентов в месяц (сегмент 2 + 5).
|
||||
- Напрямую опирается на план лечения из Фазы 1 — продолжение той же инфраструктуры.
|
||||
- Средняя сложность: контент (видео-инструкции, 10–15 техник) + простой трекер + дневник симптомов.
|
||||
- Бизнес-эффект: рост комплаенса → меньше обострений → меньше экстренных приёмов и операций, на которые хроники срываются, когда дома не помогает.
|
||||
- Эффект заметен пациенту сразу — ежедневная польза.
|
||||
|
||||
**2. Группа C (Пред-операционная подготовка) — вторым**
|
||||
- Закрывает 3 сегмента (1, 3, 6) одним модулем.
|
||||
- Быстрый MVP — маршрутная карта с чекбоксами, сроки годности, автозапись. Восстановление уже есть в прототипе.
|
||||
- Прямой измеримый бизнес-эффект: снижение переносов операций из-за просроченных анализов + возврат сомневающихся кандидатов на операцию.
|
||||
- Вклад сегментов в выручку: ~50–60 млн (хирургия), плюс косвенно вазотомия как конвертер в большую хирургию.
|
||||
|
||||
**3. Группа B (Сурдология) — третьим**
|
||||
- Изолированный сегмент с уникальными функциями.
|
||||
- Пожизненное удержание × высокий чек × растущий рынок (старение).
|
||||
- Две половины: возврат кандидатов после демо (высокая конверсия в деньги) + обслуживание после покупки (лояльность и LTV).
|
||||
- Можно запускать параллельно с C после Фазы 1, если есть ресурс.
|
||||
|
||||
**4. Группа D (АСИТ + астма) — четвёртым**
|
||||
- Самая высокая глубина пользы (влияет на медисход), но самая высокая ответственность.
|
||||
- Требует верификации контента аллергологом/пульмонологом клиники.
|
||||
- Можно начать готовить контент и интеграции параллельно с A/C/B, выпустить позже.
|
||||
|
||||
### Что не берём в план сейчас
|
||||
|
||||
- Храпуны (8), фониатрия (9), check-up (10) — специализированных модулей в горизонте планирования не делаем.
|
||||
- Отдельные «первичные» модули под сегменты — не делаем. Первичный путь = джентльменский набор + базовые функции (запись, врачи, цены, контакты), которые уже есть.
|
||||
|
||||
### Что нужно сделать вне Фаз, уже сейчас
|
||||
|
||||
- **Добавить аллерголога-иммунолога в список врачей** (есть специализация, нет конкретного врача в данных). Предусловие для Группы D.
|
||||
|
||||
---
|
||||
|
||||
## 6. Вопросы к обсуждению на встрече
|
||||
|
||||
**Первоочередной (определяет сроки Фазы 1):**
|
||||
|
||||
1. **МИС и структурированные назначения.** Отдаёт ли МИС по API назначения структурированно (препарат, доза, частота, курс) или только PDF-заключением? От этого зависит: план лечения собирается автоматически или врач заполняет вручную в виджете. Там же: API для результатов анализов, сроков годности, расписания.
|
||||
|
||||
**По процессам клиники:**
|
||||
|
||||
2. **Медицинский консьерж — кто в роли?** Дежурный врач / фельдшер / медсестра? Кто держит SLA? Как компенсируется эскалация вопроса лечащему врачу?
|
||||
3. **SLA чата** — целевое время ответа в рабочее время (1ч / 4ч / день)?
|
||||
4. **Справки и финдокументы** — текущий процесс заказа через администратора; что готовы автоматизировать в первую очередь (ФНС-справка, выписки, счета)?
|
||||
|
||||
**По метрикам (для оценки эффекта):**
|
||||
|
||||
5. Подтвердить **«2 из 3 — повторные»** — уникальные пациенты в год или визиты? Меняет оценку охвата Группы A.
|
||||
6. **Процент повторных, не доходящих до контроля** — метрика успеха Фазы 1.
|
||||
7. **Сурдология** — кандидатов/мес, % возврата за аппаратом. От этого зависит приоритет Группы B.
|
||||
8. **Переносы операций** — частая причина просроченные анализы? Усиливает Группу C.
|
||||
|
||||
**По ресурсам Фазы 1.5 и 2:**
|
||||
|
||||
9. Готов ли **аллерголог** верифицировать контент для АСИТ-трекера? Без этого D не запускаем.
|
||||
10. База знаний / статьи — достаточно ли материала для RAG или формировать отдельно?
|
||||
11. Политика по **AI shadow-mode**: кто утверждает категории вопросов для постепенного перевода в прямой режим?
|
||||
|
||||
---
|
||||
|
||||
## Приложение. Короткая сводка для слайда
|
||||
|
||||
**Что предлагаем:**
|
||||
1. **Фаза 1** — транзакционная база для всех: план лечения, результаты/медкарта, заказ справок + уже существующие запись, ближайший приём, чат с оператором, статьи. Быстрый MVP, минимум рисков.
|
||||
2. **Фаза 1.5** — коммуникационная надстройка: чат с медицинским консьержем (не прямой с врачом) и AI-помощник в shadow-mode. Запускается после стабилизации базы.
|
||||
3. **Фаза 2** — четыре сегментных модуля в порядке: A (процедуры самопомощи хроников — массовый) → C (пред-оп подготовка — 3 сегмента разом) → B (сурдология — высокий чек) → D (АСИТ + астма — с верификацией врачом).
|
||||
4. **Первичные в приложении** = базовый минимум, который уже есть. Новых первичных модулей не строим — они приходят через веб. Исключения — сегменты, где после первого визита пациент «уходит думать»: сурдология, хирургия, АСИТ.
|
||||
Binary file not shown.
@@ -15,3 +15,29 @@ npm run dev
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Дизайн-система
|
||||
|
||||
Токены в `src/tokens.css`.
|
||||
|
||||
Основные цвета:
|
||||
|
||||
- `#2BB4A8` — primary (teal), бренд и основные CTA
|
||||
- `#E04E44` — accent (красный), выделенные действия
|
||||
- `#F5EDDF` — warm hero, тёплые приветственные блоки
|
||||
- `#2E9B6B` — success, `#E8A13C` — warning, `#D94141` — danger
|
||||
|
||||
Шрифты: **PT Sans** (основной), **PT Sans Narrow** (display/заголовки).
|
||||
|
||||
## Экраны
|
||||
|
||||
Описания всех экранов — в `src/docs.js`. Ключевые группы и цели пользователя:
|
||||
|
||||
- **Главная** (`home:*`) — варианты стартового экрана: Карточки, Лента, Таймлайн, Таймлайн X, Светлая плитка. Цель: быстрый старт — запись или ближайший приём.
|
||||
- **Запись** (`booking-*`) — 4-шаговый флоу: специализация → врач → дата/время → подтверждение → успех.
|
||||
- **Врачи** (`doctors`, `doctor`) — список и карточка врача для решения «записаться».
|
||||
- **Приёмы и медкарта** (`appts`, `appt`, `medcard`, `results`, `result*`) — контроль визитов, заключения, анализы, амбулаторная карта.
|
||||
- **Восстановление и тесты** (`recovery`, `audiotest`) — трекер после операции и скрининг слуха.
|
||||
- **Чаты** (`chat`, `chat:ai`, `chat:doctor-*`, `chat:operator`) — AI-помощник, врач, администратор.
|
||||
- **Сервис** (`profile`, `qr`, `telemed`, `notifications`, `search`, `contacts`, `prices`, `articles`, `article`) — профиль, идентификация, видеозвонок, уведомления, поиск, контент.
|
||||
- **DEV/Docs** (`dev-colors`, `dev-examples`, `docs`) — палитра, примеры компонентов и гайд по экранам.
|
||||
|
||||
+913
@@ -0,0 +1,913 @@
|
||||
"""Собирает MEETING_2026-04-23.pptx из содержания MEETING_2026-04-23.md.
|
||||
|
||||
Запуск: python3 build_deck.py
|
||||
Выход: MEETING_2026-04-23.pptx в корне проекта
|
||||
"""
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt, Emu
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||||
from pptx.oxml.ns import qn
|
||||
from copy import deepcopy
|
||||
|
||||
# ---------- Бренд клиники ----------
|
||||
PRIMARY = RGBColor(0x2B, 0xB4, 0xA8)
|
||||
PRIMARY_DARK = RGBColor(0x1C, 0x8A, 0x80)
|
||||
PRIMARY_50 = RGBColor(0xE8, 0xF7, 0xF5)
|
||||
ACCENT = RGBColor(0xE0, 0x4E, 0x44)
|
||||
WARM = RGBColor(0xF5, 0xED, 0xDF)
|
||||
SUCCESS = RGBColor(0x2E, 0x9B, 0x6B)
|
||||
WARNING = RGBColor(0xE8, 0xA1, 0x3C)
|
||||
DANGER = RGBColor(0xD9, 0x41, 0x41)
|
||||
FG = RGBColor(0x1F, 0x29, 0x37)
|
||||
FG2 = RGBColor(0x4B, 0x55, 0x63)
|
||||
FG3 = RGBColor(0x9C, 0xA3, 0xAF)
|
||||
BG = RGBColor(0xFF, 0xFF, 0xFF)
|
||||
SOFT = RGBColor(0xF3, 0xF4, 0xF6)
|
||||
|
||||
FONT_SANS = "PT Sans"
|
||||
FONT_DISPLAY = "PT Sans Narrow"
|
||||
|
||||
# ---------- Геометрия 16:9 ----------
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(13.333)
|
||||
prs.slide_height = Inches(7.5)
|
||||
SW = prs.slide_width
|
||||
SH = prs.slide_height
|
||||
|
||||
BLANK_LAYOUT = prs.slide_layouts[6] # Blank
|
||||
|
||||
MARGIN_L = Inches(0.6)
|
||||
MARGIN_R = Inches(0.6)
|
||||
MARGIN_T = Inches(0.45)
|
||||
CONTENT_W = SW - MARGIN_L - MARGIN_R
|
||||
|
||||
FOOTER_TXT = "Клиника УГН · Приложение · 23 апр 2026"
|
||||
|
||||
# ---------- Примитивы ----------
|
||||
def add_rect(slide, x, y, w, h, fill=None, line=None, line_w=None):
|
||||
shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
|
||||
shp.shadow.inherit = False
|
||||
if fill is None:
|
||||
shp.fill.background()
|
||||
else:
|
||||
shp.fill.solid()
|
||||
shp.fill.fore_color.rgb = fill
|
||||
if line is None:
|
||||
shp.line.fill.background()
|
||||
else:
|
||||
shp.line.color.rgb = line
|
||||
if line_w is not None:
|
||||
shp.line.width = line_w
|
||||
return shp
|
||||
|
||||
def add_text(slide, x, y, w, h, text, *, size=16, bold=False, color=FG,
|
||||
font=FONT_SANS, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP,
|
||||
line_spacing=None):
|
||||
tb = slide.shapes.add_textbox(x, y, w, h)
|
||||
tf = tb.text_frame
|
||||
tf.word_wrap = True
|
||||
tf.margin_left = tf.margin_right = Emu(0)
|
||||
tf.margin_top = tf.margin_bottom = Emu(0)
|
||||
tf.vertical_anchor = anchor
|
||||
lines = text.split("\n")
|
||||
for i, ln in enumerate(lines):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.alignment = align
|
||||
if line_spacing is not None:
|
||||
p.line_spacing = line_spacing
|
||||
r = p.add_run()
|
||||
r.text = ln
|
||||
r.font.name = font
|
||||
r.font.size = Pt(size)
|
||||
r.font.bold = bold
|
||||
r.font.color.rgb = color
|
||||
return tb
|
||||
|
||||
def add_rich(slide, x, y, w, h, runs, *, align=PP_ALIGN.LEFT,
|
||||
anchor=MSO_ANCHOR.TOP, size=14, line_spacing=1.15):
|
||||
"""runs = [(text, {'bold':bool,'color':RGB,'size':int,'font':str}), ...]"""
|
||||
tb = slide.shapes.add_textbox(x, y, w, h)
|
||||
tf = tb.text_frame
|
||||
tf.word_wrap = True
|
||||
tf.margin_left = tf.margin_right = Emu(0)
|
||||
tf.margin_top = tf.margin_bottom = Emu(0)
|
||||
tf.vertical_anchor = anchor
|
||||
paragraphs = [[]]
|
||||
for text, style in runs:
|
||||
if text == "\n":
|
||||
paragraphs.append([])
|
||||
else:
|
||||
paragraphs[-1].append((text, style))
|
||||
for i, para in enumerate(paragraphs):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.alignment = align
|
||||
p.line_spacing = line_spacing
|
||||
for text, style in para:
|
||||
r = p.add_run()
|
||||
r.text = text
|
||||
r.font.name = style.get("font", FONT_SANS)
|
||||
r.font.size = Pt(style.get("size", size))
|
||||
r.font.bold = style.get("bold", False)
|
||||
r.font.color.rgb = style.get("color", FG)
|
||||
return tb
|
||||
|
||||
def footer(slide, idx, total):
|
||||
add_rect(slide, 0, SH - Inches(0.32), SW, Inches(0.32), fill=PRIMARY_50)
|
||||
add_text(slide, MARGIN_L, SH - Inches(0.3), Inches(8), Inches(0.25),
|
||||
FOOTER_TXT, size=10, color=FG2)
|
||||
add_text(slide, SW - Inches(1.2), SH - Inches(0.3), Inches(0.6), Inches(0.25),
|
||||
f"{idx} / {total}", size=10, color=FG2, align=PP_ALIGN.RIGHT)
|
||||
|
||||
def title_bar(slide, title, eyebrow=None):
|
||||
"""Верхняя полоска: маленькая подпись категории + крупный заголовок."""
|
||||
y = MARGIN_T
|
||||
if eyebrow:
|
||||
add_text(slide, MARGIN_L, y, CONTENT_W, Inches(0.3),
|
||||
eyebrow.upper(), size=11, bold=True, color=PRIMARY,
|
||||
font=FONT_SANS)
|
||||
y += Inches(0.34)
|
||||
add_text(slide, MARGIN_L, y, CONTENT_W, Inches(0.6),
|
||||
title, size=28, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
y += Inches(0.7)
|
||||
# Тонкая линия
|
||||
add_rect(slide, MARGIN_L, y, Inches(1.2), Inches(0.04), fill=PRIMARY)
|
||||
return y + Inches(0.25)
|
||||
|
||||
def new_slide():
|
||||
return prs.slides.add_slide(BLANK_LAYOUT)
|
||||
|
||||
# ---------- Стилизованная таблица ----------
|
||||
def add_table(slide, x, y, w, h, data, *,
|
||||
header=True, col_widths=None, row_heights=None,
|
||||
header_fill=PRIMARY, header_fg=BG, body_fg=FG,
|
||||
alt_fill=PRIMARY_50, size=11, header_size=11):
|
||||
rows, cols = len(data), len(data[0])
|
||||
tbl_shp = slide.shapes.add_table(rows, cols, x, y, w, h).table
|
||||
if col_widths:
|
||||
for i, cw in enumerate(col_widths):
|
||||
tbl_shp.columns[i].width = cw
|
||||
if row_heights:
|
||||
for i, rh in enumerate(row_heights):
|
||||
tbl_shp.rows[i].height = rh
|
||||
for ri, row in enumerate(data):
|
||||
for ci, cell_val in enumerate(row):
|
||||
cell = tbl_shp.cell(ri, ci)
|
||||
cell.margin_left = cell.margin_right = Inches(0.08)
|
||||
cell.margin_top = cell.margin_bottom = Inches(0.05)
|
||||
# Fill
|
||||
if header and ri == 0:
|
||||
cell.fill.solid(); cell.fill.fore_color.rgb = header_fill
|
||||
elif alt_fill and ri % 2 == 0 and not (header and ri == 0):
|
||||
cell.fill.solid(); cell.fill.fore_color.rgb = alt_fill
|
||||
else:
|
||||
cell.fill.solid(); cell.fill.fore_color.rgb = BG
|
||||
# Text
|
||||
tf = cell.text_frame
|
||||
tf.word_wrap = True
|
||||
tf.clear()
|
||||
lines = str(cell_val).split("\n")
|
||||
for i, ln in enumerate(lines):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.alignment = PP_ALIGN.LEFT
|
||||
r = p.add_run()
|
||||
r.text = ln
|
||||
r.font.name = FONT_SANS
|
||||
if header and ri == 0:
|
||||
r.font.size = Pt(header_size)
|
||||
r.font.bold = True
|
||||
r.font.color.rgb = header_fg
|
||||
else:
|
||||
r.font.size = Pt(size)
|
||||
r.font.color.rgb = body_fg
|
||||
return tbl_shp
|
||||
|
||||
# ============================================================
|
||||
# СЛАЙДЫ
|
||||
# ============================================================
|
||||
|
||||
def slide_title():
|
||||
s = new_slide()
|
||||
# Фоновая тёплая полоса слева
|
||||
add_rect(s, 0, 0, Inches(4.5), SH, fill=WARM)
|
||||
# Teal-акцент
|
||||
add_rect(s, Inches(4.5), 0, Inches(0.12), SH, fill=PRIMARY)
|
||||
# Левый блок — мета
|
||||
add_text(s, Inches(0.7), Inches(0.7), Inches(3.5), Inches(0.3),
|
||||
"КЛИНИКА УГН", size=12, bold=True, color=PRIMARY_DARK)
|
||||
add_text(s, Inches(0.7), Inches(1.1), Inches(3.5), Inches(0.3),
|
||||
"Мобильное приложение", size=13, color=FG2)
|
||||
add_text(s, Inches(0.7), SH - Inches(1.2), Inches(3.5), Inches(0.3),
|
||||
"Встреча", size=12, color=FG3)
|
||||
add_text(s, Inches(0.7), SH - Inches(0.9), Inches(3.5), Inches(0.4),
|
||||
"23 апреля 2026", size=18, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
# Правый блок — заголовок
|
||||
add_text(s, Inches(5.1), Inches(2.2), Inches(7.6), Inches(1.0),
|
||||
"Приоритеты развития", size=48, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
add_text(s, Inches(5.1), Inches(3.2), Inches(7.6), Inches(0.8),
|
||||
"мобильного приложения", size=36, color=FG2, font=FONT_DISPLAY)
|
||||
# Подзаголовок
|
||||
add_rect(s, Inches(5.1), Inches(4.5), Inches(0.8), Inches(0.04), fill=PRIMARY)
|
||||
add_text(s, Inches(5.1), Inches(4.7), Inches(7.6), Inches(1.5),
|
||||
"Джентльменский набор для всех\nи четыре группы сегментов повторных",
|
||||
size=20, color=FG2, font=FONT_DISPLAY)
|
||||
return s
|
||||
|
||||
def slide_agenda():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Повестка", "Что обсуждаем")
|
||||
items = [
|
||||
("1", "Контекст и критерии приоритизации",
|
||||
"Факты о потоке пациентов, 10 бизнес-сегментов, рамка оценки"),
|
||||
("2", "Текущие функции — что есть",
|
||||
"Приоритеты использования и точки усиления"),
|
||||
("3", "Джентльменский набор для всех пациентов",
|
||||
"Транзакционная база (Фаза 1) + коммуникационная надстройка (Фаза 1.5)"),
|
||||
("4", "10 сегментов × первичный/повторный в приложении",
|
||||
"Матрица и 4 группы сегментных модулей"),
|
||||
("5", "Порядок внедрения и решение",
|
||||
"Фаза 1 → Фаза 1.5 → Фаза 2 (A → C → B → D). Что выбираем сегодня"),
|
||||
]
|
||||
row_h = Inches(0.95)
|
||||
for i, (num, title, sub) in enumerate(items):
|
||||
yy = y + row_h * i
|
||||
# кружок-номер
|
||||
circle = s.shapes.add_shape(MSO_SHAPE.OVAL, MARGIN_L, yy,
|
||||
Inches(0.6), Inches(0.6))
|
||||
circle.shadow.inherit = False
|
||||
circle.fill.solid(); circle.fill.fore_color.rgb = PRIMARY
|
||||
circle.line.fill.background()
|
||||
tf = circle.text_frame
|
||||
tf.margin_left = tf.margin_right = Emu(0)
|
||||
tf.margin_top = tf.margin_bottom = Emu(0)
|
||||
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
|
||||
r = p.add_run(); r.text = num
|
||||
r.font.name = FONT_DISPLAY; r.font.size = Pt(20); r.font.bold = True
|
||||
r.font.color.rgb = BG
|
||||
add_text(s, MARGIN_L + Inches(0.9), yy + Inches(0.02),
|
||||
Inches(11), Inches(0.4), title, size=18, bold=True,
|
||||
color=FG, font=FONT_DISPLAY)
|
||||
add_text(s, MARGIN_L + Inches(0.9), yy + Inches(0.45),
|
||||
Inches(11), Inches(0.4), sub, size=13, color=FG2)
|
||||
return s
|
||||
|
||||
def slide_facts():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Ключевые факты о потоке", "Контекст")
|
||||
# Большая статистика слева
|
||||
add_text(s, MARGIN_L, y + Inches(0.2), Inches(5), Inches(1.6),
|
||||
"2 из 3", size=88, bold=True, color=PRIMARY, font=FONT_DISPLAY)
|
||||
add_text(s, MARGIN_L, y + Inches(1.8), Inches(5.5), Inches(0.7),
|
||||
"пациентов клиники — повторные",
|
||||
size=18, color=FG, font=FONT_DISPLAY)
|
||||
add_text(s, MARGIN_L, y + Inches(2.4), Inches(5.5), Inches(1.4),
|
||||
"Значительная доля — повторные внутри одного эпизода лечения: "
|
||||
"после первого визита ещё 1–3+ приёма, процедуры, контроль.",
|
||||
size=13, color=FG2)
|
||||
# Правый блок — выручка
|
||||
bx = Inches(7.0); bw = Inches(5.7)
|
||||
add_rect(s, bx, y + Inches(0.1), bw, Inches(4.8),
|
||||
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(0.5))
|
||||
add_text(s, bx + Inches(0.3), y + Inches(0.3), bw - Inches(0.6), Inches(0.4),
|
||||
"ВЫРУЧКА ~200 МЛН / ГОД · 10 СЕГМЕНТОВ", size=11, bold=True,
|
||||
color=PRIMARY_DARK)
|
||||
lead_rows = [
|
||||
("Амбулаторный поток", "тысячи/мес", "~120 млн"),
|
||||
("Взрослая хирургия (FESS, полипы, перегородка)", "300 оп/год", "~30 млн"),
|
||||
("Детская аденоидная хирургия", "400–500 оп/год", "~20–30 млн"),
|
||||
("Сурдология (слуховые аппараты)", "высокая конверсия", "~20 млн"),
|
||||
("Хроники, ЧБД, вазотомия, пульмо, храпуны, фониатрия, check-up", "6 сегментов", "остальное"),
|
||||
]
|
||||
ty = y + Inches(0.8)
|
||||
for name, vol, rev in lead_rows:
|
||||
add_text(s, bx + Inches(0.3), ty, Inches(3.5), Inches(0.3),
|
||||
name, size=12, bold=True, color=FG)
|
||||
add_text(s, bx + Inches(0.3), ty + Inches(0.28), Inches(3.5), Inches(0.24),
|
||||
vol, size=10, color=FG3)
|
||||
add_text(s, bx + bw - Inches(1.8), ty, Inches(1.5), Inches(0.4),
|
||||
rev, size=14, bold=True, color=ACCENT, align=PP_ALIGN.RIGHT,
|
||||
font=FONT_DISPLAY)
|
||||
ty += Inches(0.76)
|
||||
return s
|
||||
|
||||
def slide_frame():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Приложение = удержание, не привлечение", "Продуктовая рамка")
|
||||
# Две колонки
|
||||
colw = Inches(6.15)
|
||||
gap = Inches(0.23)
|
||||
# Левая
|
||||
lx = MARGIN_L
|
||||
add_rect(s, lx, y + Inches(0.1), colw, Inches(4.7),
|
||||
fill=SOFT, line=None)
|
||||
add_text(s, lx + Inches(0.3), y + Inches(0.25), colw - Inches(0.6), Inches(0.4),
|
||||
"ПЕРВИЧНЫЕ", size=12, bold=True, color=FG3)
|
||||
add_text(s, lx + Inches(0.3), y + Inches(0.6), colw - Inches(0.6), Inches(0.6),
|
||||
"Приходят через веб", size=22, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
add_rich(s, lx + Inches(0.3), y + Inches(1.3), colw - Inches(0.6), Inches(3.4), [
|
||||
("Главный job: ", {"bold": True, "color": FG}),
|
||||
("«куда мне обратиться, как записаться»", {}),
|
||||
("\n", {}),
|
||||
("Источник: ", {"bold": True, "color": FG}),
|
||||
("сайт, SEO, реклама, сарафан", {}),
|
||||
("\n", {}),
|
||||
("В приложении: ", {"bold": True, "color": FG}),
|
||||
("базовый минимум, который уже есть", {}),
|
||||
("\n", {}),
|
||||
("Приоритет развития: ", {"bold": True, "color": FG}),
|
||||
("низкий — веб работает лучше", {}),
|
||||
], size=13, line_spacing=1.3)
|
||||
# Правая (основная)
|
||||
rx = lx + colw + gap
|
||||
add_rect(s, rx, y + Inches(0.1), colw, Inches(4.7),
|
||||
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(1))
|
||||
add_text(s, rx + Inches(0.3), y + Inches(0.25), colw - Inches(0.6), Inches(0.4),
|
||||
"ПОВТОРНЫЕ · 2 ИЗ 3", size=12, bold=True, color=PRIMARY_DARK)
|
||||
add_text(s, rx + Inches(0.3), y + Inches(0.6), colw - Inches(0.6), Inches(0.6),
|
||||
"Приоритет приложения", size=22, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
add_rich(s, rx + Inches(0.3), y + Inches(1.3), colw - Inches(0.6), Inches(3.4), [
|
||||
("Главный job: ", {"bold": True, "color": FG}),
|
||||
("«что делать сейчас по моему лечению»", {}),
|
||||
("\n", {}),
|
||||
("Источник: ", {"bold": True, "color": FG}),
|
||||
("приложение уже установлено, push / сам открывает", {}),
|
||||
("\n", {}),
|
||||
("В приложении: ", {"bold": True, "color": FG}),
|
||||
("специализированный модуль сегмента", {}),
|
||||
("\n", {}),
|
||||
("Приоритет развития: ", {"bold": True, "color": FG}),
|
||||
("высокий — закрывает 2/3 потока", {}),
|
||||
], size=13, line_spacing=1.3)
|
||||
# Сноска-исключения
|
||||
add_text(s, MARGIN_L, y + Inches(5.1), CONTENT_W, Inches(0.4),
|
||||
"Исключения: сурдология, хирургия, АСИТ — после первого визита пациент «уходит думать», и вернуть его может только приложение.",
|
||||
size=11, color=FG2)
|
||||
return s
|
||||
|
||||
def slide_current_functions():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Текущие функции — утилита и приоритет", "Что есть сегодня")
|
||||
# Три колонки: зелёная, жёлтая, красная
|
||||
groups = [
|
||||
("ЯДРО ЦЕННОСТИ", SUCCESS, [
|
||||
("Запись на приём", "Единственная функция, приводящая новых."),
|
||||
("Ближайший приём", "3–10 открытий перед визитом. Самый частый экран."),
|
||||
]),
|
||||
("ПОДДЕРЖКА", WARNING, [
|
||||
("Список врачей", "Помогает при первом визите, повторный не открывает."),
|
||||
("Чат с оператором", "Ограничен одним собеседником — основное развитие в §3."),
|
||||
("Профиль", "Редко открывается, но критичен для персонализации."),
|
||||
]),
|
||||
("НИША", DANGER, [
|
||||
("Семейный профиль", "Критичен для 20–30% (дети, пожилые)."),
|
||||
("Контакты", "1–2 открытия за всё время. Функция «галочка»."),
|
||||
]),
|
||||
]
|
||||
cw = (CONTENT_W - Inches(0.5)) / 3
|
||||
for i, (label, col, items) in enumerate(groups):
|
||||
cx = MARGIN_L + (cw + Inches(0.25)) * i
|
||||
add_rect(s, cx, y, cw, Inches(0.5), fill=col)
|
||||
add_text(s, cx + Inches(0.3), y + Inches(0.1), cw - Inches(0.6), Inches(0.3),
|
||||
label, size=12, bold=True, color=BG)
|
||||
yy = y + Inches(0.7)
|
||||
for title, desc in items:
|
||||
add_text(s, cx, yy, cw, Inches(0.35), title,
|
||||
size=15, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
add_text(s, cx, yy + Inches(0.38), cw, Inches(0.9), desc,
|
||||
size=11, color=FG2)
|
||||
yy += Inches(1.15)
|
||||
# Подвал — вывод
|
||||
add_rect(s, MARGIN_L, Inches(6.55), CONTENT_W, Inches(0.55),
|
||||
fill=WARM)
|
||||
add_text(s, MARGIN_L + Inches(0.3), Inches(6.66), CONTENT_W - Inches(0.6), Inches(0.4),
|
||||
"Слабое место: нет причины открывать приложение между визитами. Это и закрывает джентльменский набор.",
|
||||
size=13, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
return s
|
||||
|
||||
def slide_three_layers():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Три слоя работы", "План")
|
||||
box_h = Inches(1.75)
|
||||
gap = Inches(0.15)
|
||||
# Слой 1
|
||||
y1 = y + Inches(0.1)
|
||||
add_rect(s, MARGIN_L, y1, CONTENT_W, box_h,
|
||||
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(1))
|
||||
add_text(s, MARGIN_L + Inches(0.3), y1 + Inches(0.2), Inches(3), Inches(0.3),
|
||||
"ФАЗА 1 · ТРАНЗАКЦИОННАЯ БАЗА", size=11, bold=True, color=PRIMARY_DARK)
|
||||
add_text(s, MARGIN_L + Inches(0.3), y1 + Inches(0.5), Inches(11), Inches(0.5),
|
||||
"Всем пациентам · быстрый MVP · минимум рисков",
|
||||
size=18, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
add_text(s, MARGIN_L + Inches(0.3), y1 + Inches(1.0), CONTENT_W - Inches(0.6), Inches(0.7),
|
||||
"План лечения · Результаты и медкарта · Заказ справок · "
|
||||
"(+ уже есть: запись, ближайший приём, чат с оператором, статьи)",
|
||||
size=13, color=FG2)
|
||||
# Слой 1.5
|
||||
y15 = y1 + box_h + gap
|
||||
add_rect(s, MARGIN_L, y15, CONTENT_W, box_h,
|
||||
fill=WARM, line=WARNING, line_w=Pt(1))
|
||||
add_text(s, MARGIN_L + Inches(0.3), y15 + Inches(0.2), Inches(4), Inches(0.3),
|
||||
"ФАЗА 1.5 · КОММУНИКАЦИОННАЯ НАДСТРОЙКА", size=11, bold=True, color=WARNING)
|
||||
add_text(s, MARGIN_L + Inches(0.3), y15 + Inches(0.5), Inches(11), Inches(0.5),
|
||||
"Поверх стабильной базы · отдельный регламент и безопасность",
|
||||
size=18, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
add_text(s, MARGIN_L + Inches(0.3), y15 + Inches(1.0), CONTENT_W - Inches(0.6), Inches(0.7),
|
||||
"Чат с медицинским консьержем (не прямой с лечащим) · AI-помощник на RAG в shadow-mode",
|
||||
size=13, color=FG2)
|
||||
# Слой 2
|
||||
y2 = y15 + box_h + gap
|
||||
add_rect(s, MARGIN_L, y2, CONTENT_W, box_h,
|
||||
fill=SOFT, line=FG3, line_w=Pt(0.5))
|
||||
add_text(s, MARGIN_L + Inches(0.3), y2 + Inches(0.2), Inches(3), Inches(0.3),
|
||||
"ФАЗА 2 · СЕГМЕНТНЫЕ МОДУЛИ", size=11, bold=True, color=FG3)
|
||||
add_text(s, MARGIN_L + Inches(0.3), y2 + Inches(0.5), Inches(11), Inches(0.5),
|
||||
"Углубление по группам сегментов",
|
||||
size=18, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
# 4 мини-плитки
|
||||
tiles = [("A", "Самопомощь"), ("C", "Пред-оп"), ("B", "Сурдология"), ("D", "АСИТ + астма")]
|
||||
tw = (CONTENT_W - Inches(0.6) - Inches(0.15) * 3) / 4
|
||||
for i, (code, name) in enumerate(tiles):
|
||||
tx = MARGIN_L + Inches(0.3) + (tw + Inches(0.15)) * i
|
||||
ty = y2 + Inches(1.05)
|
||||
add_rect(s, tx, ty, tw, Inches(0.55), fill=BG, line=PRIMARY, line_w=Pt(0.75))
|
||||
add_text(s, tx + Inches(0.15), ty + Inches(0.08), Inches(0.4), Inches(0.4),
|
||||
code, size=18, bold=True, color=PRIMARY, font=FONT_DISPLAY)
|
||||
add_text(s, tx + Inches(0.55), ty + Inches(0.13), tw - Inches(0.6), Inches(0.4),
|
||||
name, size=11, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
return s
|
||||
|
||||
def slide_phase1_base():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Фаза 1 · Транзакционная база", "Джентльменский набор, часть 1")
|
||||
add_text(s, MARGIN_L, y + Inches(0.0), CONTENT_W, Inches(0.4),
|
||||
"Детерминированные функции без LLM и прямого врачебного диалога. Быстрый MVP, минимум рисков.",
|
||||
size=13, color=FG2)
|
||||
data = [
|
||||
["Функция", "Статус", "Суть"],
|
||||
["Запись на приём", "Есть", "Запись 24/7, ближайшие окна."],
|
||||
["Ближайший приём", "Есть", "Что, где, когда — снимает тревогу перед визитом."],
|
||||
["Чат с оператором", "Есть", "Справки, переносы, счета, расписание (человеческий канал)."],
|
||||
["Статьи / база знаний", "Есть", "Источник правды для будущего RAG."],
|
||||
["План лечения с приёма", "Нет — фундамент", "Структурированные назначения, календарь, напоминания, ссылки на процедуры."],
|
||||
["Результаты и медкарта", "Нет — новое", "Анализы, аудиограммы, снимки, заключения. Срок годности виден — критично для пред-опа."],
|
||||
["Заказ справок и финдокументов", "Нет — новое", "ФНС-вычет 13%, справки работодателю, счета. Снимает нагрузку с администраторов."],
|
||||
]
|
||||
add_table(s, MARGIN_L, y + Inches(0.55),
|
||||
CONTENT_W, Inches(4.6),
|
||||
data,
|
||||
col_widths=[Inches(3.3), Inches(2.3), CONTENT_W - Inches(5.6)],
|
||||
size=12, header_size=12)
|
||||
# Порядок работ
|
||||
add_rect(s, MARGIN_L, Inches(6.25), CONTENT_W, Inches(0.85),
|
||||
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(0.5))
|
||||
add_rich(s, MARGIN_L + Inches(0.3), Inches(6.35), CONTENT_W - Inches(0.6), Inches(0.7), [
|
||||
("Порядок внутри Фазы 1: ", {"bold": True, "color": PRIMARY_DARK}),
|
||||
("1) План лечения — фундамент · ", {}),
|
||||
("2) Результаты / медкарта · ", {}),
|
||||
("3) Заказ справок. ", {}),
|
||||
("Критическая зависимость — МИС: ", {"bold": True, "color": FG}),
|
||||
("отдаёт ли API структурированные назначения или план заполняет врач в виджете (см. вопрос №1).", {}),
|
||||
], size=12, line_spacing=1.3)
|
||||
return s
|
||||
|
||||
def slide_phase1_5_overlay():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Фаза 1.5 · Коммуникационная надстройка", "Джентльменский набор, часть 2")
|
||||
add_text(s, MARGIN_L, y + Inches(0.0), CONTENT_W, Inches(0.6),
|
||||
"Живой диалог по поводу лечения. Строится поверх стабильной Фазы 1, требует отдельных регламентов.",
|
||||
size=13, color=FG2)
|
||||
# Две большие карточки
|
||||
cw = (CONTENT_W - Inches(0.3)) / 2
|
||||
cy = y + Inches(0.7)
|
||||
ch = Inches(4.5)
|
||||
# Консьерж
|
||||
lx = MARGIN_L
|
||||
add_rect(s, lx, cy, cw, ch, fill=BG, line=PRIMARY, line_w=Pt(1))
|
||||
add_rect(s, lx, cy, cw, Inches(0.55), fill=PRIMARY)
|
||||
add_text(s, lx + Inches(0.3), cy + Inches(0.12), cw - Inches(0.6), Inches(0.4),
|
||||
"ЧАТ С МЕДИЦИНСКИМ КОНСЬЕРЖЕМ", size=12, bold=True, color=BG)
|
||||
add_text(s, lx + Inches(0.3), cy + Inches(0.75), cw - Inches(0.6), Inches(0.5),
|
||||
"Не прямой с лечащим врачом",
|
||||
size=18, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
add_text(s, lx + Inches(0.3), cy + Inches(1.3), cw - Inches(0.6), Inches(2.4),
|
||||
"Дежурный врач / фельдшер / медсестра отвечает по протоколам "
|
||||
"на 80% рутины: «можно ли совмещать с X», «нормально ли, что третий день болит», "
|
||||
"«когда повторно сдать анализ».\n\n"
|
||||
"Эскалация лечащему — только клинически значимое.\n\n"
|
||||
"Буфер SLA и защита от выгорания врачей.",
|
||||
size=12, color=FG2)
|
||||
add_rect(s, lx + Inches(0.3), cy + ch - Inches(0.85), cw - Inches(0.6), Inches(0.7),
|
||||
fill=WARM)
|
||||
add_rich(s, lx + Inches(0.45), cy + ch - Inches(0.75), cw - Inches(0.9), Inches(0.55), [
|
||||
("Риск: ", {"bold": True, "color": DANGER}),
|
||||
("перегрузка врачей. ", {}),
|
||||
("Митигация: ", {"bold": True, "color": SUCCESS}),
|
||||
("консьерж-слой + тарификация эскалаций.", {}),
|
||||
], size=11, line_spacing=1.3)
|
||||
# AI
|
||||
rx = lx + cw + Inches(0.3)
|
||||
add_rect(s, rx, cy, cw, ch, fill=BG, line=WARNING, line_w=Pt(1))
|
||||
add_rect(s, rx, cy, cw, Inches(0.55), fill=WARNING)
|
||||
add_text(s, rx + Inches(0.3), cy + Inches(0.12), cw - Inches(0.6), Inches(0.4),
|
||||
"AI-ПОМОЩНИК (RAG, 24/7)", size=12, bold=True, color=BG)
|
||||
add_text(s, rx + Inches(0.3), cy + Inches(0.75), cw - Inches(0.6), Inches(0.5),
|
||||
"Запуск в shadow-mode",
|
||||
size=18, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
add_text(s, rx + Inches(0.3), cy + Inches(1.3), cw - Inches(0.6), Inches(2.4),
|
||||
"Знает: базу знаний клиники, статьи, план лечения пациента, медкарту, историю приёмов.\n\n"
|
||||
"Объясняет назначения, ищет ответ в статьях, оценивает «норма или срочно», "
|
||||
"эскалирует консьержу/врачу.\n\n"
|
||||
"Отвечает ночью, когда человеческие каналы недоступны.",
|
||||
size=12, color=FG2)
|
||||
add_rect(s, rx + Inches(0.3), cy + ch - Inches(0.85), cw - Inches(0.6), Inches(0.7),
|
||||
fill=WARM)
|
||||
add_rich(s, rx + Inches(0.45), cy + ch - Inches(0.75), cw - Inches(0.9), Inches(0.55), [
|
||||
("Риск: ", {"bold": True, "color": DANGER}),
|
||||
("галлюцинация LLM = юр./репутация. ", {}),
|
||||
("Митигация: ", {"bold": True, "color": SUCCESS}),
|
||||
("shadow-mode, валидация через консьержа, метрики точности перед прямым режимом.", {}),
|
||||
], size=11, line_spacing=1.3)
|
||||
return s
|
||||
|
||||
def slide_primary_vs_returning():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Первичный vs повторный в приложении", "Фундаментальное деление")
|
||||
data = [
|
||||
["Линза", "Первичный в приложении", "Повторный в приложении"],
|
||||
["Главный job",
|
||||
"«Куда обратиться и как записаться»",
|
||||
"«Что делать сейчас по моему лечению»"],
|
||||
["Источник",
|
||||
"Сайт, SEO, реклама, сарафан",
|
||||
"Уведомление, пациент сам открывает"],
|
||||
["Частота открытия",
|
||||
"1–3 раза до визита",
|
||||
"Ежедневно в активном эпизоде"],
|
||||
["Что нужно в приложении",
|
||||
"Базовый минимум (есть)",
|
||||
"Специализированный модуль сегмента"],
|
||||
["Приоритет развития",
|
||||
"Низкий — веб работает лучше",
|
||||
"Высокий — 2/3 потока"],
|
||||
]
|
||||
add_table(s, MARGIN_L, y + Inches(0.2),
|
||||
CONTENT_W, Inches(4.4),
|
||||
data,
|
||||
col_widths=[Inches(3.0), Inches(4.55), Inches(4.55)],
|
||||
size=13, header_size=12,
|
||||
header_fill=FG)
|
||||
add_rect(s, MARGIN_L, Inches(6.1), CONTENT_W, Inches(1.0),
|
||||
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(0.5))
|
||||
add_rich(s, MARGIN_L + Inches(0.3), Inches(6.2), CONTENT_W - Inches(0.6), Inches(0.85), [
|
||||
("Исключения: ", {"bold": True, "color": PRIMARY_DARK}),
|
||||
("сегменты, где «первичный в приложении» ≠ «первый визит» — сурдология, хирургия, АСИТ. "
|
||||
"После первого визита пациент уходит, и вернуть его может только приложение. "
|
||||
"Эти сегменты требуют отдельных «первичных» сценариев удержания.", {}),
|
||||
], size=13, line_spacing=1.3)
|
||||
return s
|
||||
|
||||
def slide_segment_matrix():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "10 сегментов × первичный / повторный", "Матрица")
|
||||
header = ["#", "Сегмент", "Объём · вклад", "Повторный — что нужно", "Группа"]
|
||||
rows = [
|
||||
["1", "Заблокированный нос (FESS, полипы)", "300 оп · 30 млн",
|
||||
"Пред-оп бегунок → восстановление → контроль 3/6/12 мес", "C"],
|
||||
["2", "Амбулаторный поток (острые + хроники)", "тысячи/мес · 120 млн",
|
||||
"План лечения + эпизод + процедуры самопомощи", "A"],
|
||||
["3", "Дети с аденоидами", "400–500 оп · 20–30 млн",
|
||||
"Детский бегунок → восстановление (семейный профиль)", "C"],
|
||||
["4", "Потеря слуха (сурдология)", "20 млн",
|
||||
"Паспорт аппарата, сервисный календарь, расходники, калибровка", "B"],
|
||||
["5", "Сложные хроники, ЧБД, аллергия", "часть 120 млн · LTV",
|
||||
"Процедуры самопомощи + АСИТ-трекер + пыльцевой календарь", "A+D"],
|
||||
["6", "Зависимость от капель (вазотомия)", "высокий объём",
|
||||
"Пред-оп бегунок (компактный) → восстановление", "C"],
|
||||
["7", "Пульмонология (кашель/астма)", "средний · сезонный",
|
||||
"Дневник астмы, напоминания об ингаляторах", "D"],
|
||||
["8", "Социально активные храпуны", "низкий · высокий чек",
|
||||
"Сомнологический чек-up, СИПАП-трекер", "—"],
|
||||
["9", "Фониатрия (голос)", "очень низкий",
|
||||
"Короткое окно, низкая повторность", "—"],
|
||||
["10", "Check-up и второе мнение", "единичный",
|
||||
"Разовая услуга, минимальная повторность", "—"],
|
||||
]
|
||||
data = [header] + rows
|
||||
# раскраска групп через цвет текста в последней колонке делается потом
|
||||
tbl = add_table(s, MARGIN_L, y + Inches(0.1),
|
||||
CONTENT_W, Inches(5.6),
|
||||
data,
|
||||
col_widths=[Inches(0.4), Inches(3.4), Inches(2.2), Inches(5.5), Inches(0.8)],
|
||||
size=10, header_size=11)
|
||||
# Подкрасить колонку «Группа»
|
||||
group_colors = {"A": PRIMARY_DARK, "B": PRIMARY_DARK, "C": PRIMARY_DARK,
|
||||
"D": PRIMARY_DARK, "A+D": PRIMARY_DARK, "—": FG3}
|
||||
for ri, row in enumerate(rows, start=1):
|
||||
cell = tbl.cell(ri, 4)
|
||||
tf = cell.text_frame
|
||||
for p in tf.paragraphs:
|
||||
for r in p.runs:
|
||||
r.font.bold = True
|
||||
r.font.color.rgb = group_colors.get(row[4], FG)
|
||||
r.font.size = Pt(12)
|
||||
return s
|
||||
|
||||
def slide_groups_overview():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Четыре группы сегментов для повторных", "Укрупнение")
|
||||
# 4 плитки 2×2
|
||||
tiles = [
|
||||
("A", "Процедуры самопомощи",
|
||||
"Амбулаторный + хроники (2, 5ч)",
|
||||
"Библиотека техник, напоминания, трекер, дневник.\nСамый массовый охват."),
|
||||
("C", "Пред-оп подготовка",
|
||||
"FESS + аденоиды + вазотомия (1, 3, 6)",
|
||||
"Две фазы: возврат сомневающихся; бегунок → восстановление.\nОдин модуль — три сегмента."),
|
||||
("B", "Сурдология",
|
||||
"Потеря слуха (4)",
|
||||
"Две фазы: возврат после демо-аппарата; обслуживание аппарата.\nИзолированный модуль, высокий чек."),
|
||||
("D", "АСИТ + астма",
|
||||
"Часть аллергии + пульмо (5ч, 7)",
|
||||
"Ежедневный трекер, пыльцевой календарь, навигатор побочки.\nВысокая ответственность."),
|
||||
]
|
||||
tw = (CONTENT_W - Inches(0.3)) / 2
|
||||
th = (Inches(5.0) - Inches(0.3)) / 2
|
||||
for i, (code, name, segments, desc) in enumerate(tiles):
|
||||
col = i % 2
|
||||
row = i // 2
|
||||
tx = MARGIN_L + (tw + Inches(0.3)) * col
|
||||
ty = y + Inches(0.1) + (th + Inches(0.3)) * row
|
||||
add_rect(s, tx, ty, tw, th, fill=BG, line=PRIMARY, line_w=Pt(1))
|
||||
# Круг с буквой
|
||||
circ = s.shapes.add_shape(MSO_SHAPE.OVAL, tx + Inches(0.3), ty + Inches(0.3),
|
||||
Inches(0.8), Inches(0.8))
|
||||
circ.shadow.inherit = False
|
||||
circ.fill.solid(); circ.fill.fore_color.rgb = PRIMARY
|
||||
circ.line.fill.background()
|
||||
tf = circ.text_frame
|
||||
tf.margin_left = tf.margin_right = Emu(0); tf.margin_top = tf.margin_bottom = Emu(0)
|
||||
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
|
||||
r = p.add_run(); r.text = code
|
||||
r.font.name = FONT_DISPLAY; r.font.size = Pt(28); r.font.bold = True
|
||||
r.font.color.rgb = BG
|
||||
# Название
|
||||
add_text(s, tx + Inches(1.3), ty + Inches(0.3), tw - Inches(1.5), Inches(0.5),
|
||||
name, size=20, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
add_text(s, tx + Inches(1.3), ty + Inches(0.8), tw - Inches(1.5), Inches(0.3),
|
||||
segments, size=11, color=FG3)
|
||||
# Описание
|
||||
add_text(s, tx + Inches(0.3), ty + Inches(1.5), tw - Inches(0.6),
|
||||
th - Inches(1.7), desc, size=12, color=FG2)
|
||||
return s
|
||||
|
||||
def slide_group_detail(code, color, title, caption, what_it_does, why_priority):
|
||||
s = new_slide()
|
||||
y = title_bar(s, title, f"Группа {code}")
|
||||
# Крупный код слева
|
||||
add_rect(s, MARGIN_L, y, Inches(1.4), Inches(5.0), fill=PRIMARY_50)
|
||||
add_text(s, MARGIN_L, y + Inches(1.5), Inches(1.4), Inches(2.0),
|
||||
code, size=120, bold=True, color=PRIMARY, font=FONT_DISPLAY,
|
||||
align=PP_ALIGN.CENTER)
|
||||
# Правый контент
|
||||
rx = MARGIN_L + Inches(1.6)
|
||||
rw = CONTENT_W - Inches(1.6)
|
||||
add_text(s, rx, y + Inches(0.0), rw, Inches(0.4),
|
||||
caption, size=13, color=FG3)
|
||||
# Что делает
|
||||
add_text(s, rx, y + Inches(0.45), rw, Inches(0.35),
|
||||
"ЧТО В МОДУЛЕ", size=11, bold=True, color=PRIMARY_DARK)
|
||||
add_text(s, rx, y + Inches(0.8), rw, Inches(2.8),
|
||||
what_it_does, size=13, color=FG)
|
||||
# Почему приоритет
|
||||
add_rect(s, rx, y + Inches(3.7), rw, Inches(1.3),
|
||||
fill=WARM)
|
||||
add_text(s, rx + Inches(0.3), y + Inches(3.8), Inches(5), Inches(0.3),
|
||||
"ПОЧЕМУ В ЭТОМ ПОРЯДКЕ", size=11, bold=True, color=FG2)
|
||||
add_text(s, rx + Inches(0.3), y + Inches(4.1), rw - Inches(0.6), Inches(1.1),
|
||||
why_priority, size=13, color=FG)
|
||||
return s
|
||||
|
||||
def slide_group_A():
|
||||
return slide_group_detail(
|
||||
"A", PRIMARY,
|
||||
"Процедуры самопомощи для хроников",
|
||||
"Амбулаторный поток + сложные хроники · сегменты 2 и 5",
|
||||
"• Библиотека техник (видео + шаги): промывание носа физраствором, полоскание горла, "
|
||||
"ингаляции, гимнастика слуховой трубы, голосовые упражнения, туалет уха.\n"
|
||||
"• Напоминания утром/вечером, привязка к плану лечения.\n"
|
||||
"• Трекер комплаенса: ежедневные отметки, стрики, сводка.\n"
|
||||
"• Дневник симптомов в связке: «промываю 5 дней, насморк 4 → 2».\n"
|
||||
"• Сводка для врача перед контрольным приёмом.",
|
||||
"Самый массовый охват (тысячи/мес) × ежедневная повторность. "
|
||||
"Напрямую опирается на план лечения из Фазы 1. "
|
||||
"Контент-ориентированный MVP (видео + простой трекер). "
|
||||
"Эффект заметен пациенту сразу."
|
||||
)
|
||||
|
||||
def slide_group_C():
|
||||
return slide_group_detail(
|
||||
"C", PRIMARY,
|
||||
"Пред-операционная подготовка + восстановление",
|
||||
"FESS + дети-аденоиды + вазотомия · сегменты 1, 3, 6",
|
||||
"Две фазы:\n"
|
||||
"1) Кандидаты на операцию — ушли «думать» после пред-оп приёма. Возврат через материалы, "
|
||||
"кейсы пациентов, чат с хирургом, возвратные push (3/7/21 день), прозрачность цены/рассрочки.\n"
|
||||
"2) Решившиеся — маршрутная карта («бегунок») с чекбоксами, сроки годности анализов, "
|
||||
"автозапись из пункта, загрузка сторонних результатов, чек-лист дня операции, "
|
||||
"договор и оплата в приложении → передача в «Восстановление».",
|
||||
"Один модуль закрывает 3 сегмента. Быстрый MVP (маршрутная карта — простой список с состояниями). "
|
||||
"Прямой бизнес-эффект: меньше переносов операций + возврат сомневающихся кандидатов. "
|
||||
"Вклад сегментов: ~50–60 млн."
|
||||
)
|
||||
|
||||
def slide_group_B():
|
||||
return slide_group_detail(
|
||||
"B", PRIMARY,
|
||||
"Сурдология — слухопротезирование",
|
||||
"Потеря слуха · сегмент 4",
|
||||
"Две фазы, зеркально Группе C:\n"
|
||||
"1) Кандидаты — после первого визита и демо уходят думать 2–3 месяца. "
|
||||
"Аудиограмма в профиле, аудио-демо «до/после» в любых наушниках, каталог моделей с фильтром "
|
||||
"по аудиограмме, калькулятор стоимости (ФСС, рассрочка, трейд-ин), шеринг близкому, "
|
||||
"истории пациентов, возвратные push 3/7/21 день.\n"
|
||||
"2) Пользователи аппарата — паспорт аппарата, календарь обслуживания, расходники, "
|
||||
"навигатор неполадок, ежегодная калибровка.",
|
||||
"Пожизненный LTV × высокий чек (60–180 тыс.) × растущий рынок (старение). "
|
||||
"Изолированный модуль — не пересекается с другими. "
|
||||
"Можно запускать параллельно с C после Фазы 1."
|
||||
)
|
||||
|
||||
def slide_group_D():
|
||||
return slide_group_detail(
|
||||
"D", PRIMARY,
|
||||
"АСИТ + контроль астмы",
|
||||
"Часть аллергии + пульмонология · сегменты 5ч и 7",
|
||||
"• Календарь курса (3–5 лет) с маркером «вы здесь».\n"
|
||||
"• Ежедневный приём СЛИТ с push, стрики.\n"
|
||||
"• Журнал инъекций ПКИТ.\n"
|
||||
"• Дневник симптомов + пыльцевой календарь региона.\n"
|
||||
"• Навигатор побочки с эскалацией к врачу / скорой.\n"
|
||||
"• Экстренная связь, аптечка дома.\n"
|
||||
"• Автошеринг дневника врачу перед плановым визитом.",
|
||||
"Самая высокая глубина пользы — прямое влияние на медицинский исход (удержание = успех лечения). "
|
||||
"Но самая высокая ответственность: требует верификации контента аллергологом/пульмонологом. "
|
||||
"Запускаем последним; контент можно готовить параллельно."
|
||||
)
|
||||
|
||||
def slide_roadmap():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Порядок внедрения", "Дорожная карта")
|
||||
# Фаза 1 бокс
|
||||
p1_h = Inches(0.9)
|
||||
add_rect(s, MARGIN_L, y + Inches(0.05), CONTENT_W, p1_h,
|
||||
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(1))
|
||||
add_text(s, MARGIN_L + Inches(0.3), y + Inches(0.15), Inches(3), Inches(0.3),
|
||||
"ФАЗА 1 · ТРАНЗАКЦИОННАЯ БАЗА", size=11, bold=True, color=PRIMARY_DARK)
|
||||
add_text(s, MARGIN_L + Inches(0.3), y + Inches(0.48), CONTENT_W - Inches(0.6), Inches(0.4),
|
||||
"План лечения → Результаты и медкарта → Заказ справок",
|
||||
size=14, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
# Фаза 1.5 бокс
|
||||
y15 = y + Inches(0.05) + p1_h + Inches(0.15)
|
||||
add_rect(s, MARGIN_L, y15, CONTENT_W, p1_h,
|
||||
fill=WARM, line=WARNING, line_w=Pt(1))
|
||||
add_text(s, MARGIN_L + Inches(0.3), y15 + Inches(0.1), Inches(4), Inches(0.3),
|
||||
"ФАЗА 1.5 · КОММУНИКАЦИОННАЯ НАДСТРОЙКА", size=11, bold=True, color=WARNING)
|
||||
add_text(s, MARGIN_L + Inches(0.3), y15 + Inches(0.43), CONTENT_W - Inches(0.6), Inches(0.4),
|
||||
"Чат с медицинским консьержем → AI-помощник (shadow-mode)",
|
||||
size=14, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
# Фаза 2 — 4 шага
|
||||
y2 = y15 + p1_h + Inches(0.25)
|
||||
add_text(s, MARGIN_L, y2, Inches(6), Inches(0.3),
|
||||
"ФАЗА 2 · СЕГМЕНТНЫЕ МОДУЛИ", size=11, bold=True, color=FG2)
|
||||
# 4 карточки горизонтально со стрелками
|
||||
steps = [
|
||||
("1", "A", "Процедуры самопомощи", "Массовый охват · продолжение плана лечения"),
|
||||
("2", "C", "Пред-оп подготовка", "3 сегмента одним модулем · быстрый эффект"),
|
||||
("3", "B", "Сурдология", "Высокий чек · пожизненное удержание"),
|
||||
("4", "D", "АСИТ + астма", "Верификация врачом · высокая польза"),
|
||||
]
|
||||
step_y = y2 + Inches(0.35)
|
||||
step_h = Inches(2.0)
|
||||
step_w = (CONTENT_W - Inches(0.4) * 3) / 4
|
||||
for i, (num, code, name, sub) in enumerate(steps):
|
||||
sx = MARGIN_L + (step_w + Inches(0.4)) * i
|
||||
add_rect(s, sx, step_y, step_w, step_h, fill=BG, line=PRIMARY, line_w=Pt(1))
|
||||
# Номер и код группы
|
||||
add_text(s, sx + Inches(0.2), step_y + Inches(0.2), Inches(0.8), Inches(0.4),
|
||||
f"Шаг {num}", size=11, color=FG3)
|
||||
add_text(s, sx + Inches(0.2), step_y + Inches(0.45), step_w - Inches(0.4), Inches(0.6),
|
||||
code, size=36, bold=True, color=PRIMARY, font=FONT_DISPLAY)
|
||||
add_text(s, sx + Inches(0.2), step_y + Inches(1.0), step_w - Inches(0.4), Inches(0.4),
|
||||
name, size=13, bold=True, color=FG, font=FONT_DISPLAY)
|
||||
add_text(s, sx + Inches(0.2), step_y + Inches(1.4), step_w - Inches(0.4), Inches(0.55),
|
||||
sub, size=10, color=FG2)
|
||||
# Стрелка
|
||||
if i < 3:
|
||||
arr_x = sx + step_w + Inches(0.05)
|
||||
arr = s.shapes.add_shape(MSO_SHAPE.RIGHT_ARROW, arr_x,
|
||||
step_y + step_h / 2 - Inches(0.15),
|
||||
Inches(0.3), Inches(0.3))
|
||||
arr.shadow.inherit = False
|
||||
arr.fill.solid(); arr.fill.fore_color.rgb = PRIMARY
|
||||
arr.line.fill.background()
|
||||
return s
|
||||
|
||||
def slide_questions():
|
||||
s = new_slide()
|
||||
y = title_bar(s, "Вопросы к обсуждению", "Что уточнить у клиники")
|
||||
questions = [
|
||||
("Поток", "«2 из 3 повторных» — уникальные пациенты в год или визиты?"),
|
||||
("Метрики", "Процент повторных, не доходящих до контрольного приёма?"),
|
||||
("Врачи", "Готовность врачей к SLA по асинхронному чату и в каком окне?"),
|
||||
("МИС", "API: структурированные назначения, сроки годности анализов, расписание?"),
|
||||
("Сурдо", "Сколько кандидатов/мес, % возврата за аппаратом?"),
|
||||
("Хирургия", "Частая причина переноса операций — просроченные анализы?"),
|
||||
("Аллерго", "Готов ли аллерголог верифицировать контент АСИТ-трекера?"),
|
||||
("Контент", "Достаточно ли материалов для RAG-базы?"),
|
||||
]
|
||||
col_h = Inches(0.6)
|
||||
for i, (tag, q) in enumerate(questions):
|
||||
col = i % 2
|
||||
row = i // 2
|
||||
qx = MARGIN_L + (CONTENT_W / 2 + Inches(0.15)) * col
|
||||
qy = y + Inches(0.2) + (col_h + Inches(0.4)) * row
|
||||
qw = CONTENT_W / 2 - Inches(0.15)
|
||||
add_rect(s, qx, qy, Inches(1.0), col_h, fill=PRIMARY)
|
||||
add_text(s, qx, qy, Inches(1.0), col_h, tag,
|
||||
size=12, bold=True, color=BG, font=FONT_DISPLAY,
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||
add_rect(s, qx + Inches(1.0), qy, qw - Inches(1.0), col_h,
|
||||
fill=PRIMARY_50)
|
||||
add_text(s, qx + Inches(1.2), qy, qw - Inches(1.2), col_h, q,
|
||||
size=12, color=FG, anchor=MSO_ANCHOR.MIDDLE)
|
||||
return s
|
||||
|
||||
def slide_final():
|
||||
s = new_slide()
|
||||
# Большой финал: что решить сегодня
|
||||
add_rect(s, 0, 0, SW, SH, fill=PRIMARY)
|
||||
add_text(s, MARGIN_L, Inches(0.8), CONTENT_W, Inches(0.5),
|
||||
"ЧТО РЕШАЕМ СЕГОДНЯ", size=14, bold=True, color=PRIMARY_50)
|
||||
add_text(s, MARGIN_L, Inches(1.4), CONTENT_W, Inches(1.2),
|
||||
"Подтвердить порядок:", size=36, bold=True, color=BG, font=FONT_DISPLAY)
|
||||
add_text(s, MARGIN_L, Inches(2.4), CONTENT_W, Inches(1.8),
|
||||
"Фаза 1 · транзакционная база\n→ Фаза 1.5 · консьерж + AI\n→ Фаза 2: A → C → B → D",
|
||||
size=26, bold=True, color=BG, font=FONT_DISPLAY, line_spacing=1.3)
|
||||
# Три пункта
|
||||
items = [
|
||||
("1", "С чего начинаем Фазу 1?", "План лечения — фундамент. Зависит от МИС-API."),
|
||||
("2", "Когда стартует Фаза 1.5?", "После стабилизации базы. AI в shadow-mode."),
|
||||
("3", "Параллельно", "Контент для D, добавить аллерголога в справочники."),
|
||||
]
|
||||
y = Inches(4.8)
|
||||
for i, (num, q, a) in enumerate(items):
|
||||
ix = MARGIN_L + (CONTENT_W / 3) * i
|
||||
iw = CONTENT_W / 3 - Inches(0.3)
|
||||
add_text(s, ix, y, Inches(0.5), Inches(0.5),
|
||||
num, size=36, bold=True, color=WARM, font=FONT_DISPLAY)
|
||||
add_text(s, ix + Inches(0.6), y + Inches(0.05), iw - Inches(0.6), Inches(0.5),
|
||||
q, size=14, bold=True, color=BG, font=FONT_DISPLAY)
|
||||
add_text(s, ix + Inches(0.6), y + Inches(0.6), iw - Inches(0.6), Inches(1.2),
|
||||
a, size=12, color=PRIMARY_50)
|
||||
# Подпись
|
||||
add_text(s, MARGIN_L, SH - Inches(0.7), CONTENT_W, Inches(0.4),
|
||||
"Клиника УГН · мобильное приложение · 23 апр 2026",
|
||||
size=11, color=PRIMARY_50)
|
||||
return s
|
||||
|
||||
# ============================================================
|
||||
# СБОРКА
|
||||
# ============================================================
|
||||
slides = [
|
||||
slide_title(),
|
||||
slide_agenda(),
|
||||
slide_facts(),
|
||||
slide_frame(),
|
||||
slide_current_functions(),
|
||||
slide_three_layers(),
|
||||
slide_phase1_base(),
|
||||
slide_phase1_5_overlay(),
|
||||
slide_primary_vs_returning(),
|
||||
slide_segment_matrix(),
|
||||
slide_groups_overview(),
|
||||
slide_group_A(),
|
||||
slide_group_C(),
|
||||
slide_group_B(),
|
||||
slide_group_D(),
|
||||
slide_roadmap(),
|
||||
slide_questions(),
|
||||
slide_final(),
|
||||
]
|
||||
|
||||
# Футер на всех слайдах, кроме титульного и финального (у них свой фон)
|
||||
total = len(slides)
|
||||
for idx, s in enumerate(slides, start=1):
|
||||
if idx == 1 or idx == total:
|
||||
continue
|
||||
footer(s, idx, total)
|
||||
|
||||
out = "MEETING_2026-04-23.pptx"
|
||||
prs.save(out)
|
||||
print(f"OK {out} ({total} slides)")
|
||||
Generated
+76
-1
@@ -8,8 +8,11 @@
|
||||
"name": "pcs-pt-mobile",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -739,6 +742,26 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1375,6 +1398,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -1491,6 +1520,20 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -1501,6 +1544,38 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
|
||||
|
||||
+4
-1
@@ -9,8 +9,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
+1479
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+12
-33
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FitWrap } from './FitWrap.jsx';
|
||||
import { IOSDevice } from './frames/IOSDevice.jsx';
|
||||
import { AndroidDevice } from './frames/AndroidDevice.jsx';
|
||||
import { PhoneApp } from './PhoneApp.jsx';
|
||||
@@ -188,6 +189,16 @@ function TweaksPanel({ tw, setTw, onClose }) {
|
||||
))
|
||||
)}
|
||||
{group('Шрифт', opts(FONT_OPTIONS, 'font'))}
|
||||
<div className="tweaks-section">
|
||||
<div className="label">Документы</div>
|
||||
<a href="/roadmap.html" target="_blank" rel="noopener noreferrer" style={{
|
||||
display: 'block', padding: '7px 10px', borderRadius: 8,
|
||||
background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)',
|
||||
fontSize: 12, fontWeight: 700, textDecoration: 'none', textAlign: 'center',
|
||||
}}>
|
||||
Карта развития приложения ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -290,38 +301,6 @@ function DocModal({ doc, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FitWrap({ children, w = 402, h = 874, userScale = 'auto' }) {
|
||||
const outerRef = useRef(null);
|
||||
const [autoScale, setAutoScale] = useState(1);
|
||||
useEffect(() => {
|
||||
const outer = outerRef.current;
|
||||
if (!outer) return;
|
||||
const stage = outer.closest('.stage') || outer.parentElement;
|
||||
if (!stage) return;
|
||||
const measure = () => {
|
||||
const padding = 48;
|
||||
const availW = stage.clientWidth - padding;
|
||||
const availH = stage.clientHeight - padding;
|
||||
const s = Math.min(availW / w, availH / h, 1);
|
||||
setAutoScale(Math.max(s, 0.3));
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(stage);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
|
||||
}, [w, h]);
|
||||
const scale = userScale === 'auto' ? autoScale : parseFloat(userScale);
|
||||
return (
|
||||
<div ref={outerRef} style={{ width: w * scale, height: h * scale, position: 'relative', flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: w, height: h, transformOrigin: 'top left',
|
||||
transform: `scale(${scale})`,
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [tw, setTw] = useState(TWEAKS_DEFAULT);
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { MapContainer, Marker, TileLayer, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
function InvalidateOnMount() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.invalidateSize();
|
||||
const id = window.setTimeout(() => map.invalidateSize(), 120);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function makePinIcon(pinColor) {
|
||||
const safe = /^#[0-9A-Fa-f]{6}$/.test(pinColor) ? pinColor : '#E04E44';
|
||||
return L.divIcon({
|
||||
className: 'clinic-leaflet-pin-wrap',
|
||||
html: `<svg width="26" height="34" viewBox="0 0 24 32" aria-hidden="true"><path d="M12 0C5 0 0 5 0 12c0 8 12 20 12 20s12-12 12-20c0-7-5-12-12-12z" fill="${safe}"/><circle cx="12" cy="12" r="4.5" fill="#fff"/></svg>`,
|
||||
iconSize: [26, 34],
|
||||
iconAnchor: [13, 34],
|
||||
});
|
||||
}
|
||||
|
||||
/** OSM + один маркер; center — [lat, lng] в формате Leaflet */
|
||||
export function ClinicLeafletMap({ center, pinColor = '#E04E44' }) {
|
||||
const icon = useMemo(() => makePinIcon(pinColor), [pinColor]);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={17}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
dragging
|
||||
doubleClickZoom={false}
|
||||
>
|
||||
<InvalidateOnMount />
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<Marker position={center} icon={icon} />
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { I } from './icons.jsx';
|
||||
import { IOSDevice } from './frames/IOSDevice.jsx';
|
||||
import { FitWrap } from './FitWrap.jsx';
|
||||
import { ClinicLeafletMap } from './ClinicLeafletMap.jsx';
|
||||
|
||||
/** Палитра и типографика ближе к скриншоту Oclinica */
|
||||
const oc = {
|
||||
pageBg: '#F2F2F2',
|
||||
headerBg: '#FFFFFF',
|
||||
card: '#FFFFFF',
|
||||
/** Телефон и «Записаться на приём» — один фон и цвет текста */
|
||||
phoneBtnBg: '#9ad1d8',
|
||||
phoneBtnFg: '#001c22',
|
||||
phoneBtnShadow: '0 3px 14px rgba(0, 28, 34, 0.08)',
|
||||
bookBtnBg: '#9ad1d8',
|
||||
bookBtnFg: '#001c22',
|
||||
chatBorder: '#C4A574',
|
||||
chatFg: '#6B542E',
|
||||
openBadgeBg: '#769197',
|
||||
openBadgeFg: '#FFFFFF',
|
||||
/** Блок «Почему нас выбирают» */
|
||||
hWhy: '#B88E71',
|
||||
whyWrapBg: '#EBEDF0',
|
||||
/** Звезда над заголовком «Почему нас выбирают» */
|
||||
whyHeaderStar: '#5f96a0',
|
||||
/** Иконки Email / Веб-сайт */
|
||||
whyInfoIconBg: '#f8faf9',
|
||||
whyInfoIconFg: '#619799',
|
||||
whyRowLabel: '#5A9E95',
|
||||
whyRowValue: '#3A4149',
|
||||
statCardValue: '#599195',
|
||||
statCardLabel: '#6B7684',
|
||||
noteBg: '#E8F4FC',
|
||||
noteBorder: '#B9D8EE',
|
||||
noteFg: '#245A7A',
|
||||
routeBtnBg: '#EBEDEF',
|
||||
routeBtnFg: '#4A5560',
|
||||
teal: '#1F8F85',
|
||||
tealDark: '#166B63',
|
||||
};
|
||||
|
||||
function MiniBuilding() {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
background: 'linear-gradient(180deg, #D4EBE6 0%, #E8F5F2 50%, #B8D4C8 100%)',
|
||||
display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 5,
|
||||
}}>
|
||||
<div style={{
|
||||
width: '68%', height: '58%',
|
||||
background: '#E5DAC8',
|
||||
borderTopLeftRadius: 3, borderTopRightRadius: 3,
|
||||
position: 'relative',
|
||||
boxShadow: 'inset 0 -10px 0 #7A5F3A',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', top: '16%', left: '50%', transform: 'translateX(-50%)',
|
||||
padding: '3px 8px', background: '#166B63', color: '#fff', fontSize: 7, fontWeight: 800,
|
||||
borderRadius: 3, letterSpacing: 0.4,
|
||||
}}>OCLINICA</div>
|
||||
<div style={{
|
||||
position: 'absolute', inset: '40% 10% 20%',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 3,
|
||||
}}>
|
||||
{[1, 0, 1, 1, 0, 1, 0, 1, 1, 0].map((l, i) => (
|
||||
<div key={i} style={{ background: l ? '#F5D78A' : '#2A4544', borderRadius: 1, minHeight: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavRouteIcon({ size = 18, color = oc.teal }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 18l-5-5 5-5M4 13h12.5a4.5 4.5 0 014.5 4.5V19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Глифы VK / YouTube — геометрия Simple Icons, единый цвет в интерфейсе */
|
||||
const SI_VK_PATH =
|
||||
'm9.489.004.729-.003h3.564l.73.003.914.01.433.007.418.011.403.014.388.016.374.021.36.025.345.03.333.033c1.74.196 2.933.616 3.833 1.516.9.9 1.32 2.092 1.516 3.833l.034.333.029.346.025.36.02.373.025.588.012.41.013.644.009.915.004.98-.001 3.313-.003.73-.01.914-.007.433-.011.418-.014.403-.016.388-.021.374-.025.36-.03.345-.033.333c-.196 1.74-.616 2.933-1.516 3.833-.9.9-2.092 1.32-3.833 1.516l-.333.034-.346.029-.36.025-.373.02-.588.025-.41.012-.644.013-.915.009-.98.004-3.313-.001-.73-.003-.914-.01-.433-.007-.418-.011-.403-.014-.388-.016-.374-.021-.36-.025-.345-.03-.333-.033c-1.74-.196-2.933-.616-3.833-1.516-.9-.9-1.32-2.092-1.516-3.833l-.034-.333-.029-.346-.025-.36-.02-.373-.025-.588-.012-.41-.013-.644-.009-.915-.004-.98.001-3.313.003-.73.01-.914.007-.433.011-.418.014-.403.016-.388.021-.374.025-.36.03-.345.033-.333c.196-1.74.616-2.933 1.516-3.833.9-.9 2.092-1.32 3.833-1.516l.333-.034.346-.029.36-.025.373-.02.588-.025.41-.012.644-.013.915-.009ZM6.79 7.3H4.05c.13 6.24 3.25 9.99 8.72 9.99h.31v-3.57c2.01.2 3.53 1.67 4.14 3.57h2.84c-.78-2.84-2.83-4.41-4.11-5.01 1.28-.74 3.08-2.54 3.51-4.98h-2.58c-.56 1.98-2.22 3.78-3.8 3.95V7.3H10.5v6.92c-1.6-.4-3.62-2.34-3.71-6.92Z';
|
||||
|
||||
const SI_YOUTUBE_BG_PATH =
|
||||
'M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z';
|
||||
const SI_YOUTUBE_PLAY_PATH = 'M9.545 15.568V8.432L15.818 12l-6.273 3.568z';
|
||||
|
||||
function SocialVkIcon({ size = 26, color = oc.whyHeaderStar }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden style={{ display: 'block' }}>
|
||||
<path fill={color} d={SI_VK_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialYoutubeIcon({ size = 26, color = oc.whyHeaderStar, playColor = '#fff' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden style={{ display: 'block' }}>
|
||||
<path fill={color} d={SI_YOUTUBE_BG_PATH} />
|
||||
<path fill={playColor} d={SI_YOUTUBE_PLAY_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function OclinicaTabBar() {
|
||||
const tab = (label, Icon, active) => (
|
||||
<div
|
||||
key={label}
|
||||
style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
|
||||
padding: '5px 0 2px', color: active ? oc.tealDark : '#9AA7B4',
|
||||
fontSize: 10, fontWeight: active ? 700 : 600,
|
||||
}}
|
||||
>
|
||||
<Icon size={22} sw={active ? 2.1 : 1.75} style={{ color: active ? oc.tealDark : '#9AA7B4' }} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', paddingBottom: 20 }}>
|
||||
<Link to="/" style={{ flex: 1, textDecoration: 'none', color: 'inherit' }}>
|
||||
{tab('Главная', I.home, false)}
|
||||
</Link>
|
||||
{tab('Клиники', I.pin, true)}
|
||||
{tab('Запись', I.calendar, false)}
|
||||
{tab('Профиль', I.profile, false)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Конверт — заливка заданным зелёным */
|
||||
function WhyMailGlyph({ size = 22, color = oc.whyInfoIconFg }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden style={{ display: 'block' }}>
|
||||
<path
|
||||
fill={color}
|
||||
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Глобус — контур тем же зелёным */
|
||||
function WhyGlobeGlyph({ size = 22, color = oc.whyInfoIconFg }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden style={{ display: 'block' }}>
|
||||
<circle cx="12" cy="12" r="9.25" stroke={color} strokeWidth="2" />
|
||||
<ellipse cx="12" cy="12" rx="4.2" ry="9.25" stroke={color} strokeWidth="1.75" />
|
||||
<path d="M2.5 12h19" stroke={color} strokeWidth="1.75" strokeLinecap="round" />
|
||||
<path
|
||||
d="M6.5 6.5c1.4 1.9 2.2 4.1 2.2 5.5s-.8 3.6-2.2 5.5M17.5 6.5c-1.4 1.9-2.2 4.1-2.2 5.5s.8 3.6 2.2 5.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.55"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Девятиконечная звезда + белая галочка по центру (блок «Почему нас выбирают») */
|
||||
/** Внешний ≈8.35, внутренний ≈7.22 — чуть крупнее, лучи по-прежнему короткие */
|
||||
const WHY_NINE_POINT_STAR_D =
|
||||
'M12.000,3.650L14.469,5.215L17.367,5.604L18.253,8.390L20.223,10.550L19.110,13.254L19.231,16.175L16.641,17.531L14.856,19.846L12.000,19.220L9.144,19.846L7.359,17.531L4.769,16.175L4.890,13.254L3.777,10.550L5.747,8.390L6.633,5.604L9.531,5.215Z';
|
||||
|
||||
function WhyHeaderStarGlyph({ size = 52, color = oc.whyHeaderStar }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden
|
||||
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
||||
>
|
||||
<path fill={color} d={WHY_NINE_POINT_STAR_D} />
|
||||
<path
|
||||
transform="translate(12,12) scale(0.52) translate(-12,-12)"
|
||||
d="M8.05 12.05l2.75 2.75 5.85-6.45"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ value, label, suffix }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, minWidth: 0,
|
||||
background: oc.card,
|
||||
borderRadius: 12,
|
||||
padding: '10px 4px 11px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 1px 3px rgba(15,30,40,0.06)',
|
||||
border: '1px solid #E8EAED',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3,
|
||||
fontSize: 16, fontWeight: 800, color: oc.statCardValue, fontFamily: 'var(--font-narrow)', lineHeight: 1.1,
|
||||
}}>
|
||||
<span>{value}</span>
|
||||
{suffix}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 8, fontWeight: 800, letterSpacing: 0.7, color: oc.statCardLabel, marginTop: 5,
|
||||
lineHeight: 1.2, textTransform: 'uppercase',
|
||||
}}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ glyph, label, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
background: oc.card,
|
||||
borderRadius: 14,
|
||||
padding: '12px 14px',
|
||||
marginBottom: 10,
|
||||
border: '1px solid #E8EAED',
|
||||
boxShadow: '0 1px 4px rgba(15,30,40,0.05)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 46, height: 46, borderRadius: '50%', background: oc.whyInfoIconBg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
border: '1px solid #eef2f0',
|
||||
}}>
|
||||
{glyph}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 13, color: oc.whyRowLabel, marginBottom: 3, fontWeight: 700, letterSpacing: 0.2,
|
||||
}}>{label}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: oc.whyRowValue, letterSpacing: 0.02, lineHeight: 1.35 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClinicCirclePhoto({ imageUrl, href }) {
|
||||
const [useFallback, setUseFallback] = React.useState(false);
|
||||
const img = !useFallback ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
display: 'block',
|
||||
}}
|
||||
onError={() => setUseFallback(true)}
|
||||
/>
|
||||
) : (
|
||||
<MiniBuilding />
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{img}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
const FOOTER_H = 162;
|
||||
/** Выше внутренних слоёв Leaflet (~400–700), чтобы хром не перекрывался картой при скролле */
|
||||
const Z_SCROLL = 0;
|
||||
const Z_HEADER = 2000;
|
||||
const Z_FOOTER_BAR = 2100;
|
||||
|
||||
function ContactsPhoneBody() {
|
||||
const navigate = useNavigate();
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) navigate(-1);
|
||||
else navigate('/');
|
||||
};
|
||||
|
||||
const clinics = [
|
||||
{
|
||||
id: 'zvezda',
|
||||
street: 'ул. Газеты Звезда, 31А',
|
||||
hours: 'Пн–Пт 09:00 – 21:00\nСб–Вс 09:00 – 19:00',
|
||||
note: 'Каждый 4-ый четверг месяца до 17:00',
|
||||
mapPosition: [58.008116, 56.246041],
|
||||
mapPinColor: '#E04E44',
|
||||
circleImageSrc: 'https://avatars.mds.yandex.net/get-altay/1363018/2a00000164698e13e4695cde8053e9eed99f/L_height',
|
||||
circleHref: 'https://yandex.ru/maps/org/klinika_ukho_gorlo_nos_imeni_professora_ye_n_olenevoy/1747301334/',
|
||||
},
|
||||
{
|
||||
id: 'tsetkin',
|
||||
street: 'ул. Клары Цеткин, 9',
|
||||
hours: 'Пн–Сб 09:00 – 17:00\nВс — выходной',
|
||||
note: null,
|
||||
mapPosition: [57.987262, 56.246448],
|
||||
mapPinColor: '#D94A3D',
|
||||
circleImageSrc: 'https://lh5.googleusercontent.com/proxy/FtM-XTbdVjWSzmAYLN1W_b69pueujUj2Gv6Yr7RqwYwQJOrisuUt_YI6qIXyaO9kZa3BZmQQrFpcJcfbcJUvdMGJx-s7UM3PYrnLsYtZcJ8jdVllLCU',
|
||||
circleHref: 'http://job.oclinica.ru/vakansiya-administratora',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: oc.pageBg,
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'var(--font-base), "Inter", system-ui, sans-serif',
|
||||
color: '#1F2A37',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
overflowY: 'auto', overflowX: 'hidden',
|
||||
paddingTop: 58,
|
||||
paddingBottom: FOOTER_H,
|
||||
zIndex: Z_SCROLL,
|
||||
isolation: 'isolate',
|
||||
}}>
|
||||
<header style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '42px 1fr 42px',
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px 11px',
|
||||
position: 'sticky', top: 0, zIndex: Z_HEADER,
|
||||
background: oc.headerBg,
|
||||
borderBottom: '1px solid #EFEFEF',
|
||||
}}>
|
||||
<button type="button" onClick={goBack} className="press" style={{
|
||||
width: 38, height: 38, borderRadius: 999, background: '#F5F5F5',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: 0,
|
||||
}}>
|
||||
<I.chevL size={20} style={{ color: '#2A3540' }} />
|
||||
</button>
|
||||
<h1 style={{ margin: 0, fontSize: 17, fontWeight: 700, textAlign: 'center', color: '#1F2A37' }}>Контакты</h1>
|
||||
<span />
|
||||
</header>
|
||||
|
||||
<div style={{ padding: '14px 16px 20px' }}>
|
||||
<a href="tel:+73422070303" className="press" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
width: '100%', padding: '15px 16px', borderRadius: 16, marginBottom: 10,
|
||||
background: oc.phoneBtnBg, color: oc.phoneBtnFg, textDecoration: 'none',
|
||||
fontSize: 17, fontWeight: 800, letterSpacing: 0.2,
|
||||
boxShadow: oc.phoneBtnShadow,
|
||||
border: '1px solid rgba(255,255,255,0.45)',
|
||||
}}>
|
||||
<I.phone size={21} sw={2} style={{ color: oc.phoneBtnFg }} />
|
||||
8(342) 207-03-03
|
||||
</a>
|
||||
|
||||
<Link to="/" className="press" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 9,
|
||||
width: '100%', padding: '14px 16px', borderRadius: 16, marginBottom: 20,
|
||||
background: oc.card, color: oc.chatFg,
|
||||
border: `1.5px solid ${oc.chatBorder}`, textDecoration: 'none', fontWeight: 700, fontSize: 15,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<I.chat size={20} />
|
||||
Написать в чат
|
||||
</Link>
|
||||
|
||||
<h2 style={{ fontSize: 16, fontWeight: 800, margin: '0 0 12px', color: '#1F2A37', letterSpacing: 0.02 }}>Наши клиники</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{clinics.map(c => (
|
||||
<article key={c.id} style={{
|
||||
background: oc.card,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 14px rgba(15,30,40,0.07)',
|
||||
border: '1px solid #ECECEC',
|
||||
}}>
|
||||
<div style={{ padding: 12, paddingBottom: 4 }}>
|
||||
<div style={{
|
||||
borderRadius: 14, overflow: 'hidden', position: 'relative',
|
||||
aspectRatio: '16 / 10',
|
||||
isolation: 'isolate',
|
||||
}}>
|
||||
<ClinicLeafletMap key={c.id} center={c.mapPosition} pinColor={c.mapPinColor} />
|
||||
<div style={{
|
||||
position: 'absolute', top: 10, left: 10, zIndex: 500,
|
||||
fontSize: 9, fontWeight: 900, letterSpacing: 0.7,
|
||||
padding: '6px 11px', borderRadius: 8,
|
||||
background: oc.openBadgeBg, color: oc.openBadgeFg,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
}}>ОТКРЫТО</div>
|
||||
<div style={{
|
||||
position: 'absolute', right: 10, bottom: 10,
|
||||
width: 88, height: 88, borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
border: '4px solid #fff',
|
||||
boxShadow: '0 6px 18px rgba(15,30,40,0.14)',
|
||||
zIndex: 600,
|
||||
background: '#fff',
|
||||
}}>
|
||||
<ClinicCirclePhoto imageUrl={c.circleImageSrc} href={c.circleHref} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 16px 16px' }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 800, lineHeight: 1.25, marginBottom: 12, color: '#1F2A37' }}>
|
||||
{c.street}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 800, color: '#6B7A89', letterSpacing: 0.4 }}>Режим работы</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: c.note ? 10 : 14 }}>
|
||||
<I.clock size={18} style={{ color: '#8A96A0', flexShrink: 0, marginTop: 1 }} />
|
||||
<div style={{ fontSize: 13, color: '#5A6B7B', lineHeight: 1.5, whiteSpace: 'pre-line', fontWeight: 500 }}>
|
||||
{c.hours}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.note && (
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 600, color: oc.noteFg, lineHeight: 1.45,
|
||||
padding: '11px 13px', borderRadius: 12,
|
||||
background: oc.noteBg, border: `1px solid ${oc.noteBorder}`,
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
{c.note}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" className="press" style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
padding: '13px 14px', borderRadius: 14,
|
||||
background: oc.routeBtnBg, border: '1px solid #E0E3E6',
|
||||
fontSize: 14, fontWeight: 700, color: oc.routeBtnFg,
|
||||
}}>
|
||||
<NavRouteIcon size={19} color={oc.teal} />
|
||||
Построить маршрут
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: 22,
|
||||
background: oc.whyWrapBg,
|
||||
borderRadius: 20,
|
||||
padding: '18px 12px 16px',
|
||||
border: '1px solid #DDE0E4',
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.7)',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 14 }}>
|
||||
<div style={{ marginBottom: 10, lineHeight: 0 }}>
|
||||
<WhyHeaderStarGlyph size={52} color={oc.whyHeaderStar} />
|
||||
</div>
|
||||
<h2 style={{
|
||||
margin: 0, fontSize: 20, fontWeight: 700, color: oc.hWhy,
|
||||
letterSpacing: 0.08, fontFamily: 'var(--font-base)', lineHeight: 1.25,
|
||||
}}>Почему нас выбирают</h2>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'row', gap: 8,
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<StatPill value="20+" label="лет опыта" />
|
||||
<StatPill value="50+" label="врачей" />
|
||||
<StatPill value="100k+" label="пациентов" />
|
||||
<StatPill value="4.9" label="рейтинг" suffix={<span style={{ fontSize: 13, color: oc.statCardValue, lineHeight: 1 }}>★</span>} />
|
||||
</div>
|
||||
|
||||
<InfoRow glyph={<WhyMailGlyph size={23} />} label="Email">
|
||||
<a href="mailto:mail@oclinica.ru" style={{ color: oc.whyRowValue, textDecoration: 'none', fontWeight: 600 }}>
|
||||
mail@oclinica.ru
|
||||
</a>
|
||||
</InfoRow>
|
||||
<InfoRow glyph={<WhyGlobeGlyph size={23} />} label="Веб-сайт">
|
||||
<a href="https://perm.oclinica.ru" target="_blank" rel="noreferrer" style={{ color: oc.whyRowValue, textDecoration: 'none', fontWeight: 600 }}>
|
||||
perm.oclinica.ru
|
||||
</a>
|
||||
</InfoRow>
|
||||
|
||||
<div style={{ display: 'flex', gap: 14, marginTop: 6, justifyContent: 'center' }}>
|
||||
<a
|
||||
href="https://vk.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="press"
|
||||
aria-label="ВКонтакте"
|
||||
style={{
|
||||
width: 50, height: 50, borderRadius: '50%', background: '#fff',
|
||||
border: '1px solid #E4E4E4', boxShadow: '0 2px 10px rgba(0,0,0,0.07)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<SocialVkIcon size={26} />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="press"
|
||||
aria-label="YouTube"
|
||||
style={{
|
||||
width: 50, height: 50, borderRadius: '50%', background: '#fff',
|
||||
border: '1px solid #E4E4E4', boxShadow: '0 2px 10px rgba(0,0,0,0.07)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<SocialYoutubeIcon size={26} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 20 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: Z_FOOTER_BAR,
|
||||
background: 'rgba(255,255,255,0.94)',
|
||||
backdropFilter: 'blur(16px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(16px) saturate(180%)',
|
||||
borderTop: '1px solid #E8E8E8',
|
||||
boxShadow: '0 -4px 24px rgba(15,30,40,0.06)',
|
||||
}}>
|
||||
<div style={{ padding: '10px 16px 6px' }}>
|
||||
<Link to="/" className="press" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
width: '100%', padding: '15px 16px', borderRadius: 16, textDecoration: 'none',
|
||||
background: oc.bookBtnBg,
|
||||
color: oc.bookBtnFg, fontWeight: 800, fontSize: 16, letterSpacing: 0.02,
|
||||
boxShadow: '0 6px 18px rgba(0, 28, 34, 0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.35)',
|
||||
}}>
|
||||
<I.calendar size={21} sw={2} style={{ color: oc.bookBtnFg }} />
|
||||
Записаться на приём
|
||||
</Link>
|
||||
</div>
|
||||
<OclinicaTabBar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactsRoutePage() {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<div className="stage">
|
||||
<FitWrap userScale="auto">
|
||||
<IOSDevice>
|
||||
<ContactsPhoneBody />
|
||||
</IOSDevice>
|
||||
</FitWrap>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function FitWrap({ children, w = 402, h = 874, userScale = 'auto' }) {
|
||||
const outerRef = useRef(null);
|
||||
const [autoScale, setAutoScale] = useState(1);
|
||||
useEffect(() => {
|
||||
const outer = outerRef.current;
|
||||
if (!outer) return;
|
||||
const stage = outer.closest('.stage') || outer.parentElement;
|
||||
if (!stage) return;
|
||||
const measure = () => {
|
||||
const padding = 48;
|
||||
const availW = stage.clientWidth - padding;
|
||||
const availH = stage.clientHeight - padding;
|
||||
const s = Math.min(availW / w, availH / h, 1);
|
||||
setAutoScale(Math.max(s, 0.3));
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(stage);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
|
||||
}, [w, h]);
|
||||
const scale = userScale === 'auto' ? autoScale : parseFloat(userScale);
|
||||
return (
|
||||
<div ref={outerRef} style={{ width: w * scale, height: h * scale, position: 'relative', flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: w, height: h, transformOrigin: 'top left',
|
||||
transform: `scale(${scale})`,
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -345,3 +345,9 @@ input, select, textarea { font-family: inherit; }
|
||||
|
||||
@keyframes pulse { 0%{opacity:.5;transform:scale(.95)} 100%{opacity:0;transform:scale(1.15)} }
|
||||
@keyframes blink { 50% { opacity: .3 } }
|
||||
|
||||
/* Leaflet: divIcon marker without default box */
|
||||
.leaflet-div-icon.clinic-leaflet-pin-wrap {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
@@ -42,4 +42,6 @@ export const I = {
|
||||
volume: (p) => <Icon {...p}><path d="M4 9h4l5-4v14l-5-4H4V9z"/><path d="M16 8a5 5 0 010 8M19 5a9 9 0 010 14"/></Icon>,
|
||||
arrow: (p) => <Icon {...p}><path d="M5 12h14M13 6l6 6-6 6"/></Icon>,
|
||||
menu: (p) => <Icon {...p}><path d="M4 7h16M4 12h16M4 17h16"/></Icon>,
|
||||
mail: (p) => <Icon {...p}><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></Icon>,
|
||||
globe: (p) => <Icon {...p}><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2v20M5 5c2.5 2 13.5 2 16 0M5 19c2.5-2 13.5-2 16 0"/></Icon>,
|
||||
};
|
||||
|
||||
+8
-1
@@ -1,11 +1,18 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
import ContactsRoutePage from './ContactsRoutePage.jsx';
|
||||
import './tokens.css';
|
||||
import './app.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/contacts" element={<ContactsRoutePage />} />
|
||||
<Route path="*" element={<App />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user