add pages contacts
This commit is contained in:
Generated
+76
-1
@@ -8,8 +8,11 @@
|
||||
"name": "pcs-pt-mobile",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -739,6 +742,26 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1375,6 +1398,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -1491,6 +1520,20 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -1501,6 +1544,38 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
|
||||
|
||||
+4
-1
@@ -9,8 +9,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
+2
-33
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FitWrap } from './FitWrap.jsx';
|
||||
import { IOSDevice } from './frames/IOSDevice.jsx';
|
||||
import { AndroidDevice } from './frames/AndroidDevice.jsx';
|
||||
import { PhoneApp } from './PhoneApp.jsx';
|
||||
@@ -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 (
|
||||
<div ref={outerRef} style={{ width: w * scale, height: h * scale, position: 'relative', flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: w, height: h, transformOrigin: 'top left',
|
||||
transform: `scale(${scale})`,
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [tw, setTw] = useState(TWEAKS_DEFAULT);
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { MapContainer, Marker, TileLayer, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
function InvalidateOnMount() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.invalidateSize();
|
||||
const id = window.setTimeout(() => map.invalidateSize(), 120);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function makePinIcon(pinColor) {
|
||||
const safe = /^#[0-9A-Fa-f]{6}$/.test(pinColor) ? pinColor : '#E04E44';
|
||||
return L.divIcon({
|
||||
className: 'clinic-leaflet-pin-wrap',
|
||||
html: `<svg width="26" height="34" viewBox="0 0 24 32" aria-hidden="true"><path d="M12 0C5 0 0 5 0 12c0 8 12 20 12 20s12-12 12-20c0-7-5-12-12-12z" fill="${safe}"/><circle cx="12" cy="12" r="4.5" fill="#fff"/></svg>`,
|
||||
iconSize: [26, 34],
|
||||
iconAnchor: [13, 34],
|
||||
});
|
||||
}
|
||||
|
||||
/** OSM + один маркер; center — [lat, lng] в формате Leaflet */
|
||||
export function ClinicLeafletMap({ center, pinColor = '#E04E44' }) {
|
||||
const icon = useMemo(() => makePinIcon(pinColor), [pinColor]);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={17}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
dragging
|
||||
doubleClickZoom={false}
|
||||
>
|
||||
<InvalidateOnMount />
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<Marker position={center} icon={icon} />
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { I } from './icons.jsx';
|
||||
import { IOSDevice } from './frames/IOSDevice.jsx';
|
||||
import { FitWrap } from './FitWrap.jsx';
|
||||
import { ClinicLeafletMap } from './ClinicLeafletMap.jsx';
|
||||
|
||||
/** Палитра и типографика ближе к скриншоту Oclinica */
|
||||
const oc = {
|
||||
pageBg: '#F2F2F2',
|
||||
headerBg: '#FFFFFF',
|
||||
card: '#FFFFFF',
|
||||
/** Телефон и «Записаться на приём» — один фон и цвет текста */
|
||||
phoneBtnBg: '#9ad1d8',
|
||||
phoneBtnFg: '#001c22',
|
||||
phoneBtnShadow: '0 3px 14px rgba(0, 28, 34, 0.08)',
|
||||
bookBtnBg: '#9ad1d8',
|
||||
bookBtnFg: '#001c22',
|
||||
chatBorder: '#C4A574',
|
||||
chatFg: '#6B542E',
|
||||
openBadgeBg: '#769197',
|
||||
openBadgeFg: '#FFFFFF',
|
||||
/** Блок «Почему нас выбирают» */
|
||||
hWhy: '#B88E71',
|
||||
whyWrapBg: '#EBEDF0',
|
||||
/** Звезда над заголовком «Почему нас выбирают» */
|
||||
whyHeaderStar: '#5f96a0',
|
||||
/** Иконки Email / Веб-сайт */
|
||||
whyInfoIconBg: '#f8faf9',
|
||||
whyInfoIconFg: '#619799',
|
||||
whyRowLabel: '#5A9E95',
|
||||
whyRowValue: '#3A4149',
|
||||
statCardValue: '#599195',
|
||||
statCardLabel: '#6B7684',
|
||||
noteBg: '#E8F4FC',
|
||||
noteBorder: '#B9D8EE',
|
||||
noteFg: '#245A7A',
|
||||
routeBtnBg: '#EBEDEF',
|
||||
routeBtnFg: '#4A5560',
|
||||
teal: '#1F8F85',
|
||||
tealDark: '#166B63',
|
||||
};
|
||||
|
||||
function MiniBuilding() {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
background: 'linear-gradient(180deg, #D4EBE6 0%, #E8F5F2 50%, #B8D4C8 100%)',
|
||||
display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 5,
|
||||
}}>
|
||||
<div style={{
|
||||
width: '68%', height: '58%',
|
||||
background: '#E5DAC8',
|
||||
borderTopLeftRadius: 3, borderTopRightRadius: 3,
|
||||
position: 'relative',
|
||||
boxShadow: 'inset 0 -10px 0 #7A5F3A',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', top: '16%', left: '50%', transform: 'translateX(-50%)',
|
||||
padding: '3px 8px', background: '#166B63', color: '#fff', fontSize: 7, fontWeight: 800,
|
||||
borderRadius: 3, letterSpacing: 0.4,
|
||||
}}>OCLINICA</div>
|
||||
<div style={{
|
||||
position: 'absolute', inset: '40% 10% 20%',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 3,
|
||||
}}>
|
||||
{[1, 0, 1, 1, 0, 1, 0, 1, 1, 0].map((l, i) => (
|
||||
<div key={i} style={{ background: l ? '#F5D78A' : '#2A4544', borderRadius: 1, minHeight: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavRouteIcon({ size = 18, color = oc.teal }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 18l-5-5 5-5M4 13h12.5a4.5 4.5 0 014.5 4.5V19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Глифы VK / YouTube — геометрия Simple Icons, единый цвет в интерфейсе */
|
||||
const SI_VK_PATH =
|
||||
'm9.489.004.729-.003h3.564l.73.003.914.01.433.007.418.011.403.014.388.016.374.021.36.025.345.03.333.033c1.74.196 2.933.616 3.833 1.516.9.9 1.32 2.092 1.516 3.833l.034.333.029.346.025.36.02.373.025.588.012.41.013.644.009.915.004.98-.001 3.313-.003.73-.01.914-.007.433-.011.418-.014.403-.016.388-.021.374-.025.36-.03.345-.033.333c-.196 1.74-.616 2.933-1.516 3.833-.9.9-2.092 1.32-3.833 1.516l-.333.034-.346.029-.36.025-.373.02-.588.025-.41.012-.644.013-.915.009-.98.004-3.313-.001-.73-.003-.914-.01-.433-.007-.418-.011-.403-.014-.388-.016-.374-.021-.36-.025-.345-.03-.333-.033c-1.74-.196-2.933-.616-3.833-1.516-.9-.9-1.32-2.092-1.516-3.833l-.034-.333-.029-.346-.025-.36-.02-.373-.025-.588-.012-.41-.013-.644-.009-.915-.004-.98.001-3.313.003-.73.01-.914.007-.433.011-.418.014-.403.016-.388.021-.374.025-.36.03-.345.033-.333c.196-1.74.616-2.933 1.516-3.833.9-.9 2.092-1.32 3.833-1.516l.333-.034.346-.029.36-.025.373-.02.588-.025.41-.012.644-.013.915-.009ZM6.79 7.3H4.05c.13 6.24 3.25 9.99 8.72 9.99h.31v-3.57c2.01.2 3.53 1.67 4.14 3.57h2.84c-.78-2.84-2.83-4.41-4.11-5.01 1.28-.74 3.08-2.54 3.51-4.98h-2.58c-.56 1.98-2.22 3.78-3.8 3.95V7.3H10.5v6.92c-1.6-.4-3.62-2.34-3.71-6.92Z';
|
||||
|
||||
const SI_YOUTUBE_BG_PATH =
|
||||
'M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z';
|
||||
const SI_YOUTUBE_PLAY_PATH = 'M9.545 15.568V8.432L15.818 12l-6.273 3.568z';
|
||||
|
||||
function SocialVkIcon({ size = 26, color = oc.whyHeaderStar }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden style={{ display: 'block' }}>
|
||||
<path fill={color} d={SI_VK_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialYoutubeIcon({ size = 26, color = oc.whyHeaderStar, playColor = '#fff' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden style={{ display: 'block' }}>
|
||||
<path fill={color} d={SI_YOUTUBE_BG_PATH} />
|
||||
<path fill={playColor} d={SI_YOUTUBE_PLAY_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function OclinicaTabBar() {
|
||||
const tab = (label, Icon, active) => (
|
||||
<div
|
||||
key={label}
|
||||
style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
|
||||
padding: '5px 0 2px', color: active ? oc.tealDark : '#9AA7B4',
|
||||
fontSize: 10, fontWeight: active ? 700 : 600,
|
||||
}}
|
||||
>
|
||||
<Icon size={22} sw={active ? 2.1 : 1.75} style={{ color: active ? oc.tealDark : '#9AA7B4' }} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', paddingBottom: 20 }}>
|
||||
<Link to="/" style={{ flex: 1, textDecoration: 'none', color: 'inherit' }}>
|
||||
{tab('Главная', I.home, false)}
|
||||
</Link>
|
||||
{tab('Клиники', I.pin, true)}
|
||||
{tab('Запись', I.calendar, false)}
|
||||
{tab('Профиль', I.profile, false)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Конверт — заливка заданным зелёным */
|
||||
function WhyMailGlyph({ size = 22, color = oc.whyInfoIconFg }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden style={{ display: 'block' }}>
|
||||
<path
|
||||
fill={color}
|
||||
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Глобус — контур тем же зелёным */
|
||||
function WhyGlobeGlyph({ size = 22, color = oc.whyInfoIconFg }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden style={{ display: 'block' }}>
|
||||
<circle cx="12" cy="12" r="9.25" stroke={color} strokeWidth="2" />
|
||||
<ellipse cx="12" cy="12" rx="4.2" ry="9.25" stroke={color} strokeWidth="1.75" />
|
||||
<path d="M2.5 12h19" stroke={color} strokeWidth="1.75" strokeLinecap="round" />
|
||||
<path
|
||||
d="M6.5 6.5c1.4 1.9 2.2 4.1 2.2 5.5s-.8 3.6-2.2 5.5M17.5 6.5c-1.4 1.9-2.2 4.1-2.2 5.5s.8 3.6 2.2 5.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.55"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Девятиконечная звезда + белая галочка по центру (блок «Почему нас выбирают») */
|
||||
/** Внешний ≈8.35, внутренний ≈7.22 — чуть крупнее, лучи по-прежнему короткие */
|
||||
const WHY_NINE_POINT_STAR_D =
|
||||
'M12.000,3.650L14.469,5.215L17.367,5.604L18.253,8.390L20.223,10.550L19.110,13.254L19.231,16.175L16.641,17.531L14.856,19.846L12.000,19.220L9.144,19.846L7.359,17.531L4.769,16.175L4.890,13.254L3.777,10.550L5.747,8.390L6.633,5.604L9.531,5.215Z';
|
||||
|
||||
function WhyHeaderStarGlyph({ size = 52, color = oc.whyHeaderStar }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden
|
||||
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
||||
>
|
||||
<path fill={color} d={WHY_NINE_POINT_STAR_D} />
|
||||
<path
|
||||
transform="translate(12,12) scale(0.52) translate(-12,-12)"
|
||||
d="M8.05 12.05l2.75 2.75 5.85-6.45"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ value, label, suffix }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, minWidth: 0,
|
||||
background: oc.card,
|
||||
borderRadius: 12,
|
||||
padding: '10px 4px 11px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 1px 3px rgba(15,30,40,0.06)',
|
||||
border: '1px solid #E8EAED',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3,
|
||||
fontSize: 16, fontWeight: 800, color: oc.statCardValue, fontFamily: 'var(--font-narrow)', lineHeight: 1.1,
|
||||
}}>
|
||||
<span>{value}</span>
|
||||
{suffix}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 8, fontWeight: 800, letterSpacing: 0.7, color: oc.statCardLabel, marginTop: 5,
|
||||
lineHeight: 1.2, textTransform: 'uppercase',
|
||||
}}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ glyph, label, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
background: oc.card,
|
||||
borderRadius: 14,
|
||||
padding: '12px 14px',
|
||||
marginBottom: 10,
|
||||
border: '1px solid #E8EAED',
|
||||
boxShadow: '0 1px 4px rgba(15,30,40,0.05)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 46, height: 46, borderRadius: '50%', background: oc.whyInfoIconBg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
border: '1px solid #eef2f0',
|
||||
}}>
|
||||
{glyph}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 13, color: oc.whyRowLabel, marginBottom: 3, fontWeight: 700, letterSpacing: 0.2,
|
||||
}}>{label}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: oc.whyRowValue, letterSpacing: 0.02, lineHeight: 1.35 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClinicCirclePhoto({ imageUrl, href }) {
|
||||
const [useFallback, setUseFallback] = React.useState(false);
|
||||
const img = !useFallback ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
display: 'block',
|
||||
}}
|
||||
onError={() => setUseFallback(true)}
|
||||
/>
|
||||
) : (
|
||||
<MiniBuilding />
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{img}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
const FOOTER_H = 162;
|
||||
/** Выше внутренних слоёв Leaflet (~400–700), чтобы хром не перекрывался картой при скролле */
|
||||
const Z_SCROLL = 0;
|
||||
const Z_HEADER = 2000;
|
||||
const Z_FOOTER_BAR = 2100;
|
||||
|
||||
function ContactsPhoneBody() {
|
||||
const navigate = useNavigate();
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) navigate(-1);
|
||||
else navigate('/');
|
||||
};
|
||||
|
||||
const clinics = [
|
||||
{
|
||||
id: 'zvezda',
|
||||
street: 'ул. Газеты Звезда, 31А',
|
||||
hours: 'Пн–Пт 09:00 – 21:00\nСб–Вс 09:00 – 19:00',
|
||||
note: 'Каждый 4-ый четверг месяца до 17:00',
|
||||
mapPosition: [58.008116, 56.246041],
|
||||
mapPinColor: '#E04E44',
|
||||
circleImageSrc: 'https://avatars.mds.yandex.net/get-altay/1363018/2a00000164698e13e4695cde8053e9eed99f/L_height',
|
||||
circleHref: 'https://yandex.ru/maps/org/klinika_ukho_gorlo_nos_imeni_professora_ye_n_olenevoy/1747301334/',
|
||||
},
|
||||
{
|
||||
id: 'tsetkin',
|
||||
street: 'ул. Клары Цеткин, 9',
|
||||
hours: 'Пн–Сб 09:00 – 17:00\nВс — выходной',
|
||||
note: null,
|
||||
mapPosition: [57.987262, 56.246448],
|
||||
mapPinColor: '#D94A3D',
|
||||
circleImageSrc: 'https://lh5.googleusercontent.com/proxy/FtM-XTbdVjWSzmAYLN1W_b69pueujUj2Gv6Yr7RqwYwQJOrisuUt_YI6qIXyaO9kZa3BZmQQrFpcJcfbcJUvdMGJx-s7UM3PYrnLsYtZcJ8jdVllLCU',
|
||||
circleHref: 'http://job.oclinica.ru/vakansiya-administratora',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: oc.pageBg,
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'var(--font-base), "Inter", system-ui, sans-serif',
|
||||
color: '#1F2A37',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
overflowY: 'auto', overflowX: 'hidden',
|
||||
paddingTop: 58,
|
||||
paddingBottom: FOOTER_H,
|
||||
zIndex: Z_SCROLL,
|
||||
isolation: 'isolate',
|
||||
}}>
|
||||
<header style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '42px 1fr 42px',
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px 11px',
|
||||
position: 'sticky', top: 0, zIndex: Z_HEADER,
|
||||
background: oc.headerBg,
|
||||
borderBottom: '1px solid #EFEFEF',
|
||||
}}>
|
||||
<button type="button" onClick={goBack} className="press" style={{
|
||||
width: 38, height: 38, borderRadius: 999, background: '#F5F5F5',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: 0,
|
||||
}}>
|
||||
<I.chevL size={20} style={{ color: '#2A3540' }} />
|
||||
</button>
|
||||
<h1 style={{ margin: 0, fontSize: 17, fontWeight: 700, textAlign: 'center', color: '#1F2A37' }}>Контакты</h1>
|
||||
<span />
|
||||
</header>
|
||||
|
||||
<div style={{ padding: '14px 16px 20px' }}>
|
||||
<a href="tel:+73422070303" className="press" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
width: '100%', padding: '15px 16px', borderRadius: 16, marginBottom: 10,
|
||||
background: oc.phoneBtnBg, color: oc.phoneBtnFg, textDecoration: 'none',
|
||||
fontSize: 17, fontWeight: 800, letterSpacing: 0.2,
|
||||
boxShadow: oc.phoneBtnShadow,
|
||||
border: '1px solid rgba(255,255,255,0.45)',
|
||||
}}>
|
||||
<I.phone size={21} sw={2} style={{ color: oc.phoneBtnFg }} />
|
||||
8(342) 207-03-03
|
||||
</a>
|
||||
|
||||
<Link to="/" className="press" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 9,
|
||||
width: '100%', padding: '14px 16px', borderRadius: 16, marginBottom: 20,
|
||||
background: oc.card, color: oc.chatFg,
|
||||
border: `1.5px solid ${oc.chatBorder}`, textDecoration: 'none', fontWeight: 700, fontSize: 15,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<I.chat size={20} />
|
||||
Написать в чат
|
||||
</Link>
|
||||
|
||||
<h2 style={{ fontSize: 16, fontWeight: 800, margin: '0 0 12px', color: '#1F2A37', letterSpacing: 0.02 }}>Наши клиники</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{clinics.map(c => (
|
||||
<article key={c.id} style={{
|
||||
background: oc.card,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 14px rgba(15,30,40,0.07)',
|
||||
border: '1px solid #ECECEC',
|
||||
}}>
|
||||
<div style={{ padding: 12, paddingBottom: 4 }}>
|
||||
<div style={{
|
||||
borderRadius: 14, overflow: 'hidden', position: 'relative',
|
||||
aspectRatio: '16 / 10',
|
||||
isolation: 'isolate',
|
||||
}}>
|
||||
<ClinicLeafletMap key={c.id} center={c.mapPosition} pinColor={c.mapPinColor} />
|
||||
<div style={{
|
||||
position: 'absolute', top: 10, left: 10, zIndex: 500,
|
||||
fontSize: 9, fontWeight: 900, letterSpacing: 0.7,
|
||||
padding: '6px 11px', borderRadius: 8,
|
||||
background: oc.openBadgeBg, color: oc.openBadgeFg,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
}}>ОТКРЫТО</div>
|
||||
<div style={{
|
||||
position: 'absolute', right: 10, bottom: 10,
|
||||
width: 88, height: 88, borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
border: '4px solid #fff',
|
||||
boxShadow: '0 6px 18px rgba(15,30,40,0.14)',
|
||||
zIndex: 600,
|
||||
background: '#fff',
|
||||
}}>
|
||||
<ClinicCirclePhoto imageUrl={c.circleImageSrc} href={c.circleHref} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 16px 16px' }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 800, lineHeight: 1.25, marginBottom: 12, color: '#1F2A37' }}>
|
||||
{c.street}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 800, color: '#6B7A89', letterSpacing: 0.4 }}>Режим работы</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: c.note ? 10 : 14 }}>
|
||||
<I.clock size={18} style={{ color: '#8A96A0', flexShrink: 0, marginTop: 1 }} />
|
||||
<div style={{ fontSize: 13, color: '#5A6B7B', lineHeight: 1.5, whiteSpace: 'pre-line', fontWeight: 500 }}>
|
||||
{c.hours}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.note && (
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 600, color: oc.noteFg, lineHeight: 1.45,
|
||||
padding: '11px 13px', borderRadius: 12,
|
||||
background: oc.noteBg, border: `1px solid ${oc.noteBorder}`,
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
{c.note}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" className="press" style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
padding: '13px 14px', borderRadius: 14,
|
||||
background: oc.routeBtnBg, border: '1px solid #E0E3E6',
|
||||
fontSize: 14, fontWeight: 700, color: oc.routeBtnFg,
|
||||
}}>
|
||||
<NavRouteIcon size={19} color={oc.teal} />
|
||||
Построить маршрут
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: 22,
|
||||
background: oc.whyWrapBg,
|
||||
borderRadius: 20,
|
||||
padding: '18px 12px 16px',
|
||||
border: '1px solid #DDE0E4',
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.7)',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 14 }}>
|
||||
<div style={{ marginBottom: 10, lineHeight: 0 }}>
|
||||
<WhyHeaderStarGlyph size={52} color={oc.whyHeaderStar} />
|
||||
</div>
|
||||
<h2 style={{
|
||||
margin: 0, fontSize: 20, fontWeight: 700, color: oc.hWhy,
|
||||
letterSpacing: 0.08, fontFamily: 'var(--font-base)', lineHeight: 1.25,
|
||||
}}>Почему нас выбирают</h2>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'row', gap: 8,
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<StatPill value="20+" label="лет опыта" />
|
||||
<StatPill value="50+" label="врачей" />
|
||||
<StatPill value="100k+" label="пациентов" />
|
||||
<StatPill value="4.9" label="рейтинг" suffix={<span style={{ fontSize: 13, color: oc.statCardValue, lineHeight: 1 }}>★</span>} />
|
||||
</div>
|
||||
|
||||
<InfoRow glyph={<WhyMailGlyph size={23} />} label="Email">
|
||||
<a href="mailto:mail@oclinica.ru" style={{ color: oc.whyRowValue, textDecoration: 'none', fontWeight: 600 }}>
|
||||
mail@oclinica.ru
|
||||
</a>
|
||||
</InfoRow>
|
||||
<InfoRow glyph={<WhyGlobeGlyph size={23} />} label="Веб-сайт">
|
||||
<a href="https://perm.oclinica.ru" target="_blank" rel="noreferrer" style={{ color: oc.whyRowValue, textDecoration: 'none', fontWeight: 600 }}>
|
||||
perm.oclinica.ru
|
||||
</a>
|
||||
</InfoRow>
|
||||
|
||||
<div style={{ display: 'flex', gap: 14, marginTop: 6, justifyContent: 'center' }}>
|
||||
<a
|
||||
href="https://vk.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="press"
|
||||
aria-label="ВКонтакте"
|
||||
style={{
|
||||
width: 50, height: 50, borderRadius: '50%', background: '#fff',
|
||||
border: '1px solid #E4E4E4', boxShadow: '0 2px 10px rgba(0,0,0,0.07)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<SocialVkIcon size={26} />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="press"
|
||||
aria-label="YouTube"
|
||||
style={{
|
||||
width: 50, height: 50, borderRadius: '50%', background: '#fff',
|
||||
border: '1px solid #E4E4E4', boxShadow: '0 2px 10px rgba(0,0,0,0.07)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<SocialYoutubeIcon size={26} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 20 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: Z_FOOTER_BAR,
|
||||
background: 'rgba(255,255,255,0.94)',
|
||||
backdropFilter: 'blur(16px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(16px) saturate(180%)',
|
||||
borderTop: '1px solid #E8E8E8',
|
||||
boxShadow: '0 -4px 24px rgba(15,30,40,0.06)',
|
||||
}}>
|
||||
<div style={{ padding: '10px 16px 6px' }}>
|
||||
<Link to="/" className="press" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
width: '100%', padding: '15px 16px', borderRadius: 16, textDecoration: 'none',
|
||||
background: oc.bookBtnBg,
|
||||
color: oc.bookBtnFg, fontWeight: 800, fontSize: 16, letterSpacing: 0.02,
|
||||
boxShadow: '0 6px 18px rgba(0, 28, 34, 0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.35)',
|
||||
}}>
|
||||
<I.calendar size={21} sw={2} style={{ color: oc.bookBtnFg }} />
|
||||
Записаться на приём
|
||||
</Link>
|
||||
</div>
|
||||
<OclinicaTabBar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactsRoutePage() {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<div className="stage">
|
||||
<FitWrap userScale="auto">
|
||||
<IOSDevice>
|
||||
<ContactsPhoneBody />
|
||||
</IOSDevice>
|
||||
</FitWrap>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function FitWrap({ children, w = 402, h = 874, userScale = 'auto' }) {
|
||||
const outerRef = useRef(null);
|
||||
const [autoScale, setAutoScale] = useState(1);
|
||||
useEffect(() => {
|
||||
const outer = outerRef.current;
|
||||
if (!outer) return;
|
||||
const stage = outer.closest('.stage') || outer.parentElement;
|
||||
if (!stage) return;
|
||||
const measure = () => {
|
||||
const padding = 48;
|
||||
const availW = stage.clientWidth - padding;
|
||||
const availH = stage.clientHeight - padding;
|
||||
const s = Math.min(availW / w, availH / h, 1);
|
||||
setAutoScale(Math.max(s, 0.3));
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(stage);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
|
||||
}, [w, h]);
|
||||
const scale = userScale === 'auto' ? autoScale : parseFloat(userScale);
|
||||
return (
|
||||
<div ref={outerRef} style={{ width: w * scale, height: h * scale, position: 'relative', flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: w, height: h, transformOrigin: 'top left',
|
||||
transform: `scale(${scale})`,
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -345,3 +345,9 @@ input, select, textarea { font-family: inherit; }
|
||||
|
||||
@keyframes pulse { 0%{opacity:.5;transform:scale(.95)} 100%{opacity:0;transform:scale(1.15)} }
|
||||
@keyframes blink { 50% { opacity: .3 } }
|
||||
|
||||
/* Leaflet: divIcon marker without default box */
|
||||
.leaflet-div-icon.clinic-leaflet-pin-wrap {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
@@ -42,4 +42,6 @@ export const I = {
|
||||
volume: (p) => <Icon {...p}><path d="M4 9h4l5-4v14l-5-4H4V9z"/><path d="M16 8a5 5 0 010 8M19 5a9 9 0 010 14"/></Icon>,
|
||||
arrow: (p) => <Icon {...p}><path d="M5 12h14M13 6l6 6-6 6"/></Icon>,
|
||||
menu: (p) => <Icon {...p}><path d="M4 7h16M4 12h16M4 17h16"/></Icon>,
|
||||
mail: (p) => <Icon {...p}><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></Icon>,
|
||||
globe: (p) => <Icon {...p}><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2v20M5 5c2.5 2 13.5 2 16 0M5 19c2.5-2 13.5-2 16 0"/></Icon>,
|
||||
};
|
||||
|
||||
+8
-1
@@ -1,11 +1,18 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
import ContactsRoutePage from './ContactsRoutePage.jsx';
|
||||
import './tokens.css';
|
||||
import './app.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/contacts" element={<ContactsRoutePage />} />
|
||||
<Route path="*" element={<App />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user