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 ( -
-
{children}
-
- ); -} - 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.hours} +
+
+ + {c.note && ( +
+ {c.note} +
+ )} + + +
+
+ ))} +
+ +
+
+
+ +
+

Почему нас выбирают

+
+ +
+ + + + ★} /> +
+ + } label="Email"> + + mail@oclinica.ru + + + } label="Веб-сайт"> + + perm.oclinica.ru + + + + +
+ +
+
+
+ +
+
+ + + Записаться на приём + +
+ +
+
+ ); +} + +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 ( +
+
{children}
+
+ ); +} 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( - + + + } /> + } /> + + );