Compare commits
8 Commits
a02c4faad7
...
33b0b89271
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33b0b89271 | ||
|
|
7448776d90 | ||
|
|
5da3c96179 | ||
|
|
09ae65b827 | ||
|
|
928620bde9 | ||
|
|
528d9766ed | ||
|
|
157dfbf186 | ||
|
|
050dc30d92 |
@ -1,6 +1,8 @@
|
|||||||
FROM python:3.11-alpine
|
FROM python:3.11-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN pip install --no-cache-dir requests psycopg2-binary
|
RUN pip install --no-cache-dir requests psycopg2-binary \
|
||||||
COPY bridge_1c.py /app/bridge_1c.py
|
&& adduser -D -u 10001 app
|
||||||
|
COPY --chown=10001:10001 bridge_1c.py /app/bridge_1c.py
|
||||||
|
USER app
|
||||||
EXPOSE 8088
|
EXPOSE 8088
|
||||||
CMD ["python", "/app/bridge_1c.py"]
|
CMD ["python", "/app/bridge_1c.py"]
|
||||||
|
|||||||
@ -6,19 +6,53 @@ Runs in Docker container on 195.95.133.47 and talks to 1C at 10.240.70.70:6003.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import json
|
import json
|
||||||
|
import hmac
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import requests
|
import requests
|
||||||
import psycopg2
|
import psycopg2
|
||||||
|
|
||||||
MCP_URL = "http://10.240.70.70:6003/api/execute_code"
|
MCP_URL = os.environ.get("ONEC_MCP_URL", "http://10.240.70.70:6003/api/execute_code")
|
||||||
PG_HOST = "db"
|
PG_HOST = os.environ.get("PG_HOST", "db")
|
||||||
PG_PORT = 5432
|
PG_PORT = int(os.environ.get("PG_PORT", "5432"))
|
||||||
PG_USER = "dana"
|
PG_USER = os.environ.get("PG_USER", "dana")
|
||||||
PG_DB = "dana"
|
PG_DB = os.environ.get("PG_DB", "dana")
|
||||||
PG_PASS = "301ed23c81a8e2317b77f67340f571411ac16a5e5927e4bf17b076e01657f5c0"
|
PG_PASS = os.environ.get("POSTGRES_PASSWORD", "")
|
||||||
|
|
||||||
|
# Токен для вызова 1C MCP (Bearer) и ключ, которым Go-бэкенд авторизуется перед мостом.
|
||||||
|
AUTH_TOKEN = os.environ.get("ONEC_MCP_TOKEN", "")
|
||||||
|
BRIDGE_API_KEY = os.environ.get("BRIDGE_API_KEY", "")
|
||||||
|
if not AUTH_TOKEN:
|
||||||
|
sys.exit("ONEC_MCP_TOKEN is not set")
|
||||||
|
if not BRIDGE_API_KEY:
|
||||||
|
sys.exit("BRIDGE_API_KEY is not set")
|
||||||
|
|
||||||
|
MAX_BODY = 64 * 1024
|
||||||
|
UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||||
|
|
||||||
|
|
||||||
|
def bsl_text(value, maxlen=1000):
|
||||||
|
"""Экранирует значение для вставки внутрь строкового литерала BSL ("...").
|
||||||
|
|
||||||
|
В 1С кавычка внутри строки удваивается. Управляющие символы и переводы строк
|
||||||
|
вырезаем: в однострочном литерале они валят компиляцию и не нужны для ФИО/комментария.
|
||||||
|
"""
|
||||||
|
v = "".join(c for c in str(value) if c.isprintable())[:maxlen]
|
||||||
|
return v.replace('"', '""')
|
||||||
|
|
||||||
|
|
||||||
|
def is_uuid(value):
|
||||||
|
return isinstance(value, str) and bool(UUID_RE.fullmatch(value))
|
||||||
|
|
||||||
|
|
||||||
|
def clean_phone(value):
|
||||||
|
"""Телефон для BSL-строки: только цифры и базовые символы форматирования."""
|
||||||
|
v = re.sub(r"[^\d+\-() ]", "", str(value))[:40]
|
||||||
|
return v
|
||||||
|
|
||||||
def get_linked_doctor_ids():
|
def get_linked_doctor_ids():
|
||||||
try:
|
try:
|
||||||
@ -45,8 +79,6 @@ def get_linked_doctor_ids():
|
|||||||
"a748a2a7-8771-11ed-840b-00155d46cd22",
|
"a748a2a7-8771-11ed-840b-00155d46cd22",
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTH_TOKEN = "747570bf-e7ab-461b-9d30-6f1df275a8df"
|
|
||||||
|
|
||||||
def call_1c(code: str):
|
def call_1c(code: str):
|
||||||
try:
|
try:
|
||||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json"}
|
||||||
@ -76,6 +108,19 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
def log_message(self, format, *args):
|
def log_message(self, format, *args):
|
||||||
sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), format % args))
|
sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), format % args))
|
||||||
|
|
||||||
|
def _authorized(self):
|
||||||
|
"""Мост принимает запросы только от Go-бэкенда: X-Api-Key или Bearer."""
|
||||||
|
got = self.headers.get("X-Api-Key", "")
|
||||||
|
if not got:
|
||||||
|
got = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
||||||
|
return bool(got) and hmac.compare_digest(got.encode("utf-8"), BRIDGE_API_KEY.encode("utf-8"))
|
||||||
|
|
||||||
|
def _require_auth(self):
|
||||||
|
if self._authorized():
|
||||||
|
return True
|
||||||
|
self.send_json(401, {"code": "unauthorized", "message": "Invalid API key"})
|
||||||
|
return False
|
||||||
|
|
||||||
def send_json(self, status_code: int, data: any):
|
def send_json(self, status_code: int, data: any):
|
||||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||||
self.send_response(status_code)
|
self.send_response(status_code)
|
||||||
@ -89,6 +134,12 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
path = parsed.path.rstrip("/")
|
path = parsed.path.rstrip("/")
|
||||||
query = urllib.parse.parse_qs(parsed.query)
|
query = urllib.parse.parse_qs(parsed.query)
|
||||||
|
|
||||||
|
if path == "/healthz":
|
||||||
|
self.send_json(200, {"ok": True})
|
||||||
|
return
|
||||||
|
if not self._require_auth():
|
||||||
|
return
|
||||||
|
|
||||||
if path == "/branches":
|
if path == "/branches":
|
||||||
code = """
|
code = """
|
||||||
Запрос = Новый Запрос;
|
Запрос = Новый Запрос;
|
||||||
@ -181,6 +232,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
if path.startswith("/doctors/") and path.endswith("/services"):
|
if path.startswith("/doctors/") and path.endswith("/services"):
|
||||||
parts = path.split("/")
|
parts = path.split("/")
|
||||||
doctor_id = parts[2]
|
doctor_id = parts[2]
|
||||||
|
if not is_uuid(doctor_id):
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"})
|
||||||
|
return
|
||||||
code = f"""
|
code = f"""
|
||||||
ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}"));
|
ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}"));
|
||||||
Запрос = Новый Запрос;
|
Запрос = Новый Запрос;
|
||||||
@ -273,9 +327,17 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
# Determine target doctors
|
# Determine target doctors
|
||||||
if doctor_id:
|
if doctor_id:
|
||||||
|
if not is_uuid(doctor_id):
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"})
|
||||||
|
return
|
||||||
target_ids = [doctor_id]
|
target_ids = [doctor_id]
|
||||||
else:
|
else:
|
||||||
target_ids = get_linked_doctor_ids()
|
target_ids = get_linked_doctor_ids()
|
||||||
|
target_ids = [x for x in target_ids if is_uuid(x)]
|
||||||
|
|
||||||
|
if branch_id and not is_uuid(branch_id):
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "branch_id must be a UUID"})
|
||||||
|
return
|
||||||
|
|
||||||
ids_1c_array = ", ".join([f'Новый УникальныйИдентификатор("{x}")' for x in target_ids])
|
ids_1c_array = ", ".join([f'Новый УникальныйИдентификатор("{x}")' for x in target_ids])
|
||||||
|
|
||||||
@ -409,6 +471,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
if path.startswith("/appointments/"):
|
if path.startswith("/appointments/"):
|
||||||
appt_id = path.split("/")[2]
|
appt_id = path.split("/")[2]
|
||||||
|
if not is_uuid(appt_id):
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "appointment_id must be a UUID"})
|
||||||
|
return
|
||||||
code = f"""
|
code = f"""
|
||||||
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}"));
|
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}"));
|
||||||
Ответ = Новый Структура;
|
Ответ = Новый Структура;
|
||||||
@ -446,7 +511,15 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
parsed = urllib.parse.urlparse(self.path)
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
path = parsed.path.rstrip("/")
|
path = parsed.path.rstrip("/")
|
||||||
|
|
||||||
|
if not self._require_auth():
|
||||||
|
return
|
||||||
|
try:
|
||||||
content_len = int(self.headers.get("Content-Length", 0))
|
content_len = int(self.headers.get("Content-Length", 0))
|
||||||
|
except ValueError:
|
||||||
|
content_len = 0
|
||||||
|
if content_len > MAX_BODY:
|
||||||
|
self.send_json(413, {"code": "too_large", "message": "Request body too large"})
|
||||||
|
return
|
||||||
post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
|
post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
|
||||||
try:
|
try:
|
||||||
req_json = json.loads(post_data.decode("utf-8"))
|
req_json = json.loads(post_data.decode("utf-8"))
|
||||||
@ -454,9 +527,13 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(400, {"code": "bad_request", "message": "Invalid JSON"})
|
self.send_json(400, {"code": "bad_request", "message": "Invalid JSON"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not isinstance(req_json, dict):
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "JSON object required"})
|
||||||
|
return
|
||||||
|
|
||||||
if path == "/appointments":
|
if path == "/appointments":
|
||||||
doctor_id = req_json.get("doctor_id", "")
|
doctor_id = req_json.get("doctor_id", "")
|
||||||
bitrix_lead_id = str(req_json.get("bitrix_lead_id", "")).strip()
|
bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200)
|
||||||
branch_id = req_json.get("branch_id", "")
|
branch_id = req_json.get("branch_id", "")
|
||||||
patient_name = req_json.get("patient_name", "")
|
patient_name = req_json.get("patient_name", "")
|
||||||
patient_phone = req_json.get("patient_phone", "")
|
patient_phone = req_json.get("patient_phone", "")
|
||||||
@ -475,6 +552,24 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
if not patient_name:
|
if not patient_name:
|
||||||
patient_name = "Пациент с сайта"
|
patient_name = "Пациент с сайта"
|
||||||
|
|
||||||
|
# Валидация до сборки BSL-кода: ID — строго UUID, дата — ISO 8601,
|
||||||
|
# свободный текст — через bsl_text (удвоение кавычек + срез управляющих).
|
||||||
|
if not is_uuid(doctor_id):
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"})
|
||||||
|
return
|
||||||
|
if branch_id and not is_uuid(branch_id):
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "branch_id must be a UUID"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
start_str = datetime.fromisoformat(str(start_str).replace("Z", "+00:00")).isoformat()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "start must be ISO 8601"})
|
||||||
|
return
|
||||||
|
patient_name = bsl_text(patient_name, 200)
|
||||||
|
patient_phone = clean_phone(patient_phone)
|
||||||
|
comment = bsl_text(comment, 2000)
|
||||||
|
start_str = bsl_text(start_str, 40)
|
||||||
|
|
||||||
# Normalize phone digits and generate search patterns
|
# Normalize phone digits and generate search patterns
|
||||||
digits = "".join([c for c in patient_phone if c.isdigit()])
|
digits = "".join([c for c in patient_phone if c.isdigit()])
|
||||||
if len(digits) >= 10:
|
if len(digits) >= 10:
|
||||||
@ -666,8 +761,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if path == "/appointments/link-bitrix":
|
if path == "/appointments/link-bitrix":
|
||||||
appointment_id = str(req_json.get("appointment_id", "")).strip()
|
# Legacy linking supports UUIDs and document numbers: escape both.
|
||||||
bitrix_lead_id = str(req_json.get("bitrix_lead_id", "")).strip()
|
appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200)
|
||||||
|
bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200)
|
||||||
if not appointment_id or not bitrix_lead_id:
|
if not appointment_id or not bitrix_lead_id:
|
||||||
self.send_json(400, {"code": "bad_request", "message": "appointment_id and bitrix_lead_id are required"})
|
self.send_json(400, {"code": "bad_request", "message": "appointment_id and bitrix_lead_id are required"})
|
||||||
return
|
return
|
||||||
@ -738,8 +834,8 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if path == "/appointments/link-crm":
|
if path == "/appointments/link-crm":
|
||||||
appointment_id = str(req_json.get("appointment_id", "")).strip()
|
appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200)
|
||||||
crm_deal_id = str(req_json.get("crm_deal_id", "")).strip()
|
crm_deal_id = bsl_text(str(req_json.get("crm_deal_id", "")).strip(), 200)
|
||||||
if not appointment_id or not crm_deal_id:
|
if not appointment_id or not crm_deal_id:
|
||||||
self.send_json(400, {"code": "bad_request", "message": "appointment_id and crm_deal_id are required"})
|
self.send_json(400, {"code": "bad_request", "message": "appointment_id and crm_deal_id are required"})
|
||||||
return
|
return
|
||||||
@ -815,9 +911,12 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
parsed = urllib.parse.urlparse(self.path)
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
path = parsed.path.rstrip("/")
|
path = parsed.path.rstrip("/")
|
||||||
|
|
||||||
|
if not self._require_auth():
|
||||||
|
return
|
||||||
|
|
||||||
parts = path.split("/")
|
parts = path.split("/")
|
||||||
if len(parts) == 3 and parts[1] == "appointments":
|
if len(parts) == 3 and parts[1] == "appointments":
|
||||||
appointment_id = urllib.parse.unquote(parts[2]).strip()
|
appointment_id = bsl_text(urllib.parse.unquote(parts[2]).strip(), 200)
|
||||||
code = f"""
|
code = f"""
|
||||||
Попытка
|
Попытка
|
||||||
ДокСсылка = Неопределено;
|
ДокСсылка = Неопределено;
|
||||||
|
|||||||
124
bridge/test_bridge.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Smoke-тест моста: stub requests/psycopg2, реальный HTTPServer, проверка
|
||||||
|
аутентификации, UUID-валидации и BSL-экранирования."""
|
||||||
|
import os, sys, types, json, threading, urllib.request, urllib.error
|
||||||
|
|
||||||
|
# --- stubs ---
|
||||||
|
requests_stub = types.ModuleType("requests")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_post(url, json=None, headers=None, timeout=None):
|
||||||
|
captured["code"] = json.get("code", "")
|
||||||
|
captured["headers"] = headers
|
||||||
|
class R:
|
||||||
|
status_code = 200
|
||||||
|
text = ""
|
||||||
|
def json(self):
|
||||||
|
return {"success": True, "data": "[]"}
|
||||||
|
return R()
|
||||||
|
requests_stub.post = fake_post
|
||||||
|
requests_stub.get = lambda *a, **k: None
|
||||||
|
|
||||||
|
psycopg2_stub = types.ModuleType("psycopg2")
|
||||||
|
psycopg2_stub.connect = lambda *a, **k: (_ for _ in ()).throw(Exception("no db in test"))
|
||||||
|
|
||||||
|
sys.modules["requests"] = requests_stub
|
||||||
|
sys.modules["psycopg2"] = psycopg2_stub
|
||||||
|
|
||||||
|
os.environ["ONEC_MCP_TOKEN"] = "test-mcp-token"
|
||||||
|
os.environ["BRIDGE_API_KEY"] = "test-bridge-key"
|
||||||
|
os.environ["POSTGRES_PASSWORD"] = "x"
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import bridge_1c
|
||||||
|
|
||||||
|
from http.server import HTTPServer
|
||||||
|
srv = HTTPServer(("127.0.0.1", 0), bridge_1c.BridgeHandler)
|
||||||
|
port = srv.server_address[1]
|
||||||
|
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
BASE = f"http://127.0.0.1:{port}"
|
||||||
|
KEY = {"X-Api-Key": "test-bridge-key"}
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
def req(method, path, body=None, headers=None):
|
||||||
|
from urllib.parse import quote
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(BASE + quote(path, safe="/?=&%"), data=data, method=method,
|
||||||
|
headers=headers or {}, )
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=5) as resp:
|
||||||
|
return resp.status, resp.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read()
|
||||||
|
|
||||||
|
def check(name, cond, extra=""):
|
||||||
|
print(("PASS" if cond else "FAIL"), name, extra)
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
# 1. без ключа -> 401
|
||||||
|
st, _ = req("GET", "/doctors/x/services")
|
||||||
|
check("no-auth GET -> 401", st == 401, st)
|
||||||
|
st, _ = req("POST", "/appointments", {})
|
||||||
|
check("no-auth POST -> 401", st == 401, st)
|
||||||
|
|
||||||
|
# 2. неверный ключ -> 401
|
||||||
|
st, _ = req("GET", "/branches", headers={"X-Api-Key": "wrong"})
|
||||||
|
check("bad key -> 401", st == 401, st)
|
||||||
|
|
||||||
|
# 3. Bearer тоже принимается
|
||||||
|
st, _ = req("GET", "/branches", headers={"Authorization": "Bearer test-bridge-key"})
|
||||||
|
check("Bearer ok", st == 200, st)
|
||||||
|
assert "Bearer" not in json.dumps(captured.get("headers", {})) or True
|
||||||
|
check("1C got MCP token", captured["headers"].get("Authorization") == "Bearer test-mcp-token")
|
||||||
|
|
||||||
|
# 4. UUID-валидация: инъекция в doctor_id
|
||||||
|
st, _ = req("GET", '/doctors/abc");Выполнить(0);//services', headers=KEY)
|
||||||
|
check("bad doctor_id -> 400", st == 400, st)
|
||||||
|
st, _ = req("GET", '/schedule?doctor_id=x%22%3BВыполнить(0)%3B', headers=KEY)
|
||||||
|
check("bad schedule doctor_id -> 400", st == 400, st)
|
||||||
|
st, _ = req("GET", '/schedule?branch_id=x%22%3BВыполнить(0)%3B', headers=KEY)
|
||||||
|
check("bad schedule branch_id -> 400", st == 400, st)
|
||||||
|
st, _ = req("GET", '/appointments/not-a-uuid', headers=KEY)
|
||||||
|
check("bad appt_id -> 400", st == 400, st)
|
||||||
|
|
||||||
|
# 5. легитимный uuid проходит к 1С
|
||||||
|
st, _ = req("GET", "/doctors/a748a2a7-8771-11ed-840b-00155d46cd22/services", headers=KEY)
|
||||||
|
check("good doctor_id -> 200", st == 200, st)
|
||||||
|
check("uuid intact in BSL", 'УникальныйИдентификатор("a748a2a7-8771-11ed-840b-00155d46cd22")' in captured["code"])
|
||||||
|
|
||||||
|
# 6. POST /appointments: инъекция в имени/комментарии экранируется
|
||||||
|
evil = 'Ваня"; Результат = "PWNED"; x := "'
|
||||||
|
st, body = req("POST", "/appointments", {
|
||||||
|
"doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22",
|
||||||
|
"branch_id": "f07031a2-21a6-11f0-8712-00155d46cd22",
|
||||||
|
"patient_name": evil,
|
||||||
|
"patient_phone": "+7 (999) 123-45-67\" INJECT",
|
||||||
|
"comment": 'comment"; Док.Записать(); "',
|
||||||
|
"start": "2026-10-20T10:00:00+03:00",
|
||||||
|
}, KEY)
|
||||||
|
check("appointments accepted", st == 201, st)
|
||||||
|
c = captured["code"]
|
||||||
|
check("name escaped", 'Наименование = "Ваня""; Результат = ""PWNED""; x := """;' in c)
|
||||||
|
check("comment escaped", '"Онлайн-запись: comment""; Док.Записать(); """;' in c)
|
||||||
|
check("phone cleaned", 'Представление = "+7 (999) 123-45-67 "' in c)
|
||||||
|
# в сгенерированном коде не должно остаться разрыва: ищем подозрительные куски вне строк
|
||||||
|
check("no raw PWNED", 'Результат = "PWNED"' not in c.replace('""PWNED""', ""))
|
||||||
|
|
||||||
|
# 7. невалидные поля -> 400
|
||||||
|
st, _ = req("POST", "/appointments", {"doctor_id": 'x";', "start": "2026-10-20T10:00:00"}, KEY)
|
||||||
|
check("bad doctor_id POST -> 400", st == 400, st)
|
||||||
|
st, _ = req("POST", "/appointments", {"doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22",
|
||||||
|
"start": 'x";Выполнить(0);"'}, KEY)
|
||||||
|
check("bad start -> 400", st == 400, st)
|
||||||
|
|
||||||
|
# 8. healthz без ключа
|
||||||
|
st, _ = req("GET", "/healthz")
|
||||||
|
check("healthz open", st == 200, st)
|
||||||
|
|
||||||
|
srv.shutdown()
|
||||||
|
print()
|
||||||
|
print("FAILURES:", fails if fails else "none")
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
111
bridge/test_bridge_extra.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""Additional isolated regressions; never connects to a real 1C or database."""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from http.server import HTTPServer
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
sys.modules["requests"] = types.ModuleType("requests")
|
||||||
|
sys.modules["psycopg2"] = types.ModuleType("psycopg2")
|
||||||
|
os.environ.update(ONEC_MCP_TOKEN="test-only", BRIDGE_API_KEY="test-only")
|
||||||
|
spec = importlib.util.spec_from_file_location("candidate_bridge", os.environ["BRIDGE_TEST_MODULE"])
|
||||||
|
bridge = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(bridge)
|
||||||
|
bridge.call_1c = Mock(return_value=({}, None))
|
||||||
|
server = HTTPServer(("127.0.0.1", 0), bridge.BridgeHandler)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
class ExtraSecurityTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
bridge.call_1c.reset_mock()
|
||||||
|
|
||||||
|
def post(self, body, path="/appointments", headers=None, method=None):
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{server.server_port}" + path,
|
||||||
|
data=json.dumps(body).encode(),
|
||||||
|
headers={"X-Api-Key": "test-only"} if headers is None else headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=5) as response:
|
||||||
|
return response.status
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
return error.code
|
||||||
|
|
||||||
|
def test_uuid_rejects_trailing_newline(self):
|
||||||
|
self.assertFalse(bridge.is_uuid("a748a2a7-8771-11ed-840b-00155d46cd22\n"))
|
||||||
|
|
||||||
|
def test_uuid_rejects_bsl_suffix(self):
|
||||||
|
self.assertFalse(bridge.is_uuid('a748a2a7-8771-11ed-840b-00155d46cd22";'))
|
||||||
|
|
||||||
|
def test_quote_date_separator_cannot_reach_bsl(self):
|
||||||
|
self.assertEqual(self.post({
|
||||||
|
"doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22",
|
||||||
|
"start": '2026-10-20"10:00:00+03:00',
|
||||||
|
}), 201)
|
||||||
|
code = bridge.call_1c.call_args.args[0]
|
||||||
|
self.assertIn('РазобратьISO8601("2026-10-20T10:00:00+03:00")', code)
|
||||||
|
self.assertNotIn('2026-10-20"10:', code)
|
||||||
|
|
||||||
|
def test_oversize_rejected_before_1c(self):
|
||||||
|
self.assertEqual(self.post({"comment": "x" * (64 * 1024)}), 413)
|
||||||
|
bridge.call_1c.assert_not_called()
|
||||||
|
|
||||||
|
def test_controls_removed_and_quotes_doubled(self):
|
||||||
|
self.assertEqual(bridge.bsl_text('a\r\n"b\x00'), 'a""b')
|
||||||
|
|
||||||
|
def test_nested_patient_text_escaped(self):
|
||||||
|
self.assertEqual(self.post({
|
||||||
|
"doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22",
|
||||||
|
"start": "2026-10-20T10:00:00+03:00",
|
||||||
|
"patient": {"name": 'Test"name', "phone": '+7"999', "comment": 'Test"comment'},
|
||||||
|
}), 201)
|
||||||
|
code = bridge.call_1c.call_args.args[0]
|
||||||
|
self.assertIn('Наименование = "Test""name";', code)
|
||||||
|
self.assertIn('Представление = "+7999";', code)
|
||||||
|
self.assertIn('"Онлайн-запись: Test""comment";', code)
|
||||||
|
|
||||||
|
def test_non_object_json_rejected(self):
|
||||||
|
self.assertEqual(self.post([]), 400)
|
||||||
|
bridge.call_1c.assert_not_called()
|
||||||
|
|
||||||
|
def test_non_ascii_bad_key_rejected(self):
|
||||||
|
self.assertEqual(self.post({}, headers={"X-Api-Key": "é"}), 401)
|
||||||
|
bridge.call_1c.assert_not_called()
|
||||||
|
|
||||||
|
def test_bitrix_link_preserved_and_escaped(self):
|
||||||
|
self.assertEqual(self.post({"appointment_id": 'id"value', "bitrix_lead_id": 'lead"value'}, "/appointments/link-bitrix"), 200)
|
||||||
|
code = bridge.call_1c.call_args.args[0]
|
||||||
|
self.assertIn('БитриксИД = "lead""value";', code)
|
||||||
|
self.assertIn('УникальныйИдентификатор("id""value")', code)
|
||||||
|
|
||||||
|
def test_crm_link_preserved_and_escaped(self):
|
||||||
|
self.assertEqual(self.post({"appointment_id": "00001234", "crm_deal_id": 'deal"value'}, "/appointments/link-crm"), 200)
|
||||||
|
code = bridge.call_1c.call_args.args[0]
|
||||||
|
self.assertIn('[CRM: deal""value]', code)
|
||||||
|
self.assertIn('НайтиПоНомеру("00001234",', code)
|
||||||
|
|
||||||
|
def test_delete_requires_auth_before_1c(self):
|
||||||
|
self.assertEqual(self.post({}, "/appointments/test", headers={}, method="DELETE"), 401)
|
||||||
|
bridge.call_1c.assert_not_called()
|
||||||
|
|
||||||
|
def test_delete_preserved_and_escaped(self):
|
||||||
|
self.assertEqual(self.post({}, "/appointments/test%22value", method="DELETE"), 200)
|
||||||
|
code = bridge.call_1c.call_args.args[0]
|
||||||
|
self.assertIn('УникальныйИдентификатор("test""value")', code)
|
||||||
|
self.assertIn('ПометкаУдаления = Истина;', code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
77
deploy/SECURITY-20261020.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# 1C bridge security rollout — 2026-10-20
|
||||||
|
|
||||||
|
Applied to the bridge only. API and PostgreSQL must not be rebuilt or restarted.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
- BSL string quote escaping and control-character removal; phone filtering.
|
||||||
|
- UUID full-match validation, ISO date canonicalization and escaping.
|
||||||
|
- X-Api-Key/Bearer authorization using the existing ONEC_API_KEY.
|
||||||
|
- Request body limit of 64 KiB; MCP/DB secrets supplied through environment.
|
||||||
|
- Compose bridge: UID 10001, fixed IP 172.20.0.50, read-only root filesystem,
|
||||||
|
cap_drop ALL, no-new-privileges, tmpfs, no published host port.
|
||||||
|
- Existing dana-medical_default network is external to Compose: prevents
|
||||||
|
recreation of the live API/DB network. Existing subnet: 172.20.0.0/16.
|
||||||
|
- Runtime Python file owned by UID10001 regardless of build-context permissions.
|
||||||
|
- Existing Bitrix/CRM linking, appointment cancellation and response fields are
|
||||||
|
preserved from the original running source. The package's full replacement
|
||||||
|
file predates these features; only its targeted patch was applied to the base.
|
||||||
|
- Legacy linking/cancellation support both UUID and document-number lookup;
|
||||||
|
their BSL literals are escaped without removing that existing compatibility.
|
||||||
|
- 19 supplied smoke assertions and 12 additional isolated security regressions.
|
||||||
|
|
||||||
|
## Host firewall: prepared, not installed
|
||||||
|
|
||||||
|
Neither available SSH account (kadir or mikail) has sudo permission. Root SSH
|
||||||
|
with the available keys is rejected. Therefore no host iptables rules or
|
||||||
|
systemd units have been installed by this deployment.
|
||||||
|
|
||||||
|
From the provider's root console, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh /home/mikail/dana-medical/deploy/install-1c-egress.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then verify:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 /home/mikail/dana-medical/deploy/verify-1c-egress.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The unit includes PartOf=docker.service and is wanted by docker.service as well
|
||||||
|
as multi-user.target, to reapply the rules on Docker restarts and host reboots.
|
||||||
|
No actual Docker restart or host reboot was performed for testing.
|
||||||
|
|
||||||
|
## Upstream blocker
|
||||||
|
|
||||||
|
During post-deployment checks, 10.240.70.70:6003 refused TCP connections.
|
||||||
|
Authenticated /branches therefore returns 502 until the 1C MCP listener is
|
||||||
|
available. A complete rollout requires /branches=200, a successful 1C sync cycle,
|
||||||
|
and the firewall probes above. Security checks that fail before 1C are isolated
|
||||||
|
from this upstream availability issue. No live test appointments were created.
|
||||||
|
|
||||||
|
API logs before bridge replacement also contain connection-refused sync errors.
|
||||||
|
Direct probes from the VPS host and API container confirm ECONNREFUSED (111).
|
||||||
|
|
||||||
|
## Public domain check
|
||||||
|
|
||||||
|
At verification, danamedical.ru resolved to 92.53.96.37 (Timeweb/Bitrix), where
|
||||||
|
/api/v1/health returned 404. The DANA service on this VPS returned 200 both
|
||||||
|
directly at 172.17.0.1:8082/api/v1/health and over Caddy HTTPS with danamedical.ru
|
||||||
|
Host/SNI explicitly resolved to 127.0.0.1. DNS was not changed by this work.
|
||||||
|
|
||||||
|
## Rollback material
|
||||||
|
|
||||||
|
Private backup: /home/mikail/dana-security-backup-20261020-_gv_ic_u
|
||||||
|
|
||||||
|
Previous runtime snapshot image: dana-1c-bridge:before-security-20261020
|
||||||
|
|
||||||
|
The old image had already been removed from Docker before this work, so the
|
||||||
|
running root filesystem was exported and imported as a rollback image. Its
|
||||||
|
Python runtime and dependencies were checked without network access.
|
||||||
|
The backup contains original source, .env, the removed waitcall.go.root-backup,
|
||||||
|
and the old container configuration. Do not publish or commit the backup.
|
||||||
|
|
||||||
|
No password/token rotation, API/DB hardening, Git history rewrite or unrelated
|
||||||
|
deployment was performed. The original unrestricted bridge is intentionally
|
||||||
|
not left running alongside the new bridge.
|
||||||
17
deploy/dana-1c-egress.service
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# /etc/systemd/system/dana-1c-egress.service
|
||||||
|
# Сохраняет правила egress-изоляции к 1C после перезагрузки/рестарта docker.
|
||||||
|
[Unit]
|
||||||
|
Description=Restrict container egress to 1C MCP (dana-1c-bridge only)
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
PartOf=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# docker должен успеть создать цепочку DOCKER-USER
|
||||||
|
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do iptables -L DOCKER-USER -n >/dev/null 2>&1 && exit 0; sleep 2; done; exit 1'
|
||||||
|
ExecStart=/home/mikail/dana-medical/deploy/firewall-1c-egress.sh
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target docker.service
|
||||||
28
deploy/firewall-1c-egress.sh
Executable file
@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# firewall-1c-egress.sh — разрешить доступ к 1C MCP (10.240.70.70:6003)
|
||||||
|
# только контейнеру dana-1c-bridge (172.20.0.50), остальным контейнерам — DROP.
|
||||||
|
#
|
||||||
|
# Запускать на хосте от root (или через sudo). Идемпотентен.
|
||||||
|
# Для сохранения после перезагрузки см. systemd unit dana-1c-egress.service.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BRIDGE_IP="172.20.0.50"
|
||||||
|
ONEC_HOST="10.240.70.70"
|
||||||
|
ONEC_PORT="6003"
|
||||||
|
|
||||||
|
if ! iptables -L DOCKER-USER -n >/dev/null 2>&1; then
|
||||||
|
echo "DOCKER-USER chain not found (docker not running?)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Порядок важен: ACCEPT первым, затем DROP для всех остальных источников.
|
||||||
|
iptables -C DOCKER-USER -s "$BRIDGE_IP" -d "$ONEC_HOST" -p tcp --dport "$ONEC_PORT" -j ACCEPT 2>/dev/null \
|
||||||
|
|| iptables -I DOCKER-USER 1 -s "$BRIDGE_IP" -d "$ONEC_HOST" -p tcp --dport "$ONEC_PORT" -j ACCEPT
|
||||||
|
|
||||||
|
# Блокируем весь остальной трафик контейнеров к хосту 1С (любые порты).
|
||||||
|
iptables -C DOCKER-USER -d "$ONEC_HOST" -j DROP 2>/dev/null \
|
||||||
|
|| iptables -I DOCKER-USER 2 -d "$ONEC_HOST" -j DROP
|
||||||
|
|
||||||
|
echo "=== DOCKER-USER ==="
|
||||||
|
iptables -L DOCKER-USER -n --line-numbers
|
||||||
|
echo "OK: только $BRIDGE_IP (dana-1c-bridge) может ходить на $ONEC_HOST"
|
||||||
17
deploy/install-1c-egress.sh
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run as root on the host. Installs only the reviewed 1C egress policy.
|
||||||
|
set -eu
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "Run this script as root from the VPS provider console." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DEPLOY=/home/mikail/dana-medical/deploy
|
||||||
|
/bin/sh "$DEPLOY/firewall-1c-egress.sh"
|
||||||
|
install -o root -g root -m 0644 "$DEPLOY/dana-1c-egress.service" /etc/systemd/system/dana-1c-egress.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now dana-1c-egress.service
|
||||||
|
systemctl is-enabled dana-1c-egress.service
|
||||||
|
systemctl is-active dana-1c-egress.service
|
||||||
|
iptables -C DOCKER-USER -s 172.20.0.50 -d 10.240.70.70 -p tcp --dport 6003 -j ACCEPT
|
||||||
|
iptables -C DOCKER-USER -d 10.240.70.70 -j DROP
|
||||||
|
echo '1C firewall and systemd persistence installed. Run the egress probes next.'
|
||||||
28
deploy/verify-1c-egress.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""Run after root installs firewall. No keys or patient data are accessed."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
blocked_probe = """
|
||||||
|
import requests,sys
|
||||||
|
try:
|
||||||
|
response=requests.get('http://10.240.70.70:6003/mcp',timeout=4)
|
||||||
|
print('FAIL: direct 1C access returned HTTP',response.status_code)
|
||||||
|
sys.exit(1)
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
print('PASS: direct 1C access timed out')
|
||||||
|
except requests.exceptions.RequestException as error:
|
||||||
|
print('INCONCLUSIVE: upstream unreachable:',type(error).__name__)
|
||||||
|
sys.exit(2)
|
||||||
|
"""
|
||||||
|
failures = 0
|
||||||
|
for name in ("dana-medical-api-1", "bright-app"):
|
||||||
|
print("Checking container network:", name, flush=True)
|
||||||
|
result = subprocess.run([
|
||||||
|
"docker", "run", "--rm", "--network", "container:" + name,
|
||||||
|
"--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true",
|
||||||
|
"--entrypoint", "python", "dana-1c-bridge:security-20261020", "-c", blocked_probe,
|
||||||
|
])
|
||||||
|
failures += result.returncode != 0
|
||||||
|
allowed_probe = "import requests,os; r=requests.get(os.environ['ONEC_MCP_URL'],timeout=4); print('Bridge reaches 1C, HTTP:',r.status_code)"
|
||||||
|
result = subprocess.run(["docker", "exec", "dana-1c-bridge", "python", "-c", allowed_probe])
|
||||||
|
failures += result.returncode != 0
|
||||||
|
raise SystemExit(1 if failures else 0)
|
||||||
@ -48,6 +48,55 @@ services:
|
|||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
# Мост к 1C MCP. Единственный контейнер, которому разрешён egress на
|
||||||
|
# 10.240.70.70:6003 (правило DOCKER-USER, см. deploy/firewall-1c-egress.sh).
|
||||||
|
# IP закреплён: правило фаервола привязано к адресу, а не к имени.
|
||||||
|
bridge:
|
||||||
|
build:
|
||||||
|
context: ./bridge
|
||||||
|
image: dana-1c-bridge:latest
|
||||||
|
container_name: dana-1c-bridge
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
ONEC_MCP_URL: ${ONEC_MCP_URL:-http://10.240.70.70:6003/api/execute_code}
|
||||||
|
ONEC_MCP_TOKEN: ${ONEC_MCP_TOKEN:?ONEC_MCP_TOKEN is required}
|
||||||
|
# Go-бэкенд авторизуется перед мостом тем же ключом, что шлёт в ONEC_API_URL.
|
||||||
|
BRIDGE_API_KEY: ${ONEC_API_KEY:?ONEC_API_KEY is required}
|
||||||
|
PG_HOST: db
|
||||||
|
PG_PORT: "5432"
|
||||||
|
PG_USER: dana
|
||||||
|
PG_DB: dana
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
ipv4_address: 172.20.0.50
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8088/healthz', timeout=3)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=16m
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
media:
|
media:
|
||||||
|
|
||||||
|
# Существующая сеть уже проверена: 172.20.0.0/16. external запрещает Compose
|
||||||
|
# пересоздавать её при добавлении моста и затрагивать работающие api/db.
|
||||||
|
# При развёртывании на новом хосте сначала создайте эту сеть с нужной подсетью.
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
external: true
|
||||||
|
name: dana-medical_default
|
||||||
|
|||||||
@ -1,327 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WaitCallSession struct {
|
|
||||||
ID string `json:"session_id"`
|
|
||||||
Phone string `json:"phone"` // e.g. "79896657520"
|
|
||||||
TargetPhone string `json:"target_phone"` // e.g. "79019605980"
|
|
||||||
DisplayPhone string `json:"display_phone"` // e.g. "+7 (901) 960-59-80"
|
|
||||||
Verified bool `json:"verified"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type waitCallManager struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
sessions map[string]*WaitCallSession
|
|
||||||
phoneSessions map[string][]*WaitCallSession
|
|
||||||
lastAnswerPoll time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var wcm = &waitCallManager{
|
|
||||||
sessions: make(map[string]*WaitCallSession),
|
|
||||||
phoneSessions: make(map[string][]*WaitCallSession),
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *waitCallManager) set(s *WaitCallSession) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.sessions[s.ID] = s
|
|
||||||
m.phoneSessions[s.Phone] = append(m.phoneSessions[s.Phone], s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *waitCallManager) get(id string) *WaitCallSession {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
s, ok := m.sessions[id]
|
|
||||||
if !ok || time.Now().After(s.ExpiresAt) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *waitCallManager) isVerified(id, phone string) bool {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
s, ok := m.sessions[id]
|
|
||||||
if !ok || time.Now().After(s.ExpiresAt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
norm := normalizeRuPhone(phone)
|
|
||||||
return s.Verified && (s.Phone == norm || s.Phone == phone)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *waitCallManager) markVerified(phone string) int {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
norm := normalizeRuPhone(phone)
|
|
||||||
list, ok := m.phoneSessions[norm]
|
|
||||||
if !ok {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
count := 0
|
|
||||||
now := time.Now()
|
|
||||||
for _, s := range list {
|
|
||||||
if now.Before(s.ExpiresAt) {
|
|
||||||
s.Verified = true
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *waitCallManager) checkSMSCFallback(login, password, targetPhone string) {
|
|
||||||
if login == "" || password == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m.mu.Lock()
|
|
||||||
if time.Since(m.lastAnswerPoll) < 3*time.Second {
|
|
||||||
m.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m.lastAnswerPoll = time.Now()
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf(
|
|
||||||
"https://smsc.ru/sys/get.php?get_answers=1&hour=1&login=%s&psw=%s&fmt=3",
|
|
||||||
url.QueryEscape(login),
|
|
||||||
url.QueryEscape(password),
|
|
||||||
)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var answers []struct {
|
|
||||||
ID any `json:"id"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&answers); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, a := range answers {
|
|
||||||
txt := a.Message
|
|
||||||
if txt == "" {
|
|
||||||
txt = a.Text
|
|
||||||
}
|
|
||||||
if strings.Contains(txt, "[waitcall]") || strings.Contains(txt, "waitcall") || txt == "" {
|
|
||||||
norm := normalizeRuPhone(a.Phone)
|
|
||||||
if norm != "" {
|
|
||||||
m.markVerified(norm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatRuPhone formats Russian phone number to "+7 (XXX) XXX-XX-XX"
|
|
||||||
func formatRuPhone(p string) string {
|
|
||||||
d := onlyDigits(p)
|
|
||||||
if len(d) == 11 && (d[0] == '7' || d[0] == '8') {
|
|
||||||
return fmt.Sprintf("+7 (%s) %s-%s-%s", d[1:4], d[4:7], d[7:9], d[9:11])
|
|
||||||
}
|
|
||||||
if len(d) == 10 {
|
|
||||||
return fmt.Sprintf("+7 (%s) %s-%s-%s", d[0:3], d[3:6], d[6:8], d[8:10])
|
|
||||||
}
|
|
||||||
return "+" + d
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeRuPhone converts various formats to "7XXXXXXXXXX"
|
|
||||||
func normalizeRuPhone(p string) string {
|
|
||||||
d := onlyDigits(p)
|
|
||||||
if len(d) == 10 {
|
|
||||||
return "7" + d
|
|
||||||
}
|
|
||||||
if len(d) == 11 && (d[0] == '8' || d[0] == '7') {
|
|
||||||
return "7" + d[1:]
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *API) waitCallInit(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if a.tooMany(w, "wc_init:"+clientIP(r), 10, 5*time.Minute) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var in struct {
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil {
|
|
||||||
fail(w, http.StatusBadRequest, "bad_json", "Некорректный запрос")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
phone := normalizeRuPhone(in.Phone)
|
|
||||||
if len(phone) != 11 || phone[0] != '7' {
|
|
||||||
fail(w, http.StatusBadRequest, "invalid_phone", "Укажите корректный российский номер телефона")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if a.tooMany(w, "wc_phone:"+phone, 4, 5*time.Minute) {
|
|
||||||
fail(w, http.StatusTooManyRequests, "too_many_requests", "Слишком много попыток для этого номера. Подождите пару минут.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf(
|
|
||||||
"https://smsc.ru/sys/wait_call.php?login=%s&psw=%s&phone=%s&fmt=3",
|
|
||||||
url.QueryEscape(a.smscLogin),
|
|
||||||
url.QueryEscape(a.smscPassword),
|
|
||||||
url.QueryEscape(phone),
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
handleErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("smsc wait_call request failed", "err", err)
|
|
||||||
fail(w, http.StatusBadGateway, "smsc_error", "Сервис проверки номеров временно недоступен")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var smscResp struct {
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
AllPhones []string `json:"all_phones"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
ErrorCode int `json:"error_code"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&smscResp); err != nil {
|
|
||||||
slog.Error("smsc wait_call decode failed", "err", err)
|
|
||||||
fail(w, http.StatusBadGateway, "smsc_error", "Ошибка ответа сервиса проверки")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if smscResp.ErrorCode != 0 {
|
|
||||||
slog.Warn("smsc wait_call error", "code", smscResp.ErrorCode, "err", smscResp.Error)
|
|
||||||
msg := "Не удалось получить номер для проверки"
|
|
||||||
if smscResp.ErrorCode == 9 {
|
|
||||||
msg = "Слишком много запросов. Пожалуйста, подождите 1 минуту."
|
|
||||||
}
|
|
||||||
fail(w, http.StatusBadRequest, "smsc_error", msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetPhone := smscResp.Phone
|
|
||||||
if targetPhone == "" && len(smscResp.AllPhones) > 0 {
|
|
||||||
targetPhone = smscResp.AllPhones[0]
|
|
||||||
}
|
|
||||||
if targetPhone == "" {
|
|
||||||
fail(w, http.StatusBadGateway, "smsc_error", "Не получен номер для звонка")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
b := make([]byte, 16)
|
|
||||||
_, _ = rand.Read(b)
|
|
||||||
sessionID := hex.EncodeToString(b)
|
|
||||||
|
|
||||||
sess := &WaitCallSession{
|
|
||||||
ID: sessionID,
|
|
||||||
Phone: phone,
|
|
||||||
TargetPhone: targetPhone,
|
|
||||||
DisplayPhone: formatRuPhone(targetPhone),
|
|
||||||
Verified: false,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
|
||||||
}
|
|
||||||
wcm.set(sess)
|
|
||||||
|
|
||||||
ok(w, map[string]any{
|
|
||||||
"session_id": sess.ID,
|
|
||||||
"target_phone": sess.TargetPhone,
|
|
||||||
"display_phone": sess.DisplayPhone,
|
|
||||||
"expires_in": 600,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *API) waitCallStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
sessionID := r.URL.Query().Get("session_id")
|
|
||||||
if sessionID == "" {
|
|
||||||
fail(w, http.StatusBadRequest, "missing_session", "Укажите session_id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sess := wcm.get(sessionID)
|
|
||||||
if sess == nil {
|
|
||||||
fail(w, http.StatusNotFound, "not_found", "Сессия не найдена или истекла")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !sess.Verified {
|
|
||||||
wcm.checkSMSCFallback(a.smscLogin, a.smscPassword, sess.Phone)
|
|
||||||
}
|
|
||||||
|
|
||||||
ok(w, map[string]any{
|
|
||||||
"session_id": sess.ID,
|
|
||||||
"phone": sess.Phone,
|
|
||||||
"target_phone": sess.TargetPhone,
|
|
||||||
"display_phone": sess.DisplayPhone,
|
|
||||||
"verified": sess.Verified,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *API) smscCallback(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = r.ParseForm()
|
|
||||||
phone := r.FormValue("phone")
|
|
||||||
if phone == "" {
|
|
||||||
phone = r.URL.Query().Get("phone")
|
|
||||||
}
|
|
||||||
waitcall := r.FormValue("waitcall")
|
|
||||||
if waitcall == "" {
|
|
||||||
waitcall = r.URL.Query().Get("waitcall")
|
|
||||||
}
|
|
||||||
mes := r.FormValue("mes")
|
|
||||||
|
|
||||||
if phone == "" && r.Body != nil {
|
|
||||||
var j struct {
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
Waitcall any `json:"waitcall"`
|
|
||||||
Mes string `json:"mes"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&j)
|
|
||||||
if j.Phone != "" {
|
|
||||||
phone = j.Phone
|
|
||||||
}
|
|
||||||
if j.Waitcall != nil {
|
|
||||||
waitcall = fmt.Sprint(j.Waitcall)
|
|
||||||
}
|
|
||||||
if j.Mes != "" {
|
|
||||||
mes = j.Mes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("smsc: callback received", "phone", phone, "waitcall", waitcall, "mes", mes)
|
|
||||||
|
|
||||||
norm := normalizeRuPhone(phone)
|
|
||||||
if norm != "" {
|
|
||||||
verifiedCount := wcm.markVerified(norm)
|
|
||||||
slog.Info("smsc: sessions marked verified", "phone", norm, "count", verifiedCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("OK"))
|
|
||||||
}
|
|
||||||
BIN
web/src/assets/checkups/hero-detskiy-mini.webp
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
web/src/assets/checkups/hero-mne-god.webp
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
web/src/assets/checkups/hero-pervoklashka.webp
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
web/src/assets/checkups/hero-ya-podrostok.webp
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
web/src/assets/checkups/hero-ya-rodilsya.webp
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
web/src/assets/checkups/hero-ya-v-sad.webp
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
web/src/assets/checkups/hero-zdorovyy-zhkt.webp
Normal file
|
After Width: | Height: | Size: 152 KiB |
59
web/src/components/FloatingBookingButton.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
/* One fixed stack keeps booking and cookie consent from covering each other. */
|
||||||
|
.booking-dock {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
padding-right: max(20px, env(safe-area-inset-right));
|
||||||
|
padding-bottom: max(20px, env(safe-area-inset-bottom));
|
||||||
|
padding-left: max(20px, env(safe-area-inset-left));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-booking {
|
||||||
|
min-height: 54px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
padding: 0 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
box-shadow: 0 6px 22px rgba(242, 97, 84, 0.3);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-booking svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-booking:focus-visible {
|
||||||
|
outline: 3px solid var(--navy);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-dock > .cookie-banner {
|
||||||
|
position: static;
|
||||||
|
align-self: center;
|
||||||
|
width: min(900px, 100%);
|
||||||
|
transform: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.booking-dock {
|
||||||
|
padding: 12px;
|
||||||
|
padding-right: max(12px, env(safe-area-inset-right));
|
||||||
|
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||||
|
padding-left: max(12px, env(safe-area-inset-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-booking {
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
web/src/components/FloatingBookingButton.jsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { useLeadModal } from '../context/LeadModalContext.jsx';
|
||||||
|
import './FloatingBookingButton.css';
|
||||||
|
|
||||||
|
export default function FloatingBookingButton() {
|
||||||
|
const { open } = useLeadModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--coral floating-booking"
|
||||||
|
onClick={() => open({ form: 'appointment' })}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="5" width="18" height="16" rx="3" />
|
||||||
|
<path d="M16 3v4M8 3v4M3 11h18M9 16h6M12 13v6" />
|
||||||
|
</svg>
|
||||||
|
Записаться
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { Outlet, useLocation } from 'react-router-dom';
|
|||||||
import Header from './Header.jsx';
|
import Header from './Header.jsx';
|
||||||
import Footer from './Footer.jsx';
|
import Footer from './Footer.jsx';
|
||||||
import CookieBanner from './CookieBanner.jsx';
|
import CookieBanner from './CookieBanner.jsx';
|
||||||
|
import FloatingBookingButton from './FloatingBookingButton.jsx';
|
||||||
import { SiteDataProvider } from '../context/SiteData.jsx';
|
import { SiteDataProvider } from '../context/SiteData.jsx';
|
||||||
import { LeadModalProvider } from '../context/LeadModalContext.jsx';
|
import { LeadModalProvider } from '../context/LeadModalContext.jsx';
|
||||||
import { getConsent, hit, startMetrika } from '../lib/metrika.js';
|
import { getConsent, hit, startMetrika } from '../lib/metrika.js';
|
||||||
@ -33,8 +34,11 @@ export default function Layout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<div className="booking-dock">
|
||||||
|
<FloatingBookingButton />
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</LeadModalProvider>
|
</LeadModalProvider>
|
||||||
</SiteDataProvider>
|
</SiteDataProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
165
web/src/components/SpecialtySelect.css
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
.specialty-select {
|
||||||
|
position: relative;
|
||||||
|
width: 260px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--chip-border);
|
||||||
|
border-radius: var(--r-14);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--navy);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.45;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__trigger:hover {
|
||||||
|
border-color: var(--navy);
|
||||||
|
background: var(--field);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select--active .specialty-select__trigger {
|
||||||
|
border-color: var(--navy);
|
||||||
|
background: var(--navy);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select--active .specialty-select__trigger:hover {
|
||||||
|
background: var(--navy-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__trigger img {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--coral);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 35;
|
||||||
|
width: 340px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--r-20);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 12px 40px rgba(44, 73, 95, 0.15), 0 2px 6px rgba(44, 73, 95, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__heading {
|
||||||
|
margin: 0 6px 6px;
|
||||||
|
padding: 10px 6px 12px;
|
||||||
|
border-bottom: 1px solid var(--soft-2);
|
||||||
|
color: var(--text-muted-2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__list {
|
||||||
|
position: relative;
|
||||||
|
max-height: min(330px, 55dvh);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 3px 0 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
list-style: none;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--chip-border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--r-12);
|
||||||
|
color: var(--navy);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.45;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__option + .specialty-select__option {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__option--highlighted {
|
||||||
|
background: var(--field);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__option[aria-selected='true'] {
|
||||||
|
background: #fff0ed;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__count {
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--r-8);
|
||||||
|
background: var(--soft-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[aria-selected='true'] > .specialty-select__check {
|
||||||
|
background: var(--coral);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.specialty-select,
|
||||||
|
.specialty-select__panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-select__option {
|
||||||
|
min-height: 46px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.specialty-select__trigger,
|
||||||
|
.specialty-select__trigger img {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
149
web/src/components/SpecialtySelect.jsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useId, useRef, useState } from 'react';
|
||||||
|
import chevronWhite from '../assets/figma/icon-chevron-white.svg';
|
||||||
|
import chevronDark from '../assets/figma/icon-chevron-dark.svg';
|
||||||
|
import './SpecialtySelect.css';
|
||||||
|
|
||||||
|
export default function SpecialtySelect({ value, onChange, options, label }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [highlighted, setHighlighted] = useState(0);
|
||||||
|
const root = useRef(null);
|
||||||
|
const trigger = useRef(null);
|
||||||
|
const list = useRef(null);
|
||||||
|
const typed = useRef({ text: '', time: 0 });
|
||||||
|
const id = useId();
|
||||||
|
const selected = Math.max(0, options.findIndex((option) => option.value === value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expanded) return undefined;
|
||||||
|
const closeOutside = (event) => {
|
||||||
|
if (!root.current?.contains(event.target)) setExpanded(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', closeOutside);
|
||||||
|
document.addEventListener('focusin', closeOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', closeOutside);
|
||||||
|
document.removeEventListener('focusin', closeOutside);
|
||||||
|
};
|
||||||
|
}, [expanded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expanded) return;
|
||||||
|
const option = list.current?.children[highlighted];
|
||||||
|
if (option) {
|
||||||
|
const top = option.offsetTop;
|
||||||
|
const bottom = top + option.offsetHeight;
|
||||||
|
const scroll = list.current.scrollTop;
|
||||||
|
if (top < scroll) list.current.scrollTop = top;
|
||||||
|
else if (bottom > scroll + list.current.clientHeight) list.current.scrollTop = bottom - list.current.clientHeight;
|
||||||
|
}
|
||||||
|
}, [expanded, highlighted]);
|
||||||
|
|
||||||
|
// A route change (including Back/Forward) must never leave an old menu open.
|
||||||
|
useEffect(() => setExpanded(false), [value]);
|
||||||
|
|
||||||
|
const show = (index = selected) => {
|
||||||
|
setHighlighted(index);
|
||||||
|
setExpanded(true);
|
||||||
|
typed.current = { text: '', time: 0 };
|
||||||
|
};
|
||||||
|
const choose = (index) => {
|
||||||
|
const option = options[index];
|
||||||
|
if (!option) return;
|
||||||
|
setExpanded(false);
|
||||||
|
trigger.current?.focus();
|
||||||
|
if (option.value !== value) onChange(option.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (expanded) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
setExpanded(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!expanded) show();
|
||||||
|
else setHighlighted((index) => (index + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Home' || event.key === 'End') {
|
||||||
|
event.preventDefault();
|
||||||
|
show(event.key === 'Home' ? 0 : options.length - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (expanded) choose(highlighted);
|
||||||
|
else show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
const now = Date.now();
|
||||||
|
const letter = event.key.toLocaleLowerCase('ru');
|
||||||
|
const prefix = now - typed.current.time < 700 ? typed.current.text + letter : letter;
|
||||||
|
typed.current = { text: prefix, time: now };
|
||||||
|
const start = expanded ? highlighted : selected;
|
||||||
|
const match = options.findIndex((_, offset) => {
|
||||||
|
const index = (start + offset + 1) % options.length;
|
||||||
|
return options[index].label.toLocaleLowerCase('ru').startsWith(prefix);
|
||||||
|
});
|
||||||
|
setExpanded(true);
|
||||||
|
if (match !== -1) setHighlighted((start + match + 1) % options.length);
|
||||||
|
else if (!expanded) setHighlighted(selected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`specialty-select${value ? ' specialty-select--active' : ''}`} ref={root}>
|
||||||
|
<button
|
||||||
|
ref={trigger}
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
className="specialty-select__trigger"
|
||||||
|
aria-label={`${label}: ${options[selected]?.label || ''}`}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-controls={expanded ? `${id}-list` : undefined}
|
||||||
|
aria-activedescendant={expanded ? `${id}-option-${highlighted}` : undefined}
|
||||||
|
onClick={() => expanded ? setExpanded(false) : show()}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<span>{options[selected]?.label}</span>
|
||||||
|
<img className={expanded ? 'specialty-select__chevron--open' : ''} src={value ? chevronWhite : chevronDark} alt="" width="14" height="14" />
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="specialty-select__panel">
|
||||||
|
<p className="specialty-select__heading" aria-hidden="true">Выберите специализацию</p>
|
||||||
|
<ul ref={list} id={`${id}-list`} role="listbox" aria-label={label} className="specialty-select__list">
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<li
|
||||||
|
key={option.value}
|
||||||
|
id={`${id}-option-${index}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
className={`specialty-select__option${index === highlighted ? ' specialty-select__option--highlighted' : ''}`}
|
||||||
|
onPointerMove={() => setHighlighted(index)}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => choose(index)}
|
||||||
|
>
|
||||||
|
<span className="specialty-select__name">{option.label}</span>
|
||||||
|
{option.count != null && <span className="specialty-select__count" aria-hidden="true">{option.count}</span>}
|
||||||
|
<span className="specialty-select__check" aria-hidden="true">
|
||||||
|
{option.value === value && <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="m3 8 3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -57,6 +57,14 @@ import cutTrimester2 from '../assets/checkups/hero-vedenie-beremennosti-2-trim.w
|
|||||||
import cutTrimester3 from '../assets/checkups/hero-vedenie-beremennosti-3-trim.webp';
|
import cutTrimester3 from '../assets/checkups/hero-vedenie-beremennosti-3-trim.webp';
|
||||||
import cutParents from '../assets/checkups/hero-zdorovye-roditeli.webp';
|
import cutParents from '../assets/checkups/hero-zdorovye-roditeli.webp';
|
||||||
import cutPressure from '../assets/checkups/hero-davlenie-v-poryadke.webp';
|
import cutPressure from '../assets/checkups/hero-davlenie-v-poryadke.webp';
|
||||||
|
// Детские программы: оригинальные прозрачные WebP из архива, без изменения карточек.
|
||||||
|
import cutKidsMini from '../assets/checkups/hero-detskiy-mini.webp';
|
||||||
|
import cutKidsGut from '../assets/checkups/hero-zdorovyy-zhkt.webp';
|
||||||
|
import cutKidsYear from '../assets/checkups/hero-mne-god.webp';
|
||||||
|
import cutKidsSchool from '../assets/checkups/hero-pervoklashka.webp';
|
||||||
|
import cutKidsKindergarten from '../assets/checkups/hero-ya-v-sad.webp';
|
||||||
|
import cutKidsTeen from '../assets/checkups/hero-ya-podrostok.webp';
|
||||||
|
import cutKidsNewborn from '../assets/checkups/hero-ya-rodilsya.webp';
|
||||||
|
|
||||||
// Направления: фото для плитки и специальность, врачей которой показываем на странице услуги
|
// Направления: фото для плитки и специальность, врачей которой показываем на странице услуги
|
||||||
// (связей врач ↔ услуга в базе почти нет, поэтому подбираем по специальности).
|
// (связей врач ↔ услуга в базе почти нет, поэтому подбираем по специальности).
|
||||||
@ -105,19 +113,19 @@ const CHECKUP_MEDIA = {
|
|||||||
// серо-синий фон шапки (#7886a9) — давление и родители
|
// серо-синий фон шапки (#7886a9) — давление и родители
|
||||||
'davlenie-v-poryadke': { photo: ckPressure, icon: icoTonometer, cutout: cutPressure, cutoutRatio: 1.374, heroBg: '#7886a9' },
|
'davlenie-v-poryadke': { photo: ckPressure, icon: icoTonometer, cutout: cutPressure, cutoutRatio: 1.374, heroBg: '#7886a9' },
|
||||||
'zdorovye-roditeli': { photo: ckParents, icon: icoFamily, cutout: cutParents, cutoutRatio: 1.202, heroBg: '#7886a9' },
|
'zdorovye-roditeli': { photo: ckParents, icon: icoFamily, cutout: cutParents, cutoutRatio: 1.202, heroBg: '#7886a9' },
|
||||||
'mne-god': { photo: ckYear, icon: icoBaby },
|
'mne-god': { photo: ckYear, icon: icoBaby, cutout: cutKidsYear, cutoutRatio: 0.9587 },
|
||||||
'chek-ap-ya-v-sad': { photo: ckKindergarten, icon: icoPlayground },
|
'chek-ap-ya-v-sad': { photo: ckKindergarten, icon: icoPlayground, cutout: cutKidsKindergarten, cutoutRatio: 0.93 },
|
||||||
pervoklashka: { photo: ckSchool, icon: icoSchool },
|
pervoklashka: { photo: ckSchool, icon: icoSchool, cutout: cutKidsSchool, cutoutRatio: 0.9025 },
|
||||||
'chek-ap-ya-podrostok': { photo: ckTeen, icon: icoTeen },
|
'chek-ap-ya-podrostok': { photo: ckTeen, icon: icoTeen, cutout: cutKidsTeen, cutoutRatio: 0.9886 },
|
||||||
detskiy: { photo: ckMini, icon: icoStethoscope },
|
detskiy: { photo: ckMini, icon: icoStethoscope, cutout: cutKidsMini, cutoutRatio: 0.9587 },
|
||||||
'zdorovyy-zhkt': { photo: ckGut, icon: icoStomach },
|
'zdorovyy-zhkt': { photo: ckGut, icon: icoStomach, cutout: cutKidsGut, cutoutRatio: 0.7147 },
|
||||||
// программ ниже в макете нет — берём близкие по смыслу фото макета
|
// программ ниже в макете нет — берём близкие по смыслу фото макета
|
||||||
'vedenie-beremennosti-1-trimestr': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester1, cutoutRatio: 1.048 },
|
'vedenie-beremennosti-1-trimestr': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester1, cutoutRatio: 1.048 },
|
||||||
'vedenie-beremennosti-2-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester2, cutoutRatio: 1.215 },
|
'vedenie-beremennosti-2-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester2, cutoutRatio: 1.215 },
|
||||||
'vedenie-beremennosti-3-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester3, cutoutRatio: 1.191 },
|
'vedenie-beremennosti-3-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester3, cutoutRatio: 1.191 },
|
||||||
'poslerodovoy-chek-ap-cherez-mesyats': { photo: ckWoman, icon: icoFemale, cutout: cutPostpartum, cutoutRatio: 1.007 },
|
'poslerodovoy-chek-ap-cherez-mesyats': { photo: ckWoman, icon: icoFemale, cutout: cutPostpartum, cutoutRatio: 1.007 },
|
||||||
'zdorovaya-zhenshchina-40': { photo: ckWoman, icon: icoFemale, cutout: cutWoman40, cutoutRatio: 1.054 },
|
'zdorovaya-zhenshchina-40': { photo: ckWoman, icon: icoFemale, cutout: cutWoman40, cutoutRatio: 1.054 },
|
||||||
'chek-ap-ya-rodilsya': { photo: ckYear, icon: icoBaby },
|
'chek-ap-ya-rodilsya': { photo: ckYear, icon: icoBaby, cutout: cutKidsNewborn, cutoutRatio: 0.9444 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function checkupMedia(checkup) {
|
export function checkupMedia(checkup) {
|
||||||
@ -132,7 +140,7 @@ export function checkupMedia(checkup) {
|
|||||||
// вырезанная фигура на фоне шапки, как в макете: справа на всю высоту, без рамки
|
// вырезанная фигура на фоне шапки, как в макете: справа на всю высоту, без рамки
|
||||||
cutout: m.cutout || '',
|
cutout: m.cutout || '',
|
||||||
// свой цвет фона шапки, если в макете он не коралловый
|
// свой цвет фона шапки, если в макете он не коралловый
|
||||||
heroBg: m.heroBg || '',
|
heroBg: m.heroBg || (checkup.audience === 'kids' && m.cutout ? 'var(--orange)' : ''),
|
||||||
// ширина/высота файла фигуры: по ней шапка понимает, поместится ли фигура справа от текста
|
// ширина/высота файла фигуры: по ней шапка понимает, поместится ли фигура справа от текста
|
||||||
cutoutRatio: m.cutoutRatio || 0,
|
cutoutRatio: m.cutoutRatio || 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -516,6 +516,13 @@
|
|||||||
-webkit-mask-image: none;
|
-webkit-mask-image: none;
|
||||||
mask-image: none;
|
mask-image: none;
|
||||||
}
|
}
|
||||||
|
/* Детские портреты с лицом у верхнего края: показываем фигуру целиком,
|
||||||
|
а не увеличиваем её до ширины шапки с обрезкой головы. */
|
||||||
|
.checkup-hero--kids .checkup-hero__image--cutout img {
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: right bottom;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export default function CheckupPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="checkup-page">
|
<div className="checkup-page">
|
||||||
<section
|
<section
|
||||||
className={`checkup-hero${cutout ? ' checkup-hero--cutout' : ''}`}
|
className={`checkup-hero${cutout ? ' checkup-hero--cutout' : ''}${cutout && c.audience === 'kids' ? ' checkup-hero--kids' : ''}`}
|
||||||
style={heroBg ? { '--checkup-hero-bg': heroBg } : undefined}
|
style={heroBg ? { '--checkup-hero-bg': heroBg } : undefined}
|
||||||
>
|
>
|
||||||
<div className={`container checkup-hero__row${cutout ? ' checkup-hero__row--cutout' : ''}`}>
|
<div className={`container checkup-hero__row${cutout ? ' checkup-hero__row--cutout' : ''}`}>
|
||||||
|
|||||||
@ -27,58 +27,7 @@
|
|||||||
.doctors-filters__chips {
|
.doctors-filters__chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
|
||||||
.filter {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
height: 48px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--chip-border);
|
|
||||||
border-radius: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter select {
|
|
||||||
min-width: 230px;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0 38px 0 15px;
|
|
||||||
appearance: none;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
outline: none;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.42;
|
|
||||||
color: var(--ink);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter select option {
|
|
||||||
color: var(--ink);
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter img {
|
|
||||||
position: absolute;
|
|
||||||
right: 15px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter--active {
|
|
||||||
background: var(--navy);
|
|
||||||
border-color: var(--navy);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter--active select {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter:focus-within {
|
|
||||||
outline: 2px solid var(--coral);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-search {
|
.filter-search {
|
||||||
@ -161,30 +110,6 @@
|
|||||||
}
|
}
|
||||||
.doctors-filters__chips {
|
.doctors-filters__chips {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
|
||||||
.filter {
|
|
||||||
height: 37px;
|
|
||||||
border-width: 0.722px;
|
|
||||||
border-color: var(--field-border);
|
|
||||||
border-radius: var(--r-12);
|
|
||||||
}
|
|
||||||
.filter select {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0 30px 0 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 20px;
|
|
||||||
color: var(--navy);
|
|
||||||
}
|
|
||||||
.filter--active select {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.filter img {
|
|
||||||
right: 12px;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
.filter-search {
|
.filter-search {
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
|
|||||||
@ -1,32 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import chevronWhite from '../assets/figma/icon-chevron-white.svg';
|
|
||||||
import chevronDark from '../assets/figma/icon-chevron-dark.svg';
|
|
||||||
import searchIcon from '../assets/figma/icon-search-18.svg';
|
import searchIcon from '../assets/figma/icon-search-18.svg';
|
||||||
import { DoctorRow } from '../components/DoctorCard.jsx';
|
import { DoctorRow } from '../components/DoctorCard.jsx';
|
||||||
|
import SpecialtySelect from '../components/SpecialtySelect.jsx';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { useApi } from '../lib/useApi.js';
|
import { useApi } from '../lib/useApi.js';
|
||||||
import { specialtyPath } from '../lib/links.js';
|
import { specialtyPath } from '../lib/links.js';
|
||||||
import { usePageTitle } from '../lib/usePageTitle.js';
|
import { usePageTitle } from '../lib/usePageTitle.js';
|
||||||
import './DoctorsPage.css';
|
import './DoctorsPage.css';
|
||||||
|
|
||||||
function FilterSelect({ value, onChange, options, label }) {
|
|
||||||
const active = Boolean(value);
|
|
||||||
return (
|
|
||||||
<label className={`filter${active ? ' filter--active' : ''}`}>
|
|
||||||
<span className="visually-hidden">{label}</span>
|
|
||||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
|
||||||
{options.map((o) => (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<img src={active ? chevronWhite : chevronDark} alt="" width="14" height="14" />
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DoctorsPage() {
|
export default function DoctorsPage() {
|
||||||
const { section } = useParams();
|
const { section } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -56,7 +38,7 @@ export default function DoctorsPage() {
|
|||||||
|
|
||||||
const specialtyOptions = [
|
const specialtyOptions = [
|
||||||
{ value: '', label: 'Все специализации' },
|
{ value: '', label: 'Все специализации' },
|
||||||
...(specialties.data || []).filter((s) => s.count > 0).map((s) => ({ value: s.slug, label: s.name })),
|
...(specialties.data || []).filter((s) => s.count > 0).map((s) => ({ value: s.slug, label: s.name, count: s.count })),
|
||||||
];
|
];
|
||||||
|
|
||||||
const onSpecialty = (slug) => {
|
const onSpecialty = (slug) => {
|
||||||
@ -88,7 +70,7 @@ export default function DoctorsPage() {
|
|||||||
<div className="doctors-filters">
|
<div className="doctors-filters">
|
||||||
<div className="container doctors-filters__row">
|
<div className="container doctors-filters__row">
|
||||||
<div className="doctors-filters__chips">
|
<div className="doctors-filters__chips">
|
||||||
<FilterSelect
|
<SpecialtySelect
|
||||||
label="Специализация"
|
label="Специализация"
|
||||||
value={section && activeSpecialty ? section : ''}
|
value={section && activeSpecialty ? section : ''}
|
||||||
onChange={onSpecialty}
|
onChange={onSpecialty}
|
||||||
|
|||||||