Browse Source

add pages contacts

main
poturaevpetr 2 weeks ago
parent
commit
7f09363811
  1. 77
      package-lock.json
  2. 5
      package.json
  3. 35
      src/App.jsx
  4. 46
      src/ClinicLeafletMap.jsx
  5. 577
      src/ContactsRoutePage.jsx
  6. 33
      src/FitWrap.jsx
  7. 6
      src/app.css
  8. 2
      src/icons.jsx
  9. 9
      src/main.jsx

77
package-lock.json generated

@ -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",

5
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",

35
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 (
<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);

46
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: `<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>
);
}

577
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 (
<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>
);
}

33
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 (
<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>
);
}

6
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;
}

2
src/icons.jsx

@ -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>,
};

9
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(
<React.StrictMode>
<App />
<BrowserRouter>
<Routes>
<Route path="/contacts" element={<ContactsRoutePage />} />
<Route path="*" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>
);

Loading…
Cancel
Save