);
}
function ContextChip({ label, value, dot, mono, strong, accent }) {
return (
{dot && }
{label}:
{value}
);
}
// Точка цвета сервиса — метка в списке диалогов в режиме «Все сервисы».
function ServiceDot({ color, name }) {
if (!color) return null;
return (
);
}
// ── Мобильная адаптация ──────────────────────────────────────────────────────
// Раскладка ветвится по трём брейкпоинтам. Одна кодовая база: карточки, пузыри
// и панели те же, меняется только их размещение.
const VIEWPORT_MOBILE_MAX = 699.98; // телефон
const VIEWPORT_TABLET_MAX = 1099.98; // планшет и узкое окно на десктопе
function readViewport() {
if (typeof window === "undefined") return "desktop";
const w = window.innerWidth;
return w <= VIEWPORT_MOBILE_MAX ? "mobile" : w <= VIEWPORT_TABLET_MAX ? "tablet" : "desktop";
}
// matchMedia вместо ручного resize: событие приходит только при переходе через
// границу, а не на каждый пиксель перетаскивания окна.
function useViewport() {
const [mode, setMode] = useState(readViewport);
useEffect(() => {
const mq = [
window.matchMedia(`(max-width: ${VIEWPORT_MOBILE_MAX}px)`),
window.matchMedia(`(max-width: ${VIEWPORT_TABLET_MAX}px)`),
];
const onChange = () => setMode(readViewport());
mq.forEach((m) => m.addEventListener("change", onChange));
onChange();
return () => mq.forEach((m) => m.removeEventListener("change", onChange));
}, []);
return {
mode,
isMobile: mode === "mobile",
isTablet: mode === "tablet",
isDesktop: mode === "desktop",
// Карточка клиента и меню действий уезжают в шторку и на планшете тоже.
isCompact: mode !== "desktop",
};
}
// Читаемый цвет поверх фирменного цвета сервиса.
function contrastOn(hex) {
if (!hex || hex[0] !== "#" || hex.length < 7) return "#fff";
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#0d0d12" : "#fff";
}
// Подложка модального окна. Закрывает окно только если на ней прошли ОБА
// события мыши — и нажатие, и отпускание. Прежний вариант ловил `click`, а он
// всплывает до общего предка: выделение текста в поле, законченное за краем
// формы, читалось как клик по подложке, и окно закрывалось с набранным.
// Escape закрывает всегда — раньше это умела только шторка.
function ModalOverlay({ onClose, zIndex = 50, className = "", children }) {
const downOnBackdrop = useRef(false);
useEffect(() => {
if (!onClose) return;
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
);
}
// Шапка мобильного экрана: слева либо логотип, либо «назад».
// leading — что показать слева, когда кнопка «назад» не нужна (например,
// аватар клиента в двухколоночной раскладке планшета).
function MobileAppBar({ title, subtitle, onBack, right, leading }) {
return (
{onBack ? (
) : leading ? leading : (
Х
)}
{title}
{subtitle &&
{subtitle}
}
{right}
);
}
// Круглая кнопка шапки с необязательным бейджем.
function AppBarButton({ icon, label, badge, onClick, tone = "muted" }) {
return (
);
}
// Нижняя навигация. «Статистика» — только у админа, как и в TopBar.
function MobileNav({ screen, setScreen, badge = 0, isAdmin = false }) {
const items = [
{ id: "dialogs", label: "Диалоги", icon: "chat" },
{ id: "health", label: "Состояние", icon: "server" },
{ id: "stats", label: "Статистика", icon: "chart" },
{ id: "settings", label: "Настройки", icon: "settings" },
].filter((i) => i.id !== "stats" || isAdmin);
return (
);
}
function Toast({ msg, type = "ok" }) {
if (!msg) return null;
const dot = type === "warn" ? "bg-[#f59e0b]" : "bg-[#22c55e]";
return (