diff --git a/package-lock.json b/package-lock.json
index 25bd1f3..83f7d94 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index e3fe231..b13260a 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.jsx b/src/App.jsx
index 257a9eb..64c6cda 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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';
@@ -300,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 (
-
- );
-}
-
export default function App() {
const [tw, setTw] = useState(TWEAKS_DEFAULT);
const [panelOpen, setPanelOpen] = useState(true);
diff --git a/src/ClinicLeafletMap.jsx b/src/ClinicLeafletMap.jsx
new file mode 100644
index 0000000..4d80e90
--- /dev/null
+++ b/src/ClinicLeafletMap.jsx
@@ -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: ``,
+ iconSize: [26, 34],
+ iconAnchor: [13, 34],
+ });
+}
+
+/** OSM + один маркер; center — [lat, lng] в формате Leaflet */
+export function ClinicLeafletMap({ center, pinColor = '#E04E44' }) {
+ const icon = useMemo(() => makePinIcon(pinColor), [pinColor]);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/ContactsRoutePage.jsx b/src/ContactsRoutePage.jsx
new file mode 100644
index 0000000..6d14f3e
--- /dev/null
+++ b/src/ContactsRoutePage.jsx
@@ -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 (
+
+
+
OCLINICA
+
+ {[1, 0, 1, 1, 0, 1, 0, 1, 1, 0].map((l, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+function NavRouteIcon({ size = 18, color = oc.teal }) {
+ return (
+
+ );
+}
+
+/** Глифы 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 (
+
+ );
+}
+
+function SocialYoutubeIcon({ size = 26, color = oc.whyHeaderStar, playColor = '#fff' }) {
+ return (
+
+ );
+}
+
+function OclinicaTabBar() {
+ const tab = (label, Icon, active) => (
+
+
+ {label}
+
+ );
+ return (
+
+
+ {tab('Главная', I.home, false)}
+
+ {tab('Клиники', I.pin, true)}
+ {tab('Запись', I.calendar, false)}
+ {tab('Профиль', I.profile, false)}
+
+ );
+}
+
+/** Конверт — заливка заданным зелёным */
+function WhyMailGlyph({ size = 22, color = oc.whyInfoIconFg }) {
+ return (
+
+ );
+}
+
+/** Глобус — контур тем же зелёным */
+function WhyGlobeGlyph({ size = 22, color = oc.whyInfoIconFg }) {
+ return (
+
+ );
+}
+
+/** Девятиконечная звезда + белая галочка по центру (блок «Почему нас выбирают») */
+/** Внешний ≈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 (
+
+ );
+}
+
+function StatPill({ value, label, suffix }) {
+ return (
+
+
+ {value}
+ {suffix}
+
+
{label}
+
+ );
+}
+
+function InfoRow({ glyph, label, children }) {
+ return (
+
+
+ {glyph}
+
+
+
{label}
+
+ {children}
+
+
+
+ );
+}
+
+function ClinicCirclePhoto({ imageUrl, href }) {
+ const [useFallback, setUseFallback] = React.useState(false);
+ const img = !useFallback ? (
+
setUseFallback(true)}
+ />
+ ) : (
+
+ );
+
+ if (href) {
+ return (
+
+ {img}
+
+ );
+ }
+ 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 (
+
+
+
+
+
+
+
+ 8(342) 207-03-03
+
+
+
+
+ Написать в чат
+
+
+
Наши клиники
+
+
+ {clinics.map(c => (
+
+
+
+
+
+ {c.street}
+
+
+
+ Режим работы
+
+
+
+ {c.note && (
+
+ {c.note}
+
+ )}
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ Записаться на приём
+
+
+
+
+
+ );
+}
+
+export default function ContactsRoutePage() {
+ return (
+
+ );
+}
diff --git a/src/FitWrap.jsx b/src/FitWrap.jsx
new file mode 100644
index 0000000..f12826b
--- /dev/null
+++ b/src/FitWrap.jsx
@@ -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 (
+
+ );
+}
diff --git a/src/app.css b/src/app.css
index 73501d0..3519429 100644
--- a/src/app.css
+++ b/src/app.css
@@ -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;
+}
diff --git a/src/icons.jsx b/src/icons.jsx
index f989b95..b39e8bc 100644
--- a/src/icons.jsx
+++ b/src/icons.jsx
@@ -42,4 +42,6 @@ export const I = {
volume: (p) => ,
arrow: (p) => ,
menu: (p) => ,
+ mail: (p) => ,
+ globe: (p) => ,
};
diff --git a/src/main.jsx b/src/main.jsx
index 4dc7be2..0ac5d9f 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -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(
-
+
+
+ } />
+ } />
+
+
);