Browse Source
Ported HTML/CSS/JS design bundle from Claude Design to Vite + React. 20 screens (home 3 variants, booking flow, doctors, appointments, results + audiogram, recovery, audio test, chat, profile, QR, telemed, medcard, notifications). Tweaks panel with iOS/Android frames, layout/accent/font switchers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>main
commit
95c9889b5d
18 changed files with 4542 additions and 0 deletions
@ -0,0 +1,6 @@ |
|||||||
|
node_modules |
||||||
|
dist |
||||||
|
.DS_Store |
||||||
|
*.log |
||||||
|
design.tar.gz |
||||||
|
design_extracted |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="ru"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||||
|
<title>Клиника УГН — мобильное приложение</title> |
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" /> |
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
||||||
|
<link |
||||||
|
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&family=Golos+Text:wght@400;500;600;700&family=Oswald:wght@500;600;700&family=PT+Sans:wght@400;700&family=PT+Sans+Narrow:wght@400;700&display=swap" |
||||||
|
rel="stylesheet" |
||||||
|
/> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div id="root"></div> |
||||||
|
<script type="module" src="/src/main.jsx"></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
{ |
||||||
|
"name": "pcs-pt-mobile", |
||||||
|
"private": true, |
||||||
|
"version": "0.1.0", |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"dev": "vite", |
||||||
|
"build": "vite build", |
||||||
|
"preview": "vite preview" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"react": "^18.3.1", |
||||||
|
"react-dom": "^18.3.1" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@vitejs/plugin-react": "^4.3.4", |
||||||
|
"vite": "^5.4.10" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,268 @@ |
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
||||||
|
import { IOSDevice } from './frames/IOSDevice.jsx'; |
||||||
|
import { AndroidDevice } from './frames/AndroidDevice.jsx'; |
||||||
|
import { PhoneApp } from './PhoneApp.jsx'; |
||||||
|
|
||||||
|
const TWEAKS_DEFAULT = { |
||||||
|
homeVariant: 'cards', |
||||||
|
docVariant: 'rich', |
||||||
|
density: 'comfort', |
||||||
|
accent: 'teal', |
||||||
|
font: 'inter', |
||||||
|
layout: 'single', |
||||||
|
screen: 'home', |
||||||
|
device: 'ios', |
||||||
|
scale: 'auto', |
||||||
|
showIntro: true, |
||||||
|
}; |
||||||
|
|
||||||
|
const SCALE_OPTIONS = [ |
||||||
|
{ id: 'auto', lb: 'Авто' }, |
||||||
|
{ id: '0.5', lb: '50%' }, |
||||||
|
{ id: '0.75', lb: '75%' }, |
||||||
|
{ id: '1', lb: '100%' }, |
||||||
|
]; |
||||||
|
|
||||||
|
const HOME_OPTIONS = [ |
||||||
|
{ id: 'cards', lb: 'Карточки' }, |
||||||
|
{ id: 'list', lb: 'Лента' }, |
||||||
|
{ id: 'feed', lb: 'Таймлайн' }, |
||||||
|
]; |
||||||
|
const DOC_OPTIONS = [ |
||||||
|
{ id: 'rich', lb: 'Карточки+' }, |
||||||
|
{ id: 'list', lb: 'Список' }, |
||||||
|
{ id: 'photo', lb: 'Плитка' }, |
||||||
|
]; |
||||||
|
const DENSITY_OPTIONS = [ |
||||||
|
{ id: 'comfort', lb: 'Комф.' }, |
||||||
|
{ id: 'compact', lb: 'Плотно' }, |
||||||
|
]; |
||||||
|
const ACCENT_OPTIONS = [ |
||||||
|
{ id: 'teal', lb: 'Тил', primary: '#1F8F85', darker: '#166B63', dark: '#0F4A44', p50: '#E3F4F2', p100: '#C7E8E4', p200: '#9BD6CE', warm50: '#FDF8EE', warm100: '#F5EDDF' }, |
||||||
|
{ id: 'terra', lb: 'Терра', primary: '#C77A4C', darker: '#A65C33', dark: '#7F4426', p50: '#FBEFE4', p100: '#F5DDC9', p200: '#EBC19F', warm50: '#F4F7F3', warm100: '#E5ECE4' }, |
||||||
|
{ id: 'marine', lb: 'Марин', primary: '#3C6EA8', darker: '#23538B', dark: '#193C66', p50: '#E4EDF8', p100: '#C8DAEE', p200: '#9DBDDE', warm50: '#FBF6EE', warm100: '#F2E8D5' }, |
||||||
|
]; |
||||||
|
const FONT_OPTIONS = [ |
||||||
|
{ id: 'manrope', lb: 'Manrope', base: '"Manrope", system-ui, sans-serif', narrow: '"Oswald", sans-serif' }, |
||||||
|
{ id: 'inter', lb: 'Inter', base: '"Inter", system-ui, sans-serif', narrow: '"Oswald", sans-serif' }, |
||||||
|
{ id: 'golos', lb: 'Golos', base: '"Golos Text", system-ui, sans-serif', narrow: '"Oswald", sans-serif' }, |
||||||
|
]; |
||||||
|
const SCREEN_OPTIONS = [ |
||||||
|
{ id: 'home', lb: 'Главная' }, |
||||||
|
{ id: 'doctors', lb: 'Врачи' }, |
||||||
|
{ id: 'doctor:syndaev', lb: 'Карточка врача' }, |
||||||
|
{ id: 'booking-specs', lb: 'Запись: специализация' }, |
||||||
|
{ id: 'booking-doctor:ent', lb: 'Запись: врач' }, |
||||||
|
{ id: 'booking-time:syndaev', lb: 'Запись: время' }, |
||||||
|
{ id: 'booking-confirm:syndaev:1:16:00', lb: 'Запись: подтверждение' }, |
||||||
|
{ id: 'booking-success', lb: 'Запись: успех' }, |
||||||
|
{ id: 'appts', lb: 'Приёмы' }, |
||||||
|
{ id: 'appt:a1', lb: 'Детали приёма' }, |
||||||
|
{ id: 'results', lb: 'Результаты' }, |
||||||
|
{ id: 'result-audio', lb: 'Аудиограмма' }, |
||||||
|
{ id: 'recovery', lb: 'Восстановление' }, |
||||||
|
{ id: 'audiotest', lb: 'Тест слуха' }, |
||||||
|
{ id: 'chat', lb: 'Чат' }, |
||||||
|
{ id: 'profile', lb: 'Профиль' }, |
||||||
|
{ id: 'qr', lb: 'QR' }, |
||||||
|
{ id: 'telemed', lb: 'Телемед' }, |
||||||
|
{ id: 'medcard', lb: 'Медкарта' }, |
||||||
|
{ id: 'notifications', lb: 'Уведомления' }, |
||||||
|
]; |
||||||
|
|
||||||
|
function applyTheme(tw) { |
||||||
|
const a = ACCENT_OPTIONS.find(x => x.id === tw.accent) || ACCENT_OPTIONS[0]; |
||||||
|
const f = FONT_OPTIONS.find(x => x.id === tw.font) || FONT_OPTIONS[0]; |
||||||
|
const r = document.documentElement.style; |
||||||
|
r.setProperty('--c-primary', a.primary); |
||||||
|
r.setProperty('--c-primary-darker', a.darker); |
||||||
|
r.setProperty('--c-primary-dark', a.dark); |
||||||
|
r.setProperty('--c-primary-50', a.p50); |
||||||
|
r.setProperty('--c-primary-100', a.p100); |
||||||
|
r.setProperty('--c-primary-200', a.p200); |
||||||
|
r.setProperty('--c-warm-50', a.warm50); |
||||||
|
r.setProperty('--c-warm-100', a.warm100); |
||||||
|
r.setProperty('--font-base', f.base); |
||||||
|
r.setProperty('--font-narrow', f.narrow); |
||||||
|
document.body.style.fontFamily = f.base; |
||||||
|
} |
||||||
|
|
||||||
|
function Phone({ device = 'ios', screen, ctx, label, sublabel }) { |
||||||
|
const content = <PhoneApp initialScreen={screen} ctx={ctx} />; |
||||||
|
const frame = device === 'android' |
||||||
|
? <AndroidDevice>{content}</AndroidDevice> |
||||||
|
: <IOSDevice>{content}</IOSDevice>; |
||||||
|
if (!label) return frame; |
||||||
|
return ( |
||||||
|
<div className="phone-cell"> |
||||||
|
{frame} |
||||||
|
<div className="label">{label}</div> |
||||||
|
{sublabel && <div className="sublabel">{sublabel}</div>} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function TweaksPanel({ tw, setTw, onClose }) { |
||||||
|
const group = (title, children) => ( |
||||||
|
<div className="tweaks-section"> |
||||||
|
<div className="label">{title}</div> |
||||||
|
<div className="tweaks-options">{children}</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
const opts = (options, key) => |
||||||
|
options.map(o => ( |
||||||
|
<button key={o.id} onClick={() => setTw({ ...tw, [key]: o.id })} className={tw[key] === o.id ? 'on' : ''}> |
||||||
|
{o.lb} |
||||||
|
</button> |
||||||
|
)); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="tweaks-panel"> |
||||||
|
<h3> |
||||||
|
Tweaks |
||||||
|
<button onClick={onClose} title="Закрыть">×</button> |
||||||
|
</h3> |
||||||
|
{group('Экран', |
||||||
|
<select value={tw.screen} onChange={e => setTw({ ...tw, screen: e.target.value })}> |
||||||
|
{SCREEN_OPTIONS.map(s => <option key={s.id} value={s.id}>{s.lb}</option>)} |
||||||
|
</select> |
||||||
|
)} |
||||||
|
{group('Устройство', opts([{id:'ios',lb:'iOS'},{id:'android',lb:'Android'}], 'device'))} |
||||||
|
{tw.layout === 'single' && group('Масштаб', opts(SCALE_OPTIONS, 'scale'))} |
||||||
|
{group('Компоновка', opts([ |
||||||
|
{ id:'single', lb:'1 телефон' }, |
||||||
|
{ id:'home3', lb:'Главная ×3' }, |
||||||
|
{ id:'flow', lb:'Флоу записи' }, |
||||||
|
{ id:'variants', lb:'Все варианты' }, |
||||||
|
], 'layout'))} |
||||||
|
{group('Главный экран', opts(HOME_OPTIONS, 'homeVariant'))} |
||||||
|
{group('Карточки врачей', opts(DOC_OPTIONS, 'docVariant'))} |
||||||
|
{group('Плотность', opts(DENSITY_OPTIONS, 'density'))} |
||||||
|
{group('Цвет', |
||||||
|
ACCENT_OPTIONS.map(a => ( |
||||||
|
<button key={a.id} onClick={() => setTw({ ...tw, accent: a.id })} className={tw.accent === a.id ? 'on' : ''} style={{ |
||||||
|
display: 'flex', alignItems: 'center', gap: 6 |
||||||
|
}}> |
||||||
|
<span style={{ width: 10, height: 10, borderRadius: 999, background: a.primary, display: 'inline-block' }} /> |
||||||
|
{a.lb} |
||||||
|
</button> |
||||||
|
)) |
||||||
|
)} |
||||||
|
{group('Шрифт', opts(FONT_OPTIONS, 'font'))} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
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.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); |
||||||
|
const [introVisible, setIntroVisible] = useState(tw.showIntro !== false); |
||||||
|
|
||||||
|
useEffect(() => { applyTheme(tw); }, [tw.accent, tw.font]); |
||||||
|
|
||||||
|
const ctx = { homeVariant: tw.homeVariant, docVariant: tw.docVariant, density: tw.density }; |
||||||
|
|
||||||
|
const content = useMemo(() => { |
||||||
|
if (tw.layout === 'home3') { |
||||||
|
return ( |
||||||
|
<div className="phones-grid"> |
||||||
|
<Phone device={tw.device} screen="home" ctx={{ ...ctx, homeVariant: 'cards' }} label="Главная — Карточки" sublabel="CTA + сетка специализаций" /> |
||||||
|
<Phone device={tw.device} screen="home" ctx={{ ...ctx, homeVariant: 'list' }} label="Главная — Лента" sublabel="Список с деталями" /> |
||||||
|
<Phone device={tw.device} screen="home" ctx={{ ...ctx, homeVariant: 'feed' }} label="Главная — Таймлайн" sublabel="Лента здоровья" /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
if (tw.layout === 'flow') { |
||||||
|
return ( |
||||||
|
<div className="phones-grid"> |
||||||
|
<Phone device={tw.device} screen="booking-specs" ctx={ctx} label="1 · Специализация" /> |
||||||
|
<Phone device={tw.device} screen="booking-doctor:ent" ctx={ctx} label="2 · Выбор врача" /> |
||||||
|
<Phone device={tw.device} screen="booking-time:syndaev" ctx={ctx} label="3 · Дата и время" /> |
||||||
|
<Phone device={tw.device} screen="booking-confirm:syndaev:1:16:00" ctx={ctx} label="4 · Подтверждение" /> |
||||||
|
<Phone device={tw.device} screen="booking-success" ctx={ctx} label="Успех" /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
if (tw.layout === 'variants') { |
||||||
|
return ( |
||||||
|
<div className="phones-grid"> |
||||||
|
<Phone device={tw.device} screen="home" ctx={ctx} label="Главная" /> |
||||||
|
<Phone device={tw.device} screen="doctors" ctx={ctx} label="Врачи" /> |
||||||
|
<Phone device={tw.device} screen="doctor:syndaev" ctx={ctx} label="Карточка врача" /> |
||||||
|
<Phone device={tw.device} screen="booking-time:syndaev" ctx={ctx} label="Запись: время" /> |
||||||
|
<Phone device={tw.device} screen="appts" ctx={ctx} label="Мои приёмы" /> |
||||||
|
<Phone device={tw.device} screen="appt:a1" ctx={ctx} label="Детали приёма" /> |
||||||
|
<Phone device={tw.device} screen="result-audio" ctx={ctx} label="Аудиограмма" /> |
||||||
|
<Phone device={tw.device} screen="recovery" ctx={ctx} label="Восстановление" /> |
||||||
|
<Phone device={tw.device} screen="audiotest" ctx={ctx} label="Тест слуха" /> |
||||||
|
<Phone device={tw.device} screen="chat" ctx={ctx} label="Чат с врачом" /> |
||||||
|
<Phone device={tw.device} screen="profile" ctx={ctx} label="Профиль" /> |
||||||
|
<Phone device={tw.device} screen="qr" ctx={ctx} label="QR пациента" /> |
||||||
|
<Phone device={tw.device} screen="telemed" ctx={ctx} label="Видеозвонок" /> |
||||||
|
<Phone device={tw.device} screen="medcard" ctx={ctx} label="Медкарта" /> |
||||||
|
<Phone device={tw.device} screen="notifications" ctx={ctx} label="Уведомления" /> |
||||||
|
<Phone device={tw.device} screen="booking-specs" ctx={ctx} label="Запись" /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
return ( |
||||||
|
<FitWrap userScale={tw.scale}> |
||||||
|
<Phone device={tw.device} screen={tw.screen} ctx={ctx} /> |
||||||
|
</FitWrap> |
||||||
|
); |
||||||
|
}, [tw, ctx.homeVariant, ctx.docVariant, ctx.density]); |
||||||
|
|
||||||
|
const stageClass = tw.layout === 'single' ? 'stage' : 'stage grid-mode'; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ width: '100%', height: '100%', position: 'relative' }} data-density={tw.density}> |
||||||
|
<div className={stageClass}> |
||||||
|
{content} |
||||||
|
</div> |
||||||
|
|
||||||
|
{introVisible && ( |
||||||
|
<div className="intro-banner"> |
||||||
|
<span>📱 <b>Клиника УГН</b> · мобильный прототип · откройте <b>Tweaks</b> справа, чтобы переключать экраны и варианты.</span> |
||||||
|
<button onClick={() => { setIntroVisible(false); setTw({ ...tw, showIntro: false }); }}>×</button> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{panelOpen && <TweaksPanel tw={tw} setTw={setTw} onClose={() => setPanelOpen(false)} />} |
||||||
|
{!panelOpen && ( |
||||||
|
<button className="tweaks-fab" onClick={() => setPanelOpen(true)} title="Tweaks">⚙</button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,82 @@ |
|||||||
|
import React, { useEffect, useMemo, useState } from 'react'; |
||||||
|
import { TabBar } from './components.jsx'; |
||||||
|
import { HomeCardsScreen, HomeListScreen, HomeFeedScreen } from './screens/screens-home.jsx'; |
||||||
|
import { |
||||||
|
BookingSpecsScreen, BookingDoctorScreen, BookingTimeScreen, |
||||||
|
BookingConfirmScreen, BookingSuccessScreen, |
||||||
|
DoctorsTabScreen, DoctorDetailScreen, |
||||||
|
} from './screens/screens-booking.jsx'; |
||||||
|
import { |
||||||
|
ApptsTabScreen, ApptDetailScreen, |
||||||
|
ResultsScreen, ResultAudioScreen, |
||||||
|
RecoveryScreen, AudioTestScreen, |
||||||
|
ChatTabScreen, ProfileTabScreen, QRScreen, |
||||||
|
TelemedScreen, MedcardScreen, NotificationsScreen, |
||||||
|
} from './screens/screens-misc.jsx'; |
||||||
|
|
||||||
|
function renderScreen(screenId, nav, ctx) { |
||||||
|
const parts = screenId.split(':'); |
||||||
|
const id = parts[0]; |
||||||
|
const HOME = { cards: HomeCardsScreen, list: HomeListScreen, feed: HomeFeedScreen }[ctx.homeVariant] || HomeCardsScreen; |
||||||
|
switch (id) { |
||||||
|
case 'home': return <HOME nav={nav} ctx={ctx} />; |
||||||
|
case 'doctors': return <DoctorsTabScreen nav={nav} ctx={ctx} />; |
||||||
|
case 'doctor': return <DoctorDetailScreen nav={nav} doctorId={parts[1]} />; |
||||||
|
case 'booking-specs': return <BookingSpecsScreen nav={nav} />; |
||||||
|
case 'booking-doctor': return <BookingDoctorScreen nav={nav} specId={parts[1]} />; |
||||||
|
case 'booking-time': return <BookingTimeScreen nav={nav} doctorId={parts[1]} />; |
||||||
|
case 'booking-confirm': return <BookingConfirmScreen nav={nav} doctorId={parts[1]} dateIdx={+parts[2]} time={parts[3]} />; |
||||||
|
case 'booking-success': return <BookingSuccessScreen nav={nav} />; |
||||||
|
case 'appts': return <ApptsTabScreen nav={nav} />; |
||||||
|
case 'appt': return <ApptDetailScreen nav={nav} apptId={parts[1]} />; |
||||||
|
case 'results': return <ResultsScreen nav={nav} />; |
||||||
|
case 'result-audio': return <ResultAudioScreen nav={nav} />; |
||||||
|
case 'recovery': return <RecoveryScreen nav={nav} />; |
||||||
|
case 'audiotest': return <AudioTestScreen nav={nav} />; |
||||||
|
case 'chat': return <ChatTabScreen nav={nav} />; |
||||||
|
case 'profile': return <ProfileTabScreen nav={nav} ctx={ctx} />; |
||||||
|
case 'qr': return <QRScreen nav={nav} />; |
||||||
|
case 'telemed': return <TelemedScreen nav={nav} />; |
||||||
|
case 'medcard': return <MedcardScreen nav={nav} />; |
||||||
|
case 'notifications': return <NotificationsScreen nav={nav} />; |
||||||
|
default: return <div style={{padding: 40, textAlign: 'center'}}>Экран не найден: {screenId}</div>; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const TAB_IDS = ['home', 'appts', 'doctors', 'chat', 'profile']; |
||||||
|
|
||||||
|
export function PhoneApp({ initialScreen, ctx }) { |
||||||
|
const [stack, setStack] = useState([initialScreen]); |
||||||
|
|
||||||
|
useEffect(() => { setStack([initialScreen]); }, [initialScreen]); |
||||||
|
|
||||||
|
const nav = useMemo(() => ({ |
||||||
|
push: (id) => setStack(s => [...s, id]), |
||||||
|
pop: () => setStack(s => s.length > 1 ? s.slice(0, -1) : s), |
||||||
|
set: (id) => setStack([id]), |
||||||
|
reset:() => setStack(['home']), |
||||||
|
}), []); |
||||||
|
|
||||||
|
const current = stack[stack.length - 1]; |
||||||
|
const tabId = TAB_IDS.includes(current.split(':')[0]) ? current.split(':')[0] : null; |
||||||
|
const showTabBar = tabId !== null; |
||||||
|
|
||||||
|
const modalScreens = ['qr', 'telemed', 'booking-success', 'audiotest']; |
||||||
|
const isModal = modalScreens.includes(current.split(':')[0]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ |
||||||
|
position: 'absolute', inset: 0, |
||||||
|
background: 'var(--c-bg)', |
||||||
|
overflow: 'hidden', |
||||||
|
fontFamily: 'var(--font-base)', |
||||||
|
}}> |
||||||
|
<div style={{ position: 'absolute', inset: 0, overflowY: 'auto', overflowX: 'hidden', paddingTop: 58, paddingBottom: showTabBar ? 80 : 0 }}> |
||||||
|
{renderScreen(current, nav, ctx)} |
||||||
|
</div> |
||||||
|
{showTabBar && !isModal && ( |
||||||
|
<TabBar active={tabId} onChange={(t) => nav.set(t)} /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,347 @@ |
|||||||
|
/* ============================================================ |
||||||
|
Клиника УГН — Мобильное приложение |
||||||
|
============================================================ */ |
||||||
|
|
||||||
|
:root { |
||||||
|
--c-primary: #2BB4A8; |
||||||
|
--c-primary-dark: #1F8F85; |
||||||
|
--c-primary-darker: #166B63; |
||||||
|
--c-primary-50: #F2FAF9; |
||||||
|
--c-primary-100: #E3F4F2; |
||||||
|
--c-primary-200: #C7E9E4; |
||||||
|
--c-primary-300: #9ED8D1; |
||||||
|
|
||||||
|
--c-accent: #E04E44; |
||||||
|
--c-accent-dark: #B63D35; |
||||||
|
--c-accent-50: #FCF1F0; |
||||||
|
|
||||||
|
--c-warm-50: #FBF7EE; |
||||||
|
--c-warm-100: #F5EDDF; |
||||||
|
--c-warm-200: #E8DCC5; |
||||||
|
--c-warm-text: #7A6A2E; |
||||||
|
|
||||||
|
--c-success: #2E9B6B; |
||||||
|
--c-success-50: #E8F5EE; |
||||||
|
--c-warning: #E8A13C; |
||||||
|
--c-warning-50: #FBEFD8; |
||||||
|
--c-danger: #D94141; |
||||||
|
|
||||||
|
--c-bg: #F7F9FB; |
||||||
|
--c-bg-card: #FFFFFF; |
||||||
|
--c-bg-app-ios: #F5F7F9; |
||||||
|
--c-bg-app-and: #F4FBF8; |
||||||
|
|
||||||
|
--c-border: #EAF0F3; |
||||||
|
--c-border-strong: #D8E1E6; |
||||||
|
--c-divider: #F0F4F6; |
||||||
|
|
||||||
|
--c-fg-1: #17242E; |
||||||
|
--c-fg-2: #3E4C5D; |
||||||
|
--c-fg-3: #6B7A89; |
||||||
|
--c-fg-4: #9AA7B4; |
||||||
|
|
||||||
|
--font-base: 'Manrope', system-ui, sans-serif; |
||||||
|
--font-narrow: 'Oswald', sans-serif; |
||||||
|
|
||||||
|
--r-sm: 8px; |
||||||
|
--r-md: 12px; |
||||||
|
--r-lg: 16px; |
||||||
|
--r-xl: 20px; |
||||||
|
--r-2xl: 24px; |
||||||
|
|
||||||
|
--sh-sm: 0 1px 2px rgba(15, 42, 55, 0.04), 0 2px 6px rgba(15, 42, 55, 0.04); |
||||||
|
--sh-md: 0 2px 8px rgba(15, 42, 55, 0.06), 0 8px 24px rgba(15, 42, 55, 0.06); |
||||||
|
--sh-lg: 0 4px 16px rgba(15, 42, 55, 0.08), 0 16px 40px rgba(15, 42, 55, 0.08); |
||||||
|
} |
||||||
|
|
||||||
|
[data-density="compact"] { |
||||||
|
--pad-card: 12px; |
||||||
|
--pad-row: 10px 14px; |
||||||
|
--gap-xs: 8px; |
||||||
|
--gap-sm: 10px; |
||||||
|
--gap-md: 14px; |
||||||
|
--gap-lg: 18px; |
||||||
|
} |
||||||
|
[data-density="spacious"] { |
||||||
|
--pad-card: 18px; |
||||||
|
--pad-row: 16px 18px; |
||||||
|
--gap-xs: 12px; |
||||||
|
--gap-sm: 16px; |
||||||
|
--gap-md: 22px; |
||||||
|
--gap-lg: 28px; |
||||||
|
} |
||||||
|
|
||||||
|
* { box-sizing: border-box; } |
||||||
|
|
||||||
|
html, body { |
||||||
|
margin: 0; padding: 0; |
||||||
|
font-family: var(--font-base); |
||||||
|
background: #EBEEF2; |
||||||
|
color: var(--c-fg-1); |
||||||
|
-webkit-font-smoothing: antialiased; |
||||||
|
-moz-osx-font-smoothing: grayscale; |
||||||
|
} |
||||||
|
body { overflow: hidden; } |
||||||
|
#root { width: 100vw; height: 100vh; } |
||||||
|
|
||||||
|
button { font-family: inherit; border: 0; background: none; cursor: pointer; padding: 0; } |
||||||
|
input, select, textarea { font-family: inherit; } |
||||||
|
|
||||||
|
.noscroll::-webkit-scrollbar { display: none; } |
||||||
|
.noscroll { scrollbar-width: none; } |
||||||
|
|
||||||
|
.seg { |
||||||
|
display: inline-flex; background: #EEF2F5; border-radius: 10px; padding: 3px; |
||||||
|
gap: 2px; |
||||||
|
} |
||||||
|
.seg button { |
||||||
|
padding: 7px 14px; font-size: 13px; font-weight: 700; color: var(--c-fg-3); |
||||||
|
border-radius: 8px; transition: all .2s; |
||||||
|
} |
||||||
|
.seg button.on { background: #fff; color: var(--c-primary-darker); box-shadow: var(--sh-sm); } |
||||||
|
|
||||||
|
.btn-p { |
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px; |
||||||
|
background: var(--c-primary); color: #fff; font-weight: 700; font-size: 15px; |
||||||
|
border-radius: 12px; padding: 14px 20px; transition: background .15s; |
||||||
|
} |
||||||
|
.btn-p:hover { background: var(--c-primary-dark); } |
||||||
|
.btn-p.block { width: 100%; } |
||||||
|
.btn-accent { background: var(--c-accent); } |
||||||
|
.btn-accent:hover { background: var(--c-accent-dark); } |
||||||
|
|
||||||
|
.btn-s { |
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px; |
||||||
|
background: var(--c-primary-100); color: var(--c-primary-darker); font-weight: 700; |
||||||
|
font-size: 14px; border-radius: 10px; padding: 10px 14px; |
||||||
|
} |
||||||
|
.btn-s:hover { background: var(--c-primary-200); } |
||||||
|
|
||||||
|
.btn-g { |
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px; |
||||||
|
background: #fff; color: var(--c-fg-2); font-weight: 600; |
||||||
|
font-size: 14px; border-radius: 10px; padding: 10px 14px; |
||||||
|
border: 1px solid var(--c-border); |
||||||
|
} |
||||||
|
|
||||||
|
.chip { |
||||||
|
display: inline-flex; align-items: center; gap: 6px; |
||||||
|
padding: 5px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; |
||||||
|
background: var(--c-primary-100); color: var(--c-primary-darker); |
||||||
|
} |
||||||
|
.chip-warm { background: var(--c-warm-100); color: var(--c-warm-text); } |
||||||
|
.chip-soft { background: var(--c-bg); color: var(--c-fg-3); border: 1px solid var(--c-border); font-weight: 500; } |
||||||
|
.chip-success { background: var(--c-success-50); color: var(--c-success); } |
||||||
|
.chip-danger { background: var(--c-accent-50); color: var(--c-accent); } |
||||||
|
|
||||||
|
.card { |
||||||
|
background: #fff; border-radius: var(--r-lg); padding: var(--pad-card, 16px); |
||||||
|
box-shadow: var(--sh-sm); |
||||||
|
} |
||||||
|
|
||||||
|
.h-screen { font-size: 28px; font-weight: 700; letter-spacing: -0.3px; color: var(--c-fg-1); margin: 0; line-height: 1.15; } |
||||||
|
.h-sec { font-size: 17px; font-weight: 700; color: var(--c-fg-1); margin: 0; } |
||||||
|
.h-row { font-size: 15px; font-weight: 700; color: var(--c-fg-1); margin: 0; } |
||||||
|
.sub { font-size: 13px; color: var(--c-fg-3); } |
||||||
|
.mute { color: var(--c-fg-3); } |
||||||
|
.price { font-family: var(--font-narrow); font-weight: 700; color: var(--c-fg-1); } |
||||||
|
|
||||||
|
.avatar { |
||||||
|
display: inline-flex; align-items: center; justify-content: center; |
||||||
|
font-weight: 700; color: var(--c-primary-darker); |
||||||
|
background: linear-gradient(135deg, #E3F4F2, #B5E3DE); |
||||||
|
border-radius: 50%; flex-shrink: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .5; display: inline-block; vertical-align: middle; } |
||||||
|
|
||||||
|
.tabbar { |
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; |
||||||
|
background: rgba(255,255,255,0.92); |
||||||
|
backdrop-filter: blur(18px) saturate(180%); |
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(180%); |
||||||
|
border-top: 1px solid var(--c-border); |
||||||
|
padding: 8px 4px 22px; |
||||||
|
display: flex; justify-content: space-around; align-items: flex-start; |
||||||
|
z-index: 40; |
||||||
|
} |
||||||
|
.tab { |
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; |
||||||
|
font-size: 10px; font-weight: 600; color: var(--c-fg-4); padding: 4px 2px; |
||||||
|
transition: color .15s; |
||||||
|
} |
||||||
|
.tab.on { color: var(--c-primary-darker); } |
||||||
|
|
||||||
|
.pills { display: flex; gap: 8px; overflow-x: auto; padding: 2px 16px; } |
||||||
|
.pills::-webkit-scrollbar { display: none; } |
||||||
|
.pill { |
||||||
|
flex-shrink: 0; padding: 8px 14px; border-radius: 999px; font-size: 13px; |
||||||
|
font-weight: 600; background: #fff; color: var(--c-fg-2); border: 1px solid var(--c-border); |
||||||
|
} |
||||||
|
.pill.on { background: var(--c-primary-darker); color: #fff; border-color: var(--c-primary-darker); } |
||||||
|
|
||||||
|
.press { transition: transform .12s ease, opacity .12s ease; } |
||||||
|
.press:active { transform: scale(0.98); opacity: .9; } |
||||||
|
|
||||||
|
.divider { height: 1px; background: var(--c-divider); margin: 0; border: 0; } |
||||||
|
|
||||||
|
.stat-big { |
||||||
|
font-family: var(--font-narrow); font-weight: 700; font-size: 28px; color: var(--c-primary-darker); |
||||||
|
line-height: 1; |
||||||
|
} |
||||||
|
|
||||||
|
/* Stage – centers a single phone OR grid of phones */ |
||||||
|
.stage { |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
overflow: auto; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
padding: 28px; |
||||||
|
box-sizing: border-box; |
||||||
|
background: |
||||||
|
radial-gradient(1200px 600px at 20% 0%, #F2EADA 0%, transparent 60%), |
||||||
|
radial-gradient(1000px 500px at 90% 100%, #E3F1EE 0%, transparent 50%), |
||||||
|
#EDEFF3; |
||||||
|
} |
||||||
|
.stage.grid-mode { align-items: flex-start; padding-top: 28px; padding-bottom: 40px; overflow: auto; } |
||||||
|
|
||||||
|
.phones-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fit, 410px); |
||||||
|
gap: 32px 28px; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
.phone-cell { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: center; |
||||||
|
gap: 10px; |
||||||
|
} |
||||||
|
.phone-cell .label { |
||||||
|
font-size: 13px; |
||||||
|
color: #4A5560; |
||||||
|
font-weight: 700; |
||||||
|
letter-spacing: .2px; |
||||||
|
} |
||||||
|
.phone-cell .sublabel { |
||||||
|
font-size: 11px; |
||||||
|
color: #8A95A2; |
||||||
|
} |
||||||
|
|
||||||
|
/* Tweaks panel */ |
||||||
|
.tweaks-panel { |
||||||
|
position: fixed; |
||||||
|
right: 18px; |
||||||
|
bottom: 18px; |
||||||
|
width: 300px; |
||||||
|
max-height: 80vh; |
||||||
|
overflow-y: auto; |
||||||
|
background: #fff; |
||||||
|
border-radius: 20px; |
||||||
|
box-shadow: 0 20px 60px rgba(15,30,40,0.25), 0 4px 16px rgba(15,30,40,0.08); |
||||||
|
padding: 16px; |
||||||
|
z-index: 100; |
||||||
|
font-size: 13px; |
||||||
|
} |
||||||
|
.tweaks-panel h3 { |
||||||
|
margin: 0 0 12px; |
||||||
|
font-size: 14px; |
||||||
|
font-weight: 700; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
} |
||||||
|
.tweaks-panel h3 button { |
||||||
|
background: transparent; |
||||||
|
border: 0; |
||||||
|
color: #8A95A2; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 18px; |
||||||
|
padding: 0; |
||||||
|
} |
||||||
|
.tweaks-section { margin-bottom: 14px; } |
||||||
|
.tweaks-section .label { |
||||||
|
font-size: 11px; |
||||||
|
text-transform: uppercase; |
||||||
|
letter-spacing: .6px; |
||||||
|
color: #8A95A2; |
||||||
|
font-weight: 700; |
||||||
|
margin-bottom: 6px; |
||||||
|
} |
||||||
|
.tweaks-options { |
||||||
|
display: flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
gap: 5px; |
||||||
|
} |
||||||
|
.tweaks-options button { |
||||||
|
padding: 6px 10px; |
||||||
|
border-radius: 8px; |
||||||
|
border: 1px solid #E4EAF2; |
||||||
|
background: #fff; |
||||||
|
font-size: 12px; |
||||||
|
font-weight: 600; |
||||||
|
color: #4A5560; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.tweaks-options button.on { |
||||||
|
background: var(--c-primary-darker); |
||||||
|
color: #fff; |
||||||
|
border-color: var(--c-primary-darker); |
||||||
|
} |
||||||
|
.tweaks-options select { |
||||||
|
flex: 1; |
||||||
|
padding: 7px 10px; |
||||||
|
border-radius: 8px; |
||||||
|
border: 1px solid #E4EAF2; |
||||||
|
font-size: 12px; |
||||||
|
font-weight: 600; |
||||||
|
background: #fff; |
||||||
|
} |
||||||
|
|
||||||
|
.tweaks-fab { |
||||||
|
position: fixed; |
||||||
|
right: 18px; |
||||||
|
bottom: 18px; |
||||||
|
width: 44px; |
||||||
|
height: 44px; |
||||||
|
border-radius: 999px; |
||||||
|
background: #fff; |
||||||
|
box-shadow: 0 4px 16px rgba(15,30,40,0.15); |
||||||
|
border: 0; |
||||||
|
cursor: pointer; |
||||||
|
z-index: 99; |
||||||
|
display: flex; align-items: center; justify-content: center; |
||||||
|
font-size: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.intro-banner { |
||||||
|
position: fixed; |
||||||
|
left: 50%; |
||||||
|
top: 18px; |
||||||
|
transform: translateX(-50%); |
||||||
|
background: #fff; |
||||||
|
border-radius: 14px; |
||||||
|
padding: 10px 16px; |
||||||
|
box-shadow: 0 8px 24px rgba(15,30,40,0.12); |
||||||
|
font-size: 13px; |
||||||
|
color: #4A5560; |
||||||
|
z-index: 50; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 10px; |
||||||
|
max-width: 90vw; |
||||||
|
} |
||||||
|
.intro-banner b { color: var(--c-primary-darker); } |
||||||
|
.intro-banner button { |
||||||
|
background: transparent; |
||||||
|
border: 0; |
||||||
|
cursor: pointer; |
||||||
|
color: #8A95A2; |
||||||
|
font-size: 18px; |
||||||
|
padding: 0 0 0 6px; |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes pulse { 0%{opacity:.5;transform:scale(.95)} 100%{opacity:0;transform:scale(1.15)} } |
||||||
|
@keyframes blink { 50% { opacity: .3 } } |
||||||
@ -0,0 +1,222 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { I } from './icons.jsx'; |
||||||
|
|
||||||
|
export function Avatar({ init, size = 44, style = {} }) { |
||||||
|
return ( |
||||||
|
<div className="avatar" style={{ width: size, height: size, fontSize: size * 0.38, ...style }}> |
||||||
|
{init} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function DoctorCard({ doc, variant = 'rich', onClick, dense }) { |
||||||
|
if (variant === 'list') { |
||||||
|
return ( |
||||||
|
<button onClick={onClick} className="press" style={{ |
||||||
|
display: 'flex', width: '100%', textAlign: 'left', |
||||||
|
background: '#fff', borderRadius: 14, padding: dense ? 12 : 14, |
||||||
|
gap: 12, alignItems: 'center', border: '1px solid var(--c-border)' |
||||||
|
}}> |
||||||
|
<Avatar init={doc.init} size={46} /> |
||||||
|
<div style={{ flex: 1, minWidth: 0 }}> |
||||||
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--c-fg-1)', marginBottom: 2, lineHeight: 1.25 }}>{doc.name}</div> |
||||||
|
<div style={{ fontSize: 13, color: 'var(--c-fg-3)', display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> |
||||||
|
<span>{doc.spec}</span> |
||||||
|
<span className="dot" /> |
||||||
|
<span>{doc.exp} лет</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ textAlign: 'right', flexShrink: 0 }}> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, justifyContent: 'flex-end', marginBottom: 4 }}> |
||||||
|
<I.star size={13} style={{ color: '#E8A13C' }} /> |
||||||
|
<span style={{ fontSize: 13, fontWeight: 700 }}>{doc.rating}</span> |
||||||
|
</div> |
||||||
|
<div className="price" style={{ fontSize: 15 }}>{doc.price} ₽</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
); |
||||||
|
} |
||||||
|
if (variant === 'rich') { |
||||||
|
return ( |
||||||
|
<button onClick={onClick} className="press" style={{ |
||||||
|
background: '#fff', borderRadius: 18, padding: dense ? 14 : 16, width: '100%', |
||||||
|
textAlign: 'left', boxShadow: 'var(--sh-sm)', border: '1px solid var(--c-border)', |
||||||
|
display: 'flex', flexDirection: 'column', gap: 12, |
||||||
|
}}> |
||||||
|
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-start' }}> |
||||||
|
<Avatar init={doc.init} size={56} /> |
||||||
|
<div style={{ flex: 1, minWidth: 0 }}> |
||||||
|
<div style={{ fontWeight: 700, fontSize: 16, color: 'var(--c-fg-1)', lineHeight: 1.25, marginBottom: 3 }}>{doc.name}</div> |
||||||
|
<div style={{ fontSize: 13, color: 'var(--c-fg-3)', marginBottom: 6 }}>{doc.spec}</div> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 12, color: 'var(--c-fg-3)' }}> |
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, color: 'var(--c-fg-2)' }}> |
||||||
|
<I.star size={12} style={{ color: '#E8A13C' }} /> |
||||||
|
<strong>{doc.rating}</strong> |
||||||
|
<span style={{ color: 'var(--c-fg-4)' }}>· {doc.reviews}</span> |
||||||
|
</span> |
||||||
|
<span className="dot" /> |
||||||
|
<span>{doc.exp} лет опыта</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{doc.kmn && <span className="chip" style={{ background: 'var(--c-warm-100)', color: 'var(--c-warm-text)', fontSize: 11, flexShrink: 0 }}>К.м.н.</span>} |
||||||
|
</div> |
||||||
|
<div style={{ |
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', |
||||||
|
paddingTop: 12, borderTop: '1px solid var(--c-divider)' |
||||||
|
}}> |
||||||
|
<div> |
||||||
|
<div style={{ fontSize: 11, color: 'var(--c-fg-4)', marginBottom: 2 }}>Ближайший приём</div> |
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--c-primary-darker)' }}>{doc.next}</div> |
||||||
|
</div> |
||||||
|
<div className="price" style={{ fontSize: 18 }}>{doc.price} ₽</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
); |
||||||
|
} |
||||||
|
return ( |
||||||
|
<button onClick={onClick} className="press" style={{ |
||||||
|
width: '100%', textAlign: 'left', borderRadius: 20, overflow: 'hidden', |
||||||
|
background: '#fff', boxShadow: 'var(--sh-sm)', display: 'flex', flexDirection: 'column', |
||||||
|
}}> |
||||||
|
<div style={{ |
||||||
|
aspectRatio: '5/4', background: 'linear-gradient(180deg,#E3F4F2 0%,#B5E3DE 100%)', |
||||||
|
position: 'relative', display: 'flex', alignItems: 'flex-end', justifyContent: 'center', |
||||||
|
color: 'var(--c-primary-darker)', fontWeight: 700, fontSize: 48, fontFamily: 'var(--font-narrow)' |
||||||
|
}}> |
||||||
|
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: .5 }}> |
||||||
|
{doc.init} |
||||||
|
</div> |
||||||
|
{doc.kmn && <div style={{ position: 'absolute', top: 10, left: 10, padding: '4px 8px', borderRadius: 6, background: 'rgba(255,255,255,0.9)', color: 'var(--c-warm-text)', fontSize: 11, fontWeight: 700 }}>К.м.н.</div>} |
||||||
|
<div style={{ position: 'absolute', top: 10, right: 10, padding: '4px 8px', borderRadius: 999, background: 'rgba(255,255,255,0.9)', fontSize: 12, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 3 }}> |
||||||
|
<I.star size={12} style={{ color: '#E8A13C' }} /> |
||||||
|
{doc.rating} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ padding: 12 }}> |
||||||
|
<div style={{ fontWeight: 700, fontSize: 14, color: 'var(--c-fg-1)', lineHeight: 1.25, marginBottom: 3, minHeight: 35 }}>{doc.name.split(' ').slice(0, 2).join(' ')}</div> |
||||||
|
<div style={{ fontSize: 12, color: 'var(--c-fg-3)', marginBottom: 8 }}>{doc.spec}</div> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> |
||||||
|
<div className="price" style={{ fontSize: 15 }}>{doc.price} ₽</div> |
||||||
|
<I.chev size={16} style={{ color: 'var(--c-fg-4)' }} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function AppointmentCard({ appt, doctor, addr, onClick, compact = false }) { |
||||||
|
const isUpcoming = appt.status === 'upcoming'; |
||||||
|
return ( |
||||||
|
<button onClick={onClick} className="press" style={{ |
||||||
|
width: '100%', textAlign: 'left', |
||||||
|
background: isUpcoming ? 'linear-gradient(135deg,#E3F4F2 0%,#F2FAF9 100%)' : '#fff', |
||||||
|
borderRadius: 18, padding: 16, border: isUpcoming ? '1px solid var(--c-primary-200)' : '1px solid var(--c-border)', |
||||||
|
display: 'flex', gap: 14, alignItems: 'flex-start', |
||||||
|
}}> |
||||||
|
<div style={{ |
||||||
|
width: 50, height: 58, flexShrink: 0, |
||||||
|
background: isUpcoming ? 'var(--c-primary-darker)' : '#fff', |
||||||
|
color: isUpcoming ? '#fff' : 'var(--c-fg-2)', |
||||||
|
border: isUpcoming ? 0 : '1px solid var(--c-border)', |
||||||
|
borderRadius: 12, padding: '8px 6px', textAlign: 'center', |
||||||
|
}}> |
||||||
|
<div style={{ fontFamily: 'var(--font-narrow)', fontSize: 22, fontWeight: 700, lineHeight: 1 }}>{appt.date.split(' ')[0]}</div> |
||||||
|
<div style={{ fontSize: 11, marginTop: 2, opacity: .85 }}>{appt.date.split(' ')[1]}</div> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1, minWidth: 0 }}> |
||||||
|
<div style={{ fontSize: 12, color: isUpcoming ? 'var(--c-primary-darker)' : 'var(--c-fg-3)', fontWeight: 700, marginBottom: 3, display: 'flex', alignItems: 'center', gap: 6 }}> |
||||||
|
<I.clock size={13} /> |
||||||
|
{appt.time} · {appt.weekday} |
||||||
|
</div> |
||||||
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--c-fg-1)', marginBottom: 2, lineHeight: 1.25 }}>{doctor.name.split(' ').slice(0, 2).join(' ')}</div> |
||||||
|
<div style={{ fontSize: 13, color: 'var(--c-fg-3)', marginBottom: 6 }}>{doctor.spec}</div> |
||||||
|
{!compact && ( |
||||||
|
<div style={{ fontSize: 12, color: 'var(--c-fg-3)', display: 'flex', alignItems: 'center', gap: 5 }}> |
||||||
|
<I.pin size={12} /> |
||||||
|
{addr.short} · {appt.room} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
{isUpcoming && <I.chev size={18} style={{ color: 'var(--c-fg-4)', flexShrink: 0 }} />} |
||||||
|
{!isUpcoming && appt.hasReport && <span className="chip chip-soft" style={{ flexShrink: 0 }}>Заключение</span>} |
||||||
|
</button> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function SectionHeader({ title, action, onAction, pad = '0 20px' }) { |
||||||
|
return ( |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: pad, marginBottom: 12 }}> |
||||||
|
<h2 className="h-sec">{title}</h2> |
||||||
|
{action && <button onClick={onAction} style={{ fontSize: 13, color: 'var(--c-primary-dark)', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 3 }}>{action} <I.chev size={14} /></button>} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function TabBar({ active, onChange, platform = 'ios' }) { |
||||||
|
const tabs = [ |
||||||
|
{ id: 'home', label: 'Главная', icon: I.home }, |
||||||
|
{ id: 'appts', label: 'Приёмы', icon: I.calendar }, |
||||||
|
{ id: 'doctors', label: 'Врачи', icon: I.stetho }, |
||||||
|
{ id: 'chat', label: 'Чат', icon: I.chat, badge: 2 }, |
||||||
|
{ id: 'profile', label: 'Профиль', icon: I.profile }, |
||||||
|
]; |
||||||
|
return ( |
||||||
|
<div className="tabbar" style={{ paddingBottom: platform === 'ios' ? 30 : 14 }}> |
||||||
|
{tabs.map(t => { |
||||||
|
const IconC = t.icon; |
||||||
|
const on = active === t.id; |
||||||
|
return ( |
||||||
|
<button key={t.id} onClick={() => onChange(t.id)} className={'tab' + (on ? ' on' : '')}> |
||||||
|
<div style={{ position: 'relative' }}> |
||||||
|
<IconC size={22} sw={on ? 2 : 1.75} /> |
||||||
|
{t.badge > 0 && ( |
||||||
|
<span style={{ |
||||||
|
position: 'absolute', top: -3, right: -6, |
||||||
|
background: 'var(--c-accent)', color: '#fff', |
||||||
|
fontSize: 10, fontWeight: 700, |
||||||
|
minWidth: 16, height: 16, borderRadius: 999, |
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', |
||||||
|
padding: '0 4px', |
||||||
|
}}>{t.badge}</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<span>{t.label}</span> |
||||||
|
</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function ScreenHeader({ title, subtitle, onBack, rightIcon, onRight, center = false }) { |
||||||
|
return ( |
||||||
|
<div style={{ |
||||||
|
display: 'flex', alignItems: 'center', padding: '12px 16px 8px', |
||||||
|
background: 'transparent', |
||||||
|
gap: 12, position: 'sticky', top: 0, zIndex: 10, |
||||||
|
}}> |
||||||
|
{onBack && ( |
||||||
|
<button onClick={onBack} className="press" style={{ |
||||||
|
width: 38, height: 38, borderRadius: 999, background: '#fff', |
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', |
||||||
|
boxShadow: 'var(--sh-sm)', border: '1px solid var(--c-border)' |
||||||
|
}}> |
||||||
|
<I.chevL size={20} style={{ color: 'var(--c-fg-1)' }} /> |
||||||
|
</button> |
||||||
|
)} |
||||||
|
<div style={{ flex: 1, textAlign: center ? 'center' : 'left' }}> |
||||||
|
{subtitle && <div className="sub" style={{ marginBottom: 2 }}>{subtitle}</div>} |
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--c-fg-1)' }}>{title}</div> |
||||||
|
</div> |
||||||
|
{rightIcon && ( |
||||||
|
<button onClick={onRight} className="press" style={{ |
||||||
|
width: 38, height: 38, borderRadius: 999, background: '#fff', |
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', |
||||||
|
boxShadow: 'var(--sh-sm)', border: '1px solid var(--c-border)' |
||||||
|
}}> |
||||||
|
{React.createElement(rightIcon, { size: 18, style: { color: 'var(--c-fg-1)' } })} |
||||||
|
</button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,88 @@ |
|||||||
|
// Реальные данные с сайта Клиники УГН им. проф. Е.Н. Оленевой
|
||||||
|
// (oclinica.ru/lor) — врачи, услуги, цены.
|
||||||
|
|
||||||
|
export const CLINIC_DATA = { |
||||||
|
clinic: { |
||||||
|
name: 'Клиника УГН', |
||||||
|
full: 'Клиника ухо, горло, нос им. проф. Е.Н. Оленевой', |
||||||
|
phone: '(342) 207-03-03', |
||||||
|
hours: '9:00–21:00 ежедневно', |
||||||
|
addresses: [ |
||||||
|
{ id: 'tsetkin', short: 'К. Цеткин, 9', full: 'ул. Клары Цеткин, 9', note: 'Основная клиника + Центр сурдологии' }, |
||||||
|
{ id: 'zvezda', short: 'Газеты Звезда, 31а', full: 'ул. Газеты Звезда, 31а', note: 'Клиника лечения кашля и аллергии' }, |
||||||
|
{ id: 'krasnokamsk', short: 'Краснокамск', full: 'г. Краснокамск, филиал', note: 'Филиал' }, |
||||||
|
], |
||||||
|
}, |
||||||
|
doctors: [ |
||||||
|
{ id: 'makarova', init: 'МЛ', name: 'Макарова Людмила Германовна', spec: 'ЛОР-врач · Сурдолог', exp: 24, price: 1800, rating: 4.9, reviews: 312, kmn: false, next: 'Завтра, 10:30', address: 'tsetkin' }, |
||||||
|
{ id: 'semerikova',init: 'СН', name: 'Семерикова Наталия Александровна', spec: 'ЛОР-хирург · Сурдолог', exp: 28, price: 2400, rating: 5.0, reviews: 428, kmn: true, next: 'Сегодня, 16:00', address: 'tsetkin' }, |
||||||
|
{ id: 'voronchikhina', init: 'ВН', name: 'Ворончихина Наталия Валерьевна', spec: 'Отоневролог · Хирург', exp: 22, price: 2400, rating: 4.9, reviews: 201, kmn: true, next: '21 апр, 09:00', address: 'tsetkin' }, |
||||||
|
{ id: 'lobanova', init: 'ЛИ', name: 'Лобанова Ирина Юрьевна', spec: 'ЛОР-врач · Сурдолог', exp: 18, price: 1800, rating: 4.8, reviews: 256, kmn: false, next: 'Завтра, 14:15', address: 'tsetkin' }, |
||||||
|
{ id: 'torsunova', init: 'ТН', name: 'Торсунова Наталья Сергеевна', spec: 'Слухопротезирование', exp: 14, price: 1500, rating: 4.9, reviews: 98, kmn: false, next: '22 апр, 11:00', address: 'tsetkin' }, |
||||||
|
{ id: 'suvorova', init: 'СС', name: 'Суворова Светлана Викторовна', spec: 'ЛОР-врач · Сурдолог', exp: 16, price: 1800, rating: 4.8, reviews: 174, kmn: false, next: 'Сегодня, 18:30', address: 'zvezda' }, |
||||||
|
{ id: 'syndaev', init: 'СА', name: 'Синдяев Андрей Викторович', spec: 'ЛОР-хирург', exp: 21, price: 2200, rating: 5.0, reviews: 389, kmn: false, next: 'Завтра, 12:00', address: 'tsetkin' }, |
||||||
|
{ id: 'zykin', init: 'ЗО', name: 'Зыкин Олег Владимирович', spec: 'ЛОР-врач · Фониатр', exp: 19, price: 2000, rating: 4.9, reviews: 217, kmn: false, next: '21 апр, 15:30', address: 'tsetkin' }, |
||||||
|
], |
||||||
|
services: [ |
||||||
|
{ cat: 'Приёмы', name: 'Приём ЛОР-врача первичный', price: 1800 }, |
||||||
|
{ cat: 'Приёмы', name: 'Приём ЛОР-врача повторный', price: 1400 }, |
||||||
|
{ cat: 'Приёмы', name: 'Консультация кандидата мед. наук', price: 2400 }, |
||||||
|
{ cat: 'Приёмы', name: 'Приём детского ЛОР-врача', price: 1800 }, |
||||||
|
{ cat: 'Диагностика',name: 'Эндоскопия ЛОР-органов', price: 1200 }, |
||||||
|
{ cat: 'Диагностика',name: 'Аудиометрия', price: 1100 }, |
||||||
|
{ cat: 'Диагностика',name: 'Тимпанометрия', price: 800 }, |
||||||
|
{ cat: 'Процедуры', name: 'Промывание миндалин', price: 1200 }, |
||||||
|
{ cat: 'Процедуры', name: 'Промывание носа «Кукушка»', price: 900 }, |
||||||
|
{ cat: 'Операции', name: 'Аденотомия (эндоскопическая)', price: 28000 }, |
||||||
|
{ cat: 'Операции', name: 'Вазотомия нижних раковин', price: 22000 }, |
||||||
|
{ cat: 'Операции', name: 'Септопластика', price: 45000 }, |
||||||
|
{ cat: 'Операции', name: 'Тонзиллэктомия', price: 38000 }, |
||||||
|
], |
||||||
|
specializations: [ |
||||||
|
{ id: 'lor', icon: 'ear', label: 'ЛОР', count: 12 }, |
||||||
|
{ id: 'surdo', icon: 'hear', label: 'Сурдология', count: 5 }, |
||||||
|
{ id: 'allergo', icon: 'leaf', label: 'Аллергология', count: 3 }, |
||||||
|
{ id: 'phono', icon: 'mic', label: 'Фониатрия', count: 2 }, |
||||||
|
{ id: 'kids', icon: 'baby', label: 'Детский ЛОР', count: 7 }, |
||||||
|
{ id: 'surgery', icon: 'scalpel', label: 'Хирургия', count: 6 }, |
||||||
|
], |
||||||
|
appointments: [ |
||||||
|
{ id: 'a1', status: 'upcoming', doctor: 'semerikova', date: '21 апр', weekday: 'понедельник', time: '16:00', room: 'Каб. 204', address: 'tsetkin', type: 'Первичный приём' }, |
||||||
|
{ id: 'a2', status: 'upcoming', doctor: 'torsunova', date: '25 апр', weekday: 'пятница', time: '11:00', room: 'Каб. 118', address: 'tsetkin', type: 'Аудиометрия' }, |
||||||
|
{ id: 'a3', status: 'past', doctor: 'makarova', date: '8 апр', weekday: 'среда', time: '10:30', room: 'Каб. 202', address: 'tsetkin', type: 'Первичный приём', hasReport: true }, |
||||||
|
{ id: 'a4', status: 'past', doctor: 'zykin', date: '28 мар', weekday: 'пятница', time: '15:00', room: 'Каб. 210', address: 'tsetkin', type: 'Консультация', hasReport: true }, |
||||||
|
], |
||||||
|
results: [ |
||||||
|
{ id: 'r1', name: 'Аудиограмма', date: '8 апр 2026', doctor: 'makarova', status: 'ready', kind: 'audio' }, |
||||||
|
{ id: 'r2', name: 'Эндоскопия носоглотки', date: '8 апр 2026', doctor: 'makarova', status: 'ready', kind: 'image' }, |
||||||
|
{ id: 'r3', name: 'Общий анализ крови', date: '1 апр 2026', doctor: 'syndaev', status: 'ready', kind: 'lab' }, |
||||||
|
{ id: 'r4', name: 'Мазок из зева', date: '28 мар 2026',doctor: 'zykin', status: 'ready', kind: 'lab' }, |
||||||
|
{ id: 'r5', name: 'Посев на микрофлору', date: '20 апр 2026',doctor: 'syndaev', status: 'pending', kind: 'lab' }, |
||||||
|
], |
||||||
|
articles: [ |
||||||
|
{ tag: 'Дети', title: 'Как понять, что у ребёнка отит', mins: 4, author: 'Макарова Л.Г.' }, |
||||||
|
{ tag: 'Операции', title: 'Восстановление после септопластики', mins: 6, author: 'Синдяев А.В.' }, |
||||||
|
{ tag: 'Беременность', title: 'Безопасно лечим горло при беременности', mins: 5, author: 'Лобанова И.Ю.' }, |
||||||
|
{ tag: 'Слух', title: 'Когда пора проверить слух', mins: 3, author: 'Семерикова Н.А.' }, |
||||||
|
], |
||||||
|
recovery: { |
||||||
|
op: 'Септопластика', |
||||||
|
surgeon: 'syndaev', |
||||||
|
date: '12 апр 2026', |
||||||
|
dayNow: 6, |
||||||
|
totalDays: 14, |
||||||
|
steps: [ |
||||||
|
{ day: 0, title: 'День операции', done: true, note: 'Постельный режим, холод на переносицу' }, |
||||||
|
{ day: 1, title: '1 день — снятие тампонов',done: true, note: 'Контрольный осмотр хирурга' }, |
||||||
|
{ day: 3, title: '3 день — промывание', done: true, note: 'Солевой раствор 4 раза в день' }, |
||||||
|
{ day: 6, title: '6 день — осмотр', done: false, active: true, note: 'Осмотр хирурга, снятие корочек' }, |
||||||
|
{ day: 10, title: '10 день — контроль', done: false, note: 'Эндоскопия полости носа' }, |
||||||
|
{ day: 14, title: 'Выписка', done: false, note: 'Финальный осмотр' }, |
||||||
|
], |
||||||
|
meds: [ |
||||||
|
{ name: 'Аква Марис', freq: '4 раза в день', nextTake: '14:00', taken: 2, total: 4 }, |
||||||
|
{ name: 'Амоксиклав 625 мг', freq: '2 раза в день', nextTake: '20:00', taken: 1, total: 2 }, |
||||||
|
{ name: 'Нурофен', freq: 'при боли', nextTake: '—', taken: 0, total: 0 }, |
||||||
|
], |
||||||
|
}, |
||||||
|
}; |
||||||
@ -0,0 +1,74 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
const MD_C = { |
||||||
|
surface: '#f4fbf8', |
||||||
|
onSurface: '#171d1b', |
||||||
|
frameBorder: 'rgba(116,119,117,0.5)', |
||||||
|
}; |
||||||
|
|
||||||
|
function AndroidStatusBar({ dark = false }) { |
||||||
|
const c = dark ? '#fff' : MD_C.onSurface; |
||||||
|
return ( |
||||||
|
<div style={{ |
||||||
|
height: 40, display: 'flex', alignItems: 'center', |
||||||
|
justifyContent: 'space-between', padding: '0 16px', |
||||||
|
position: 'relative', flexShrink: 0, |
||||||
|
fontFamily: 'Roboto, system-ui, sans-serif', |
||||||
|
}}> |
||||||
|
<div style={{ width: 128, display: 'flex', alignItems: 'center', gap: 8 }}> |
||||||
|
<span style={{ fontSize: 14, fontWeight: 400, letterSpacing: 0.25, lineHeight: '20px', color: c }}>9:30</span> |
||||||
|
</div> |
||||||
|
<div style={{ |
||||||
|
position: 'absolute', left: '50%', top: 8, transform: 'translateX(-50%)', |
||||||
|
width: 24, height: 24, borderRadius: 100, background: '#2e2e2e', |
||||||
|
}} /> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}> |
||||||
|
<div style={{ display: 'flex', paddingRight: 2 }}> |
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}> |
||||||
|
<path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill={c}/> |
||||||
|
</svg> |
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}> |
||||||
|
<path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill={c}/> |
||||||
|
</svg> |
||||||
|
</div> |
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"> |
||||||
|
<rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill={c}/> |
||||||
|
<rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill={c}/> |
||||||
|
</svg> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function AndroidNavBar({ dark = false }) { |
||||||
|
return ( |
||||||
|
<div style={{ |
||||||
|
height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', |
||||||
|
flexShrink: 0, |
||||||
|
}}> |
||||||
|
<div style={{ |
||||||
|
width: 108, height: 4, borderRadius: 2, |
||||||
|
background: dark ? '#fff' : MD_C.onSurface, opacity: 0.4, |
||||||
|
}} /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function AndroidDevice({ children, width = 412, height = 892, dark = false }) { |
||||||
|
return ( |
||||||
|
<div style={{ |
||||||
|
width, height, borderRadius: 18, overflow: 'hidden', |
||||||
|
background: dark ? '#1d1b20' : MD_C.surface, |
||||||
|
border: `8px solid ${MD_C.frameBorder}`, |
||||||
|
boxShadow: '0 30px 80px rgba(0,0,0,0.25)', |
||||||
|
display: 'flex', flexDirection: 'column', boxSizing: 'border-box', |
||||||
|
position: 'relative', |
||||||
|
}}> |
||||||
|
<AndroidStatusBar dark={dark} /> |
||||||
|
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}> |
||||||
|
{children} |
||||||
|
</div> |
||||||
|
<AndroidNavBar dark={dark} /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
export function IOSStatusBar({ dark = false, time = '9:41' }) { |
||||||
|
const c = dark ? '#fff' : '#000'; |
||||||
|
return ( |
||||||
|
<div style={{ |
||||||
|
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center', |
||||||
|
padding: '21px 24px 19px', boxSizing: 'border-box', |
||||||
|
position: 'relative', zIndex: 20, width: '100%', |
||||||
|
}}> |
||||||
|
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}> |
||||||
|
<span style={{ |
||||||
|
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590, |
||||||
|
fontSize: 17, lineHeight: '22px', color: c, |
||||||
|
}}>{time}</span> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}> |
||||||
|
<svg width="19" height="12" viewBox="0 0 19 12"> |
||||||
|
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/> |
||||||
|
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/> |
||||||
|
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/> |
||||||
|
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/> |
||||||
|
</svg> |
||||||
|
<svg width="17" height="12" viewBox="0 0 17 12"> |
||||||
|
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/> |
||||||
|
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/> |
||||||
|
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/> |
||||||
|
</svg> |
||||||
|
<svg width="27" height="13" viewBox="0 0 27 13"> |
||||||
|
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/> |
||||||
|
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/> |
||||||
|
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/> |
||||||
|
</svg> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function IOSDevice({ children, width = 402, height = 874, dark = false }) { |
||||||
|
return ( |
||||||
|
<div style={{ |
||||||
|
width, height, borderRadius: 48, overflow: 'hidden', |
||||||
|
position: 'relative', background: dark ? '#000' : '#F2F2F7', |
||||||
|
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)', |
||||||
|
fontFamily: '-apple-system, system-ui, sans-serif', |
||||||
|
WebkitFontSmoothing: 'antialiased', |
||||||
|
}}> |
||||||
|
<div style={{ |
||||||
|
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)', |
||||||
|
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50, |
||||||
|
}} /> |
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}> |
||||||
|
<IOSStatusBar dark={dark} /> |
||||||
|
</div> |
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}> |
||||||
|
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>{children}</div> |
||||||
|
</div> |
||||||
|
<div style={{ |
||||||
|
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60, |
||||||
|
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end', |
||||||
|
paddingBottom: 8, pointerEvents: 'none', |
||||||
|
}}> |
||||||
|
<div style={{ |
||||||
|
width: 139, height: 5, borderRadius: 100, |
||||||
|
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)', |
||||||
|
}} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
// Outline icons — 24x24, 1.75 stroke. Нейтральный медицинский стиль. |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
const Icon = ({ d, size = 22, stroke = 'currentColor', fill = 'none', sw = 1.75, children, style }) => ( |
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill} stroke={stroke} strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" style={style}> |
||||||
|
{d ? <path d={d} /> : children} |
||||||
|
</svg> |
||||||
|
); |
||||||
|
|
||||||
|
export const I = { |
||||||
|
home: (p) => <Icon {...p}><path d="M3 10.5L12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/><path d="M10 21v-6h4v6"/></Icon>, |
||||||
|
calendar: (p) => <Icon {...p}><rect x="3.5" y="5" width="17" height="15" rx="2.5"/><path d="M8 3v4M16 3v4M3.5 10h17"/></Icon>, |
||||||
|
chat: (p) => <Icon {...p}><path d="M4 5h16a1 1 0 011 1v11a1 1 0 01-1 1H9l-4 3v-3H4a1 1 0 01-1-1V6a1 1 0 011-1z"/></Icon>, |
||||||
|
profile: (p) => <Icon {...p}><circle cx="12" cy="8.5" r="4"/><path d="M4 20.5c1.5-4 4.5-6 8-6s6.5 2 8 6"/></Icon>, |
||||||
|
phone: (p) => <Icon {...p}><path d="M5 4h3l2 5-2.5 1.5a12 12 0 006 6L15 14l5 2v3a2 2 0 01-2 2 16 16 0 01-13-13 2 2 0 012-2z"/></Icon>, |
||||||
|
pin: (p) => <Icon {...p}><path d="M12 22s7-6.5 7-12a7 7 0 10-14 0c0 5.5 7 12 7 12z"/><circle cx="12" cy="10" r="2.5"/></Icon>, |
||||||
|
clock: (p) => <Icon {...p}><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></Icon>, |
||||||
|
search: (p) => <Icon {...p}><circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4-4"/></Icon>, |
||||||
|
chev: (p) => <Icon {...p}><path d="M9 6l6 6-6 6"/></Icon>, |
||||||
|
chevL: (p) => <Icon {...p}><path d="M15 6l-6 6 6 6"/></Icon>, |
||||||
|
chevD: (p) => <Icon {...p}><path d="M6 9l6 6 6-6"/></Icon>, |
||||||
|
close: (p) => <Icon {...p}><path d="M6 6l12 12M18 6L6 18"/></Icon>, |
||||||
|
check: (p) => <Icon {...p}><path d="M5 12.5l4 4 10-10"/></Icon>, |
||||||
|
bell: (p) => <Icon {...p}><path d="M6 16V10a6 6 0 1112 0v6l1.5 2h-15L6 16z"/><path d="M10 19.5a2 2 0 004 0"/></Icon>, |
||||||
|
video: (p) => <Icon {...p}><rect x="3" y="6.5" width="12" height="11" rx="2"/><path d="M15 10l6-3v10l-6-3"/></Icon>, |
||||||
|
doc: (p) => <Icon {...p}><path d="M6 3h8l5 5v13a1 1 0 01-1 1H6a1 1 0 01-1-1V4a1 1 0 011-1z"/><path d="M14 3v5h5"/><path d="M8 13h7M8 17h7"/></Icon>, |
||||||
|
plus: (p) => <Icon {...p}><path d="M12 5v14M5 12h14"/></Icon>, |
||||||
|
heart: (p) => <Icon {...p}><path d="M12 20s-7-4.5-7-10a4 4 0 017-2.5A4 4 0 0119 10c0 5.5-7 10-7 10z"/></Icon>, |
||||||
|
star: (p) => <Icon fill="currentColor" stroke="none" {...p}><path d="M12 2.5l2.9 6 6.6.9-4.8 4.7 1.1 6.6L12 17.6l-5.9 3.1 1.1-6.6L2.5 9.4l6.6-.9z"/></Icon>, |
||||||
|
ear: (p) => <Icon {...p}><path d="M8.5 20c-2 0-3.5-1.5-3.5-3.5 0-1.5 1-2.5 1-4.5 0-4.5 3-7.5 7-7.5s7 2.5 7 6.5-3 5.5-5 5.5c-1.5 0-2 1-2 2s-.5 3-2.5 3-2-1.5-2-1.5"/></Icon>, |
||||||
|
hearing: (p) => <Icon {...p}><path d="M6 12c0-3.5 2.5-6 6-6s6 2.5 6 6"/><path d="M9 12c0-1.5 1.5-3 3-3s3 1.5 3 3"/><circle cx="12" cy="12" r="1"/><path d="M17 16l2 2M17 8l2-2"/></Icon>, |
||||||
|
stetho: (p) => <Icon {...p}><path d="M6 3v6a4 4 0 008 0V3"/><path d="M10 13v3a4 4 0 008 0v-2"/><circle cx="18" cy="9.5" r="1.5"/></Icon>, |
||||||
|
pill: (p) => <Icon {...p}><rect x="3" y="9" width="18" height="7" rx="3.5" transform="rotate(-30 12 12.5)"/><path d="M8 16l8-8"/></Icon>, |
||||||
|
qr: (p) => <Icon {...p}><rect x="3.5" y="3.5" width="6" height="6" rx="1"/><rect x="14.5" y="3.5" width="6" height="6" rx="1"/><rect x="3.5" y="14.5" width="6" height="6" rx="1"/><path d="M14.5 14.5h2v2M20.5 14.5v2M14.5 18.5h2M18.5 16.5v4M16.5 20.5h4"/></Icon>, |
||||||
|
card: (p) => <Icon {...p}><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M7 15h3"/></Icon>, |
||||||
|
file: (p) => <Icon {...p}><path d="M6 3h8l5 5v13H6z"/><path d="M14 3v5h5"/></Icon>, |
||||||
|
filter: (p) => <Icon {...p}><path d="M4 5h16M7 12h10M10 19h4"/></Icon>, |
||||||
|
mic: (p) => <Icon {...p}><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3"/></Icon>, |
||||||
|
user: (p) => <Icon {...p}><circle cx="12" cy="8" r="4"/><path d="M4 21c1-5 4-7 8-7s7 2 8 7"/></Icon>, |
||||||
|
shield: (p) => <Icon {...p}><path d="M12 3l8 3v6c0 5-4 8-8 9-4-1-8-4-8-9V6z"/></Icon>, |
||||||
|
gift: (p) => <Icon {...p}><rect x="3" y="9" width="18" height="12" rx="1"/><path d="M3 13h18M12 9v12M8 9a2.5 2.5 0 010-5c2 0 4 5 4 5M16 9a2.5 2.5 0 000-5c-2 0-4 5-4 5"/></Icon>, |
||||||
|
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>, |
||||||
|
}; |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import ReactDOM from 'react-dom/client'; |
||||||
|
import App from './App.jsx'; |
||||||
|
import './tokens.css'; |
||||||
|
import './app.css'; |
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render( |
||||||
|
<React.StrictMode> |
||||||
|
<App /> |
||||||
|
</React.StrictMode> |
||||||
|
); |
||||||
@ -0,0 +1,450 @@ |
|||||||
|
import React, { useState } from 'react'; |
||||||
|
import { I } from '../icons.jsx'; |
||||||
|
import { CLINIC_DATA } from '../data.js'; |
||||||
|
import { Avatar, DoctorCard, ScreenHeader } from '../components.jsx'; |
||||||
|
|
||||||
|
export function BookingSpecsScreen({ nav }) { |
||||||
|
const { specializations } = CLINIC_DATA; |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 100 }}> |
||||||
|
<ScreenHeader title="Запись на приём" subtitle="Шаг 1 из 4" onBack={() => nav.pop()} /> |
||||||
|
<div style={{ padding: '8px 20px 12px' }}> |
||||||
|
<div className="h-screen" style={{ marginBottom: 8 }}>Выберите направление</div> |
||||||
|
<div className="sub" style={{ marginBottom: 16 }}>27 ЛОР-врачей в клинике · 6 кандидатов мед. наук</div> |
||||||
|
|
||||||
|
<div className="seg" style={{ marginBottom: 18 }}> |
||||||
|
{['Взрослому', 'Ребёнку', 'Онлайн'].map((l, i) => ( |
||||||
|
<button key={i} className={i === 0 ? 'on' : ''}>{l}</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}> |
||||||
|
{specializations.map(s => { |
||||||
|
const icons = { ear: I.ear, hear: I.hearing, leaf: I.heart, mic: I.mic, baby: I.profile, scalpel: I.stetho }; |
||||||
|
const Ic = icons[s.icon]; |
||||||
|
return ( |
||||||
|
<button key={s.id} onClick={() => nav.push('booking-doctor:' + s.id)} className="press card" style={{ |
||||||
|
padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 12, |
||||||
|
textAlign: 'left', |
||||||
|
}}> |
||||||
|
<div style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<Ic size={24} style={{ color: 'var(--c-primary-darker)' }} /> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 2 }}>{s.label}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{s.count} специалистов</div> |
||||||
|
</div> |
||||||
|
<div style={{ fontSize: 13, color: 'var(--c-primary-darker)', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 4 }}> |
||||||
|
от 1500 ₽ <I.chev size={14} /> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function BookingDoctorScreen({ nav }) { |
||||||
|
const { doctors } = CLINIC_DATA; |
||||||
|
const [q, setQ] = useState(''); |
||||||
|
const filtered = doctors.filter(d => !q || d.name.toLowerCase().includes(q.toLowerCase()) || d.spec.toLowerCase().includes(q.toLowerCase())); |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 100 }}> |
||||||
|
<ScreenHeader title="Выберите врача" subtitle="Шаг 2 из 4" onBack={() => nav.pop()} rightIcon={I.filter} /> |
||||||
|
<div style={{ padding: '0 16px 12px' }}> |
||||||
|
<div style={{ |
||||||
|
background: '#fff', borderRadius: 14, padding: '10px 14px', |
||||||
|
display: 'flex', alignItems: 'center', gap: 10, |
||||||
|
border: '1px solid var(--c-border)', |
||||||
|
}}> |
||||||
|
<I.search size={18} style={{ color: 'var(--c-fg-4)' }} /> |
||||||
|
<input value={q} onChange={e => setQ(e.target.value)} placeholder="ФИО или специализация" style={{ |
||||||
|
flex: 1, border: 0, outline: 0, fontSize: 15, background: 'transparent', |
||||||
|
}} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="pills" style={{ marginBottom: 12 }}> |
||||||
|
{['Все','Свободно сегодня','Кандидаты наук','Хирурги','Детские'].map((p,i)=>( |
||||||
|
<button key={i} className={'pill' + (i===0?' on':'')}>{p}</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
<div style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 10 }}> |
||||||
|
{filtered.map(d => ( |
||||||
|
<DoctorCard key={d.id} doc={d} variant="rich" onClick={() => nav.push('booking-time:' + d.id)} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function BookingTimeScreen({ nav, doctorId }) { |
||||||
|
const doc = CLINIC_DATA.doctors.find(d => d.id === doctorId); |
||||||
|
const [selDate, setSelDate] = useState(1); |
||||||
|
const [selTime, setSelTime] = useState('16:00'); |
||||||
|
|
||||||
|
const dates = [ |
||||||
|
{ i: 0, d: 'Сегодня', n: '20', wd: 'вс' }, |
||||||
|
{ i: 1, d: 'Пн', n: '21', wd: 'пн' }, |
||||||
|
{ i: 2, d: 'Вт', n: '22', wd: 'вт' }, |
||||||
|
{ i: 3, d: 'Ср', n: '23', wd: 'ср' }, |
||||||
|
{ i: 4, d: 'Чт', n: '24', wd: 'чт' }, |
||||||
|
{ i: 5, d: 'Пт', n: '25', wd: 'пт' }, |
||||||
|
{ i: 6, d: 'Сб', n: '26', wd: 'сб' }, |
||||||
|
]; |
||||||
|
const slots = { |
||||||
|
morning: ['09:00','09:30','10:00','10:30','11:15','11:45'], |
||||||
|
day: ['12:00','13:30','14:00','14:30','15:15','15:45'], |
||||||
|
evening: ['16:00','16:30','17:15','18:00','18:30','19:15','20:00'], |
||||||
|
}; |
||||||
|
const booked = new Set(['10:30','14:00','18:00']); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 130 }}> |
||||||
|
<ScreenHeader title="Дата и время" subtitle="Шаг 3 из 4" onBack={() => nav.pop()} /> |
||||||
|
|
||||||
|
<div style={{ padding: '0 20px 16px' }}> |
||||||
|
<div className="card" style={{ display: 'flex', gap: 12, alignItems: 'center', padding: 14 }}> |
||||||
|
<Avatar init={doc.init} size={48} /> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700 }}>{doc.name.split(' ').slice(0,2).join(' ')}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{doc.spec}</div> |
||||||
|
</div> |
||||||
|
<div className="price" style={{ fontSize: 16 }}>{doc.price} ₽</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}> |
||||||
|
<div style={{ padding: '0 20px 10px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> |
||||||
|
<h2 className="h-sec">Апрель 2026</h2> |
||||||
|
<button style={{ color: 'var(--c-primary-darker)', fontSize: 13, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 4 }}> |
||||||
|
<I.calendar size={15} /> Календарь |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<div style={{ display: 'flex', gap: 8, padding: '0 16px', overflowX: 'auto' }} className="noscroll"> |
||||||
|
{dates.map(d => ( |
||||||
|
<button key={d.i} onClick={() => setSelDate(d.i)} className="press" style={{ |
||||||
|
flexShrink: 0, padding: '10px 4px', width: 54, borderRadius: 14, |
||||||
|
background: selDate === d.i ? 'var(--c-primary-darker)' : '#fff', |
||||||
|
color: selDate === d.i ? '#fff' : 'var(--c-fg-1)', |
||||||
|
border: selDate === d.i ? 0 : '1px solid var(--c-border)', |
||||||
|
textAlign: 'center', |
||||||
|
}}> |
||||||
|
<div style={{ fontSize: 11, opacity: .7, marginBottom: 3 }}>{d.d}</div> |
||||||
|
<div style={{ fontFamily: 'var(--font-narrow)', fontSize: 20, fontWeight: 700, lineHeight: 1 }}>{d.n}</div> |
||||||
|
<div style={{ fontSize: 10, marginTop: 4, opacity: selDate===d.i ? .7 : .5 }}> |
||||||
|
{selDate===d.i ? '12 окон' : '·'} |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ padding: '0 20px' }}> |
||||||
|
{Object.entries({ 'Утро': slots.morning, 'День': slots.day, 'Вечер': slots.evening }).map(([label, arr]) => ( |
||||||
|
<div key={label} style={{ marginBottom: 18 }}> |
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--c-fg-3)', textTransform: 'uppercase', letterSpacing: .5, marginBottom: 10 }}>{label}</div> |
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 8 }}> |
||||||
|
{arr.map(t => { |
||||||
|
const isBooked = booked.has(t); |
||||||
|
const sel = selTime === t; |
||||||
|
return ( |
||||||
|
<button key={t} disabled={isBooked} onClick={() => setSelTime(t)} className="press" style={{ |
||||||
|
padding: '11px 0', borderRadius: 10, fontSize: 14, fontWeight: 700, |
||||||
|
background: sel ? 'var(--c-primary-darker)' : isBooked ? 'var(--c-divider)' : '#fff', |
||||||
|
color: sel ? '#fff' : isBooked ? 'var(--c-fg-4)' : 'var(--c-fg-1)', |
||||||
|
border: sel ? 0 : '1px solid var(--c-border)', |
||||||
|
textDecoration: isBooked ? 'line-through' : 'none', |
||||||
|
cursor: isBooked ? 'not-allowed' : 'pointer', |
||||||
|
}}>{t}</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ |
||||||
|
position: 'absolute', left: 0, right: 0, bottom: 0, padding: '14px 20px 34px', |
||||||
|
background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(10px)', |
||||||
|
borderTop: '1px solid var(--c-border)', |
||||||
|
}}> |
||||||
|
<button onClick={() => nav.push('booking-confirm:' + doctorId + ':' + selDate + ':' + selTime)} className="btn-p block"> |
||||||
|
Выбрать · {dates[selDate].d}, {selTime} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function BookingConfirmScreen({ nav, doctorId, dateIdx, time }) { |
||||||
|
const doc = CLINIC_DATA.doctors.find(d => d.id === doctorId); |
||||||
|
const addr = CLINIC_DATA.clinic.addresses.find(a => a.id === doc.address); |
||||||
|
const [type, setType] = useState('offline'); |
||||||
|
const [comment, setComment] = useState(''); |
||||||
|
const dates = ['Сегодня, 20 апр','Пн, 21 апр','Вт, 22 апр','Ср, 23 апр','Чт, 24 апр','Пт, 25 апр','Сб, 26 апр']; |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 120 }}> |
||||||
|
<ScreenHeader title="Подтверждение" subtitle="Шаг 4 из 4" onBack={() => nav.pop()} /> |
||||||
|
<div style={{ padding: '0 20px' }}> |
||||||
|
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 16 }}> |
||||||
|
<div style={{ padding: 16, display: 'flex', gap: 12, alignItems: 'center' }}> |
||||||
|
<Avatar init={doc.init} size={50} /> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontWeight: 700, fontSize: 15 }}>{doc.name.split(' ').slice(0,2).join(' ')}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{doc.spec}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="divider" /> |
||||||
|
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between' }}> |
||||||
|
<span className="sub">Дата</span> |
||||||
|
<span style={{ fontSize: 14, fontWeight: 700 }}>{dates[dateIdx]}</span> |
||||||
|
</div> |
||||||
|
<div className="divider" /> |
||||||
|
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between' }}> |
||||||
|
<span className="sub">Время</span> |
||||||
|
<span style={{ fontSize: 14, fontWeight: 700 }}>{time}</span> |
||||||
|
</div> |
||||||
|
<div className="divider" /> |
||||||
|
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between' }}> |
||||||
|
<span className="sub">Адрес</span> |
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, textAlign: 'right' }}>{addr.full}</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="h-sec" style={{ marginBottom: 10 }}>Формат приёма</div> |
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 20 }}> |
||||||
|
{[ |
||||||
|
{ id: 'offline', lb: 'Очно', sub: 'В клинике', i: I.pin }, |
||||||
|
{ id: 'online', lb: 'Онлайн', sub: 'Видеосвязь', i: I.video }, |
||||||
|
].map(o => { |
||||||
|
const OIcon = o.i; |
||||||
|
return ( |
||||||
|
<button key={o.id} onClick={() => setType(o.id)} className="press" style={{ |
||||||
|
padding: 14, borderRadius: 14, |
||||||
|
background: type === o.id ? 'var(--c-primary-100)' : '#fff', |
||||||
|
border: `2px solid ${type === o.id ? 'var(--c-primary-darker)' : 'var(--c-border)'}`, |
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 8, textAlign: 'left', |
||||||
|
}}> |
||||||
|
<OIcon size={22} style={{ color: 'var(--c-primary-darker)' }} /> |
||||||
|
<div> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700 }}>{o.lb}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{o.sub}</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="h-sec" style={{ marginBottom: 10 }}>Комментарий для врача</div> |
||||||
|
<textarea value={comment} onChange={e => setComment(e.target.value)} placeholder="Опишите симптомы или задайте вопрос" style={{ |
||||||
|
width: '100%', padding: 14, borderRadius: 14, border: '1px solid var(--c-border)', |
||||||
|
fontSize: 14, resize: 'none', minHeight: 80, background: '#fff', outline: 'none', |
||||||
|
}} /> |
||||||
|
|
||||||
|
<div style={{ marginTop: 20, display: 'flex', alignItems: 'center', gap: 10, padding: 14, background: 'var(--c-warm-50)', borderRadius: 12 }}> |
||||||
|
<I.shield size={20} style={{ color: 'var(--c-warm-text)' }} /> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>Приём можно отменить или перенести не позднее чем за 3 часа</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '14px 20px 34px', background: '#fff', borderTop: '1px solid var(--c-border)' }}> |
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}> |
||||||
|
<span className="sub">К оплате в клинике</span> |
||||||
|
<span className="price" style={{ fontSize: 20 }}>{doc.price} ₽</span> |
||||||
|
</div> |
||||||
|
<button onClick={() => nav.push('booking-success')} className="btn-p block">Подтвердить запись</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function BookingSuccessScreen({ nav }) { |
||||||
|
return ( |
||||||
|
<div style={{ padding: '0 20px', height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}> |
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', padding: '20px 0' }}> |
||||||
|
<div style={{ |
||||||
|
width: 96, height: 96, borderRadius: 999, |
||||||
|
background: 'var(--c-primary-100)', |
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', |
||||||
|
marginBottom: 24, position: 'relative', |
||||||
|
}}> |
||||||
|
<div style={{ |
||||||
|
position: 'absolute', inset: -12, borderRadius: 999, |
||||||
|
background: 'var(--c-primary-100)', opacity: .4, |
||||||
|
}} /> |
||||||
|
<I.check size={56} style={{ color: 'var(--c-primary-darker)', position: 'relative' }} sw={3} /> |
||||||
|
</div> |
||||||
|
<h1 style={{ fontSize: 24, fontWeight: 700, margin: '0 0 8px', color: 'var(--c-fg-1)' }}>Вы записаны!</h1> |
||||||
|
<p style={{ fontSize: 15, color: 'var(--c-fg-3)', margin: '0 0 28px', maxWidth: 280 }}> |
||||||
|
Понедельник, 21 апреля в 16:00 к Семериковой Н.А. Напомним за 2 часа. |
||||||
|
</p> |
||||||
|
<div className="card" style={{ padding: 16, width: '100%', textAlign: 'left', marginBottom: 12 }}> |
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 10 }}> |
||||||
|
<I.pin size={18} style={{ color: 'var(--c-primary-darker)' }} /> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700 }}>ул. Клары Цеткин, 9</div> |
||||||
|
</div> |
||||||
|
<div className="sub" style={{ fontSize: 13, paddingLeft: 28 }}>Каб. 204 · 2 этаж</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ padding: '8px 0 24px', display: 'flex', flexDirection: 'column', gap: 8 }}> |
||||||
|
<button onClick={() => { nav.reset(); nav.set('appts'); }} className="btn-p block">К моим приёмам</button> |
||||||
|
<button onClick={() => nav.reset()} className="btn-g" style={{ width: '100%', padding: 14 }}>На главную</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function DoctorsTabScreen({ nav, ctx }) { |
||||||
|
const { doctors } = CLINIC_DATA; |
||||||
|
const [q, setQ] = useState(''); |
||||||
|
const [filter, setFilter] = useState('all'); |
||||||
|
const filtered = doctors.filter(d => !q || d.name.toLowerCase().includes(q.toLowerCase())); |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 100 }}> |
||||||
|
<div style={{ padding: '12px 20px 16px' }}> |
||||||
|
<h1 className="h-screen" style={{ marginBottom: 14 }}>Врачи</h1> |
||||||
|
<div style={{ background: '#fff', borderRadius: 14, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, border: '1px solid var(--c-border)' }}> |
||||||
|
<I.search size={18} style={{ color: 'var(--c-fg-4)' }} /> |
||||||
|
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Найти врача" style={{ flex: 1, border: 0, outline: 0, fontSize: 15, background: 'transparent' }} /> |
||||||
|
<I.filter size={18} style={{ color: 'var(--c-fg-3)' }} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="pills" style={{ marginBottom: 12 }}> |
||||||
|
{['Все','Свободно сегодня','Кандидаты наук','Хирурги','Детские','Сурдологи'].map((p,i)=>( |
||||||
|
<button key={i} onClick={() => setFilter(p)} className={'pill' + (filter===p ? ' on' : (i===0 && filter==='all' ? ' on' : ''))}>{p}</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
<div style={{ padding: '0 16px' }}> |
||||||
|
{ctx.docVariant === 'photo' ? ( |
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> |
||||||
|
{filtered.map(d => <DoctorCard key={d.id} doc={d} variant="photo" onClick={() => nav.push('doctor:' + d.id)} />)} |
||||||
|
</div> |
||||||
|
) : ctx.docVariant === 'list' ? ( |
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> |
||||||
|
{filtered.map(d => <DoctorCard key={d.id} doc={d} variant="list" onClick={() => nav.push('doctor:' + d.id)} dense={ctx.density==='compact'} />)} |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> |
||||||
|
{filtered.map(d => <DoctorCard key={d.id} doc={d} variant="rich" onClick={() => nav.push('doctor:' + d.id)} dense={ctx.density==='compact'} />)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function DoctorDetailScreen({ nav, doctorId }) { |
||||||
|
const doc = CLINIC_DATA.doctors.find(d => d.id === doctorId); |
||||||
|
const [tab, setTab] = useState('info'); |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 110 }}> |
||||||
|
<ScreenHeader title="" onBack={() => nav.pop()} rightIcon={I.heart} /> |
||||||
|
<div style={{ padding: '0 20px 16px' }}> |
||||||
|
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}> |
||||||
|
<Avatar init={doc.init} size={84} style={{ fontSize: 32 }} /> |
||||||
|
<div style={{ flex: 1, paddingTop: 6 }}> |
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2, marginBottom: 4 }}>{doc.name}</div> |
||||||
|
<div style={{ fontSize: 14, color: 'var(--c-fg-3)', marginBottom: 8 }}>{doc.spec}</div> |
||||||
|
{doc.kmn && <span className="chip chip-warm">Кандидат мед. наук</span>} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ padding: '0 20px 16px', display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 10 }}> |
||||||
|
<div className="card" style={{ textAlign: 'center', padding: 12 }}> |
||||||
|
<div className="stat-big">{doc.exp}</div> |
||||||
|
<div className="sub" style={{ fontSize: 11 }}>лет опыта</div> |
||||||
|
</div> |
||||||
|
<div className="card" style={{ textAlign: 'center', padding: 12 }}> |
||||||
|
<div className="stat-big" style={{ display: 'flex', justifyContent: 'center', alignItems: 'baseline', gap: 3 }}> |
||||||
|
{doc.rating}<I.star size={16} style={{ color: '#E8A13C' }} /> |
||||||
|
</div> |
||||||
|
<div className="sub" style={{ fontSize: 11 }}>{doc.reviews} отзыва</div> |
||||||
|
</div> |
||||||
|
<div className="card" style={{ textAlign: 'center', padding: 12 }}> |
||||||
|
<div className="stat-big">{doc.price}</div> |
||||||
|
<div className="sub" style={{ fontSize: 11 }}>₽ приём</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ padding: '0 20px 12px' }}> |
||||||
|
<div className="seg" style={{ width: '100%', display: 'flex' }}> |
||||||
|
{[['info','О враче'],['schedule','Расписание'],['reviews','Отзывы']].map(([id,lb]) => ( |
||||||
|
<button key={id} onClick={() => setTab(id)} className={tab===id?'on':''} style={{ flex: 1 }}>{lb}</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ padding: '0 20px' }}> |
||||||
|
{tab === 'info' && ( |
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> |
||||||
|
<div className="card"> |
||||||
|
<div className="h-row" style={{ marginBottom: 6 }}>Образование</div> |
||||||
|
<p style={{ margin: 0, fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.55 }}>Пермский государственный медицинский университет им. акад. Е.А. Вагнера, специальность «Оториноларингология».</p> |
||||||
|
</div> |
||||||
|
<div className="card"> |
||||||
|
<div className="h-row" style={{ marginBottom: 6 }}>Специализация</div> |
||||||
|
<p style={{ margin: 0, fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.55 }}>Эндоскопическая хирургия околоносовых пазух, лечение хронических отитов, слухопротезирование.</p> |
||||||
|
</div> |
||||||
|
<div className="card"> |
||||||
|
<div className="h-row" style={{ marginBottom: 10 }}>Услуги и цены</div> |
||||||
|
{[ |
||||||
|
['Первичный приём', doc.price], |
||||||
|
['Повторный приём', doc.price - 400], |
||||||
|
['Эндоскопия ЛОР-органов', 1200], |
||||||
|
].map(([n,p],i,a) => ( |
||||||
|
<div key={n}> |
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '10px 0' }}> |
||||||
|
<span style={{ fontSize: 14 }}>{n}</span> |
||||||
|
<span className="price" style={{ fontSize: 15 }}>{p} ₽</span> |
||||||
|
</div> |
||||||
|
{i < a.length - 1 && <div className="divider" />} |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
{tab === 'schedule' && ( |
||||||
|
<div className="card"> |
||||||
|
{['Пн 21 апр','Вт 22 апр','Ср 23 апр','Чт 24 апр'].map((d,i,a) => ( |
||||||
|
<div key={d}> |
||||||
|
<div style={{ padding: '12px 0' }}> |
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 8 }}>{d}</div> |
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> |
||||||
|
{['09:00','10:30','14:00','16:00','18:30'].slice(0, 5-i).map(t => ( |
||||||
|
<span key={t} style={{ padding: '5px 10px', borderRadius: 8, background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)', fontSize: 13, fontWeight: 700 }}>{t}</span> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{i < a.length - 1 && <div className="divider" />} |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
{tab === 'reviews' && ( |
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> |
||||||
|
{[ |
||||||
|
{ n: 'Елена К.', d: '12 апр', r: 5, t: 'Доктор очень внимательная, всё объяснила простыми словами. Дочке 6 лет, нашли общий язык быстро.' }, |
||||||
|
{ n: 'Михаил П.', d: '5 апр', r: 5, t: 'Профессионал высокого уровня. Операция прошла без осложнений, восстановление быстрое.' }, |
||||||
|
{ n: 'Ольга Н.', d: '28 мар', r: 4, t: 'Хороший специалист. Жаль, что приём ждать долго в регистратуре.' }, |
||||||
|
].map((r,i)=>( |
||||||
|
<div key={i} className="card"> |
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}> |
||||||
|
<div style={{ fontWeight: 700, fontSize: 14 }}>{r.n}</div> |
||||||
|
<div style={{ display: 'flex', gap: 1 }}>{[1,2,3,4,5].map(s => <I.star key={s} size={13} style={{ color: s<=r.r?'#E8A13C':'#E4EAF2' }}/>)}</div> |
||||||
|
</div> |
||||||
|
<div className="sub" style={{ fontSize: 11, marginBottom: 8 }}>{r.d}</div> |
||||||
|
<div style={{ fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>{r.t}</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '14px 20px 34px', background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(8px)', borderTop: '1px solid var(--c-border)' }}> |
||||||
|
<button onClick={() => nav.push('booking-time:' + doc.id)} className="btn-p block">Записаться · от {doc.price} ₽</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,294 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { I } from '../icons.jsx'; |
||||||
|
import { CLINIC_DATA } from '../data.js'; |
||||||
|
import { Avatar, AppointmentCard, SectionHeader } from '../components.jsx'; |
||||||
|
|
||||||
|
export function HomeCardsScreen({ nav }) { |
||||||
|
const { doctors, appointments, specializations, clinic, articles } = CLINIC_DATA; |
||||||
|
const upcoming = appointments.find(a => a.status === 'upcoming'); |
||||||
|
const upDoc = upcoming && doctors.find(d => d.id === upcoming.doctor); |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 100 }}> |
||||||
|
<div style={{ padding: '8px 20px 16px', background: 'linear-gradient(180deg,#F5EDDF 0%,transparent 100%)' }}> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}> |
||||||
|
<div> |
||||||
|
<div className="sub" style={{ marginBottom: 2 }}>Добрый день,</div> |
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, color: 'var(--c-fg-1)' }}>Анна Сергеевна</div> |
||||||
|
</div> |
||||||
|
<div style={{ display: 'flex', gap: 8 }}> |
||||||
|
<button onClick={() => nav.push('notifications')} className="press" style={{ width: 42, height: 42, borderRadius: 999, background: '#fff', border: '1px solid var(--c-border)', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}> |
||||||
|
<I.bell size={20} style={{ color: 'var(--c-fg-1)' }} /> |
||||||
|
<span style={{ position: 'absolute', top: 7, right: 9, width: 8, height: 8, borderRadius: 999, background: 'var(--c-accent)' }} /> |
||||||
|
</button> |
||||||
|
<button onClick={() => nav.push('qr')} className="press" style={{ width: 42, height: 42, borderRadius: 999, background: '#fff', border: '1px solid var(--c-border)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.qr size={20} style={{ color: 'var(--c-fg-1)' }} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button onClick={() => nav.push('booking-specs')} className="press" style={{ |
||||||
|
width: '100%', textAlign: 'left', |
||||||
|
background: 'var(--c-primary-darker)', color: '#fff', |
||||||
|
borderRadius: 20, padding: 18, |
||||||
|
display: 'flex', alignItems: 'center', gap: 14, |
||||||
|
boxShadow: '0 10px 30px rgba(22,107,99,.25)', |
||||||
|
}}> |
||||||
|
<div style={{ width: 48, height: 48, borderRadius: 14, background: 'rgba(255,255,255,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.plus size={26} /> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 2 }}>Записаться на приём</div> |
||||||
|
<div style={{ fontSize: 13, opacity: .8 }}>К ЛОР-врачу · сегодня или завтра</div> |
||||||
|
</div> |
||||||
|
<I.arrow size={22} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{upcoming && upDoc && ( |
||||||
|
<div style={{ padding: '16px 20px 8px' }}> |
||||||
|
<SectionHeader title="Ближайший приём" pad="0 0 0 0" action="Все" onAction={() => nav.set('appts')} /> |
||||||
|
<AppointmentCard appt={upcoming} doctor={upDoc} addr={clinic.addresses.find(a => a.id === upcoming.address)} onClick={() => nav.push('appt:' + upcoming.id)} /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<div style={{ padding: '16px 20px 8px' }}> |
||||||
|
<SectionHeader title="Специализации" pad="0 0 0 0" /> |
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 10 }}> |
||||||
|
{specializations.map(s => { |
||||||
|
const icons = { ear: I.ear, hear: I.hearing, leaf: I.heart, mic: I.mic, baby: I.profile, scalpel: I.stetho }; |
||||||
|
const Ic = icons[s.icon]; |
||||||
|
return ( |
||||||
|
<button key={s.id} onClick={() => nav.push('doctors')} className="press card" style={{ padding: 14, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 10 }}> |
||||||
|
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<Ic size={20} style={{ color: 'var(--c-primary-darker)' }} /> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 1 }}>{s.label}</div> |
||||||
|
<div className="sub" style={{ fontSize: 11 }}>{s.count} врачей</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ padding: '16px 20px 8px' }}> |
||||||
|
<SectionHeader title="Быстрые действия" pad="0 0 0 0" /> |
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 10 }}> |
||||||
|
<button onClick={() => nav.push('telemed')} className="press card" style={{ padding: 14, display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left' }}> |
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'var(--c-accent-50)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.video size={20} style={{ color: 'var(--c-accent)' }} /> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>Телемедицина</div> |
||||||
|
<div className="sub" style={{ fontSize: 11 }}>Видео с врачом</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
<button onClick={() => nav.push('audiotest')} className="press card" style={{ padding: 14, display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left' }}> |
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.hearing size={20} style={{ color: 'var(--c-primary-darker)' }} /> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>Тест слуха</div> |
||||||
|
<div className="sub" style={{ fontSize: 11 }}>За 3 минуты</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
<button onClick={() => nav.push('results')} className="press card" style={{ padding: 14, display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left' }}> |
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'var(--c-warm-100)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.doc size={20} style={{ color: 'var(--c-warm-text)' }} /> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>Анализы</div> |
||||||
|
<div className="sub" style={{ fontSize: 11 }}>5 результатов</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
<button onClick={() => nav.push('recovery')} className="press card" style={{ padding: 14, display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left' }}> |
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'var(--c-success-50)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.shield size={20} style={{ color: 'var(--c-success)' }} /> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>Восстановление</div> |
||||||
|
<div className="sub" style={{ fontSize: 11 }}>День 6 из 14</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ padding: '16px 0 8px' }}> |
||||||
|
<SectionHeader title="Статьи врачей" action="Все" onAction={() => nav.push('articles')} /> |
||||||
|
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', padding: '0 20px 8px' }} className="noscroll"> |
||||||
|
{articles.map((a, i) => ( |
||||||
|
<button key={i} className="press card" style={{ |
||||||
|
flexShrink: 0, width: 220, padding: 0, overflow: 'hidden', textAlign: 'left', |
||||||
|
}}> |
||||||
|
<div style={{ |
||||||
|
height: 90, background: ['#F5EDDF','#E3F4F2','#FDF8E6','#FCF1F0'][i % 4], |
||||||
|
display: 'flex', alignItems: 'flex-end', padding: 12, |
||||||
|
}}> |
||||||
|
<span className="chip chip-warm" style={{ background: 'rgba(255,255,255,0.85)' }}>{a.tag}</span> |
||||||
|
</div> |
||||||
|
<div style={{ padding: 12 }}> |
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--c-fg-1)', lineHeight: 1.3, marginBottom: 6, minHeight: 34 }}>{a.title}</div> |
||||||
|
<div className="sub" style={{ fontSize: 11 }}>{a.author} · {a.mins} мин</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function HomeListScreen({ nav }) { |
||||||
|
const { doctors, appointments, clinic } = CLINIC_DATA; |
||||||
|
const upcoming = appointments.find(a => a.status === 'upcoming'); |
||||||
|
const upDoc = upcoming && doctors.find(d => d.id === upcoming.doctor); |
||||||
|
const items = [ |
||||||
|
{ id: 'book', icon: I.plus, title: 'Записаться на приём', sub: 'ЛОР, сурдолог, фониатр', tint: 'var(--c-primary-darker)', bg: 'var(--c-primary-100)', cta: true, go: 'booking-specs' }, |
||||||
|
{ id: 'appt', icon: I.calendar, title: 'Мои приёмы', sub: `${appointments.filter(a=>a.status==='upcoming').length} предстоящих · ${appointments.filter(a=>a.status==='past').length} прошедших`, go: 'appts' }, |
||||||
|
{ id: 'card', icon: I.file, title: 'Медицинская карта', sub: 'История, результаты, заключения', go: 'medcard' }, |
||||||
|
{ id: 'results', icon: I.doc, title: 'Анализы и обследования', sub: '5 результатов · 1 в работе', go: 'results' }, |
||||||
|
{ id: 'telemed', icon: I.video, title: 'Видеоконсультация', sub: 'Онлайн с ЛОР-врачом', go: 'telemed' }, |
||||||
|
{ id: 'audio', icon: I.hearing, title: 'Тест слуха', sub: 'Аудиограмма за 3 минуты', go: 'audiotest' }, |
||||||
|
{ id: 'recovery', icon: I.shield, title: 'Восстановление', sub: 'Септопластика · день 6', go: 'recovery' }, |
||||||
|
{ id: 'chat', icon: I.chat, title: 'Чат с врачом', sub: '2 непрочитанных', badge: 2, go: 'chat' }, |
||||||
|
]; |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 100 }}> |
||||||
|
<div style={{ padding: '8px 20px 16px' }}> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}> |
||||||
|
<div> |
||||||
|
<div className="sub">Здравствуйте,</div> |
||||||
|
<div style={{ fontSize: 20, fontWeight: 700 }}>Анна Сергеевна</div> |
||||||
|
</div> |
||||||
|
<button onClick={() => nav.push('notifications')} className="press" style={{ width: 40, height: 40, borderRadius: 999, background: '#fff', border: '1px solid var(--c-border)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.bell size={18} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{upcoming && upDoc && ( |
||||||
|
<div style={{ marginBottom: 18 }}> |
||||||
|
<AppointmentCard appt={upcoming} doctor={upDoc} addr={clinic.addresses.find(a=>a.id===upcoming.address)} onClick={() => nav.push('appt:' + upcoming.id)} compact /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<div className="card" style={{ margin: '0 16px', padding: 0, overflow: 'hidden' }}> |
||||||
|
{items.map((it, i) => ( |
||||||
|
<React.Fragment key={it.id}> |
||||||
|
<button onClick={() => nav.push(it.go)} className="press" style={{ |
||||||
|
width: '100%', display: 'flex', alignItems: 'center', gap: 14, |
||||||
|
padding: '14px 16px', textAlign: 'left', |
||||||
|
background: it.cta ? 'var(--c-primary-50)' : 'transparent', |
||||||
|
}}> |
||||||
|
<div style={{ |
||||||
|
width: 40, height: 40, borderRadius: 10, |
||||||
|
background: it.bg || 'var(--c-primary-100)', |
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, |
||||||
|
}}> |
||||||
|
{React.createElement(it.icon, { size: 20, style: { color: it.tint || 'var(--c-primary-darker)' } })} |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1, minWidth: 0 }}> |
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 2 }}>{it.title}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{it.sub}</div> |
||||||
|
</div> |
||||||
|
{it.badge && <span style={{ background: 'var(--c-accent)', color: '#fff', fontSize: 11, fontWeight: 700, padding: '2px 7px', borderRadius: 999 }}>{it.badge}</span>} |
||||||
|
<I.chev size={16} style={{ color: 'var(--c-fg-4)' }} /> |
||||||
|
</button> |
||||||
|
{i < items.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 70 }} />} |
||||||
|
</React.Fragment> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function HomeFeedScreen({ nav }) { |
||||||
|
const { doctors, appointments, clinic, articles, recovery } = CLINIC_DATA; |
||||||
|
const upcoming = appointments.find(a => a.status === 'upcoming'); |
||||||
|
const upDoc = upcoming && doctors.find(d => d.id === upcoming.doctor); |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 100 }}> |
||||||
|
<div style={{ padding: '8px 20px 12px' }}> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> |
||||||
|
<div> |
||||||
|
<div className="sub">18 апреля, суббота</div> |
||||||
|
<div style={{ fontSize: 24, fontWeight: 700 }}>Как Ваше самочувствие?</div> |
||||||
|
</div> |
||||||
|
<button onClick={() => nav.push('profile')} className="press"> |
||||||
|
<Avatar init="АС" size={42} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ padding: '0 16px' }}> |
||||||
|
<button onClick={() => nav.push('recovery')} className="press card" style={{ |
||||||
|
width: '100%', textAlign: 'left', padding: 18, marginBottom: 14, |
||||||
|
background: 'linear-gradient(135deg,#E3F4F2,#F2FAF9)', |
||||||
|
border: '1px solid var(--c-primary-200)', |
||||||
|
}}> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> |
||||||
|
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--c-primary-darker)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.shield size={18} style={{ color: '#fff' }} /> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<div style={{ fontWeight: 700, fontSize: 14 }}>Восстановление</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{recovery.op}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<span className="chip">День {recovery.dayNow} из {recovery.totalDays}</span> |
||||||
|
</div> |
||||||
|
<div style={{ height: 6, background: 'rgba(255,255,255,0.7)', borderRadius: 999, overflow: 'hidden', marginBottom: 12 }}> |
||||||
|
<div style={{ width: `${recovery.dayNow/recovery.totalDays*100}%`, height: '100%', background: 'var(--c-primary-darker)', borderRadius: 999 }} /> |
||||||
|
</div> |
||||||
|
<div style={{ fontSize: 13, color: 'var(--c-fg-2)' }}> |
||||||
|
Сегодня: <strong>осмотр хирурга, снятие корочек</strong> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
|
||||||
|
<div className="card" style={{ padding: 14, marginBottom: 14, display: 'flex', gap: 12, alignItems: 'center' }}> |
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'var(--c-warning-50)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.pill size={20} style={{ color: 'var(--c-warning)' }} /> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700 }}>Амоксиклав 625 мг</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>Принять в 20:00 — через 2 часа</div> |
||||||
|
</div> |
||||||
|
<button className="btn-s" style={{ padding: '8px 12px', fontSize: 13 }}>Принял</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{upcoming && upDoc && ( |
||||||
|
<> |
||||||
|
<SectionHeader title="Ближайший приём" pad="4px 4px 8px" /> |
||||||
|
<AppointmentCard appt={upcoming} doctor={upDoc} addr={clinic.addresses.find(a=>a.id===upcoming.address)} onClick={() => nav.push('appt:' + upcoming.id)} /> |
||||||
|
</> |
||||||
|
)} |
||||||
|
|
||||||
|
<button onClick={() => nav.push('booking-specs')} className="press" style={{ |
||||||
|
width: '100%', marginTop: 14, padding: '14px 18px', borderRadius: 16, |
||||||
|
background: 'var(--c-accent)', color: '#fff', fontWeight: 700, fontSize: 15, |
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, |
||||||
|
}}> |
||||||
|
<I.plus size={20} /> Записаться на приём |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style={{ padding: '18px 20px 8px' }}> |
||||||
|
<SectionHeader title="Полезно прочитать" pad="0 0 0 0" /> |
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> |
||||||
|
{articles.slice(0,3).map((a,i)=>( |
||||||
|
<button key={i} className="press card" style={{ display: 'flex', gap: 12, alignItems: 'center', padding: 14, textAlign: 'left' }}> |
||||||
|
<div style={{ width: 56, height: 56, borderRadius: 12, background: ['#F5EDDF','#E3F4F2','#FDF8E6'][i%3], flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24 }}> |
||||||
|
{['📖','👂','🤱'][i%3]} |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontSize: 11, color: 'var(--c-primary-dark)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, marginBottom: 3 }}>{a.tag}</div> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, lineHeight: 1.3, marginBottom: 3 }}>{a.title}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{a.author} · {a.mins} мин</div> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,757 @@ |
|||||||
|
import React, { useState, useEffect } from 'react'; |
||||||
|
import { I } from '../icons.jsx'; |
||||||
|
import { CLINIC_DATA } from '../data.js'; |
||||||
|
import { Avatar, AppointmentCard, ScreenHeader } from '../components.jsx'; |
||||||
|
|
||||||
|
export function ApptsTabScreen({ nav }) { |
||||||
|
const { appointments, doctors, clinic } = CLINIC_DATA; |
||||||
|
const [tab, setTab] = useState('upcoming'); |
||||||
|
const items = appointments.filter(a => a.status === tab); |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 100 }}> |
||||||
|
<div style={{ padding: '12px 20px 12px' }}> |
||||||
|
<h1 className="h-screen" style={{ marginBottom: 14 }}>Мои приёмы</h1> |
||||||
|
<div className="seg" style={{ width: '100%', display: 'flex' }}> |
||||||
|
<button onClick={() => setTab('upcoming')} className={tab==='upcoming'?'on':''} style={{ flex: 1 }}>Предстоящие · {appointments.filter(a=>a.status==='upcoming').length}</button> |
||||||
|
<button onClick={() => setTab('past')} className={tab==='past'?'on':''} style={{ flex: 1 }}>Прошедшие</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 10 }}> |
||||||
|
{items.map(a => { |
||||||
|
const d = doctors.find(x => x.id === a.doctor); |
||||||
|
const ad = clinic.addresses.find(x => x.id === a.address); |
||||||
|
return <AppointmentCard key={a.id} appt={a} doctor={d} addr={ad} onClick={() => nav.push('appt:' + a.id)} />; |
||||||
|
})} |
||||||
|
{items.length === 0 && ( |
||||||
|
<div style={{ textAlign: 'center', padding: '40px 20px' }}> |
||||||
|
<div style={{ fontSize: 48, opacity: .3, marginBottom: 8 }}>📋</div> |
||||||
|
<div className="sub">Нет приёмов в этой категории</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<div style={{ padding: 16, marginTop: 12 }}> |
||||||
|
<button onClick={() => nav.push('booking-specs')} className="btn-p block"> |
||||||
|
<I.plus size={18} /> Записаться на приём |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function ApptDetailScreen({ nav, apptId }) { |
||||||
|
const a = CLINIC_DATA.appointments.find(x => x.id === apptId); |
||||||
|
const d = CLINIC_DATA.doctors.find(x => x.id === a.doctor); |
||||||
|
const ad = CLINIC_DATA.clinic.addresses.find(x => x.id === a.address); |
||||||
|
const isUp = a.status === 'upcoming'; |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 120 }}> |
||||||
|
<ScreenHeader title="Приём" onBack={() => nav.pop()} /> |
||||||
|
<div style={{ padding: '0 20px' }}> |
||||||
|
<div className="card" style={{ padding: 20, textAlign: 'center', marginBottom: 14, background: isUp ? 'linear-gradient(135deg,#E3F4F2,#F2FAF9)' : '#fff' }}> |
||||||
|
<div style={{ fontSize: 14, color: 'var(--c-primary-darker)', fontWeight: 700, marginBottom: 6 }}>{a.weekday}, {a.date}</div> |
||||||
|
<div style={{ fontSize: 42, fontFamily: 'var(--font-narrow)', fontWeight: 700, color: 'var(--c-fg-1)', lineHeight: 1, marginBottom: 8 }}>{a.time}</div> |
||||||
|
<div className="sub">{a.type}</div> |
||||||
|
</div> |
||||||
|
<div className="card" style={{ padding: 0, marginBottom: 14 }}> |
||||||
|
<button onClick={() => nav.push('doctor:' + d.id)} style={{ width: '100%', textAlign: 'left', padding: 14, display: 'flex', gap: 12, alignItems: 'center' }}> |
||||||
|
<Avatar init={d.init} size={48} /> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontWeight: 700, fontSize: 15 }}>{d.name.split(' ').slice(0,2).join(' ')}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{d.spec}</div> |
||||||
|
</div> |
||||||
|
<I.chev size={16} style={{ color: 'var(--c-fg-4)' }} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<div className="card" style={{ padding: 0 }}> |
||||||
|
<div style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}> |
||||||
|
<I.pin size={20} style={{ color: 'var(--c-primary-darker)' }} /> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700 }}>{ad.full}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{a.room} · 2 этаж</div> |
||||||
|
</div> |
||||||
|
<button className="btn-s" style={{ padding: '8px 12px', fontSize: 12 }}>Карта</button> |
||||||
|
</div> |
||||||
|
<div className="divider" /> |
||||||
|
<div style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}> |
||||||
|
<I.phone size={20} style={{ color: 'var(--c-primary-darker)' }} /> |
||||||
|
<div style={{ flex: 1, fontSize: 14 }}>(342) 207-03-03</div> |
||||||
|
<I.chev size={16} style={{ color: 'var(--c-fg-4)' }} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{isUp && ( |
||||||
|
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}> |
||||||
|
<button className="btn-g" style={{ width: '100%', padding: 14 }}> |
||||||
|
<I.calendar size={18} /> Добавить в календарь |
||||||
|
</button> |
||||||
|
<button className="btn-g" style={{ width: '100%', padding: 14 }}> |
||||||
|
<I.bell size={18} /> Напомнить позже |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{a.hasReport && ( |
||||||
|
<div style={{ marginTop: 16 }}> |
||||||
|
<div className="h-sec" style={{ marginBottom: 10 }}>Заключение врача</div> |
||||||
|
<div className="card"> |
||||||
|
<div style={{ fontSize: 14, lineHeight: 1.55, color: 'var(--c-fg-2)' }}> |
||||||
|
Диагноз: хронический риносинусит, обострение. Назначено: Аква Марис, Назонекс 14 дней. Контрольный осмотр через 2 недели. |
||||||
|
</div> |
||||||
|
<button className="btn-s" style={{ marginTop: 12, padding: '8px 14px', fontSize: 13 }}> |
||||||
|
<I.doc size={15} /> Открыть PDF |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
{isUp && ( |
||||||
|
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '14px 20px 34px', background: '#fff', borderTop: '1px solid var(--c-border)', display: 'flex', gap: 10 }}> |
||||||
|
<button className="btn-g" style={{ flex: 1, padding: 14, color: 'var(--c-danger)', borderColor: 'var(--c-accent-50)' }}>Отменить</button> |
||||||
|
<button className="btn-p" style={{ flex: 2 }}>Перенести</button> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function ResultsScreen({ nav }) { |
||||||
|
const { results, doctors } = CLINIC_DATA; |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 100 }}> |
||||||
|
<ScreenHeader title="Анализы и обследования" onBack={() => nav.pop()} /> |
||||||
|
<div className="pills" style={{ marginBottom: 12 }}> |
||||||
|
{['Все','Готовы','В работе','Аудио','Эндоскопия'].map((p,i)=>( |
||||||
|
<button key={i} className={'pill' + (i===0?' on':'')}>{p}</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
<div style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 10 }}> |
||||||
|
{results.map(r => { |
||||||
|
const d = doctors.find(x => x.id === r.doctor); |
||||||
|
const isReady = r.status === 'ready'; |
||||||
|
const kindIcons = { audio: I.hearing, image: I.video, lab: I.doc }; |
||||||
|
const Ic = kindIcons[r.kind]; |
||||||
|
return ( |
||||||
|
<button key={r.id} onClick={() => isReady && nav.push(r.kind === 'audio' ? 'result-audio' : 'result:' + r.id)} className="press card" style={{ |
||||||
|
display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left', opacity: isReady ? 1 : .7, |
||||||
|
}}> |
||||||
|
<div style={{ width: 44, height: 44, borderRadius: 12, background: isReady ? 'var(--c-primary-100)' : 'var(--c-warning-50)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<Ic size={22} style={{ color: isReady ? 'var(--c-primary-darker)' : 'var(--c-warning)' }} /> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontWeight: 700, fontSize: 15, marginBottom: 2 }}>{r.name}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{r.date} · {d.name.split(' ')[0]}</div> |
||||||
|
</div> |
||||||
|
{isReady ? <I.chev size={16} style={{ color: 'var(--c-fg-4)' }} /> : <span className="chip" style={{ background: 'var(--c-warning-50)', color: 'var(--c-warning)' }}>В работе</span>} |
||||||
|
</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function ResultAudioScreen({ nav }) { |
||||||
|
const freqs = [250, 500, 1000, 2000, 4000, 8000]; |
||||||
|
const leftDB = [10, 15, 20, 25, 35, 50]; |
||||||
|
const rightDB = [5, 10, 15, 20, 30, 40]; |
||||||
|
const w = 320, h = 220, pl = 30, pt = 10, pr = 10, pb = 30; |
||||||
|
const iw = w - pl - pr, ih = h - pt - pb; |
||||||
|
const xFor = i => pl + (i / (freqs.length - 1)) * iw; |
||||||
|
const yFor = db => pt + (db / 120) * ih; |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 40 }}> |
||||||
|
<ScreenHeader title="Аудиограмма" subtitle="8 апр 2026" onBack={() => nav.pop()} rightIcon={I.doc} /> |
||||||
|
<div style={{ padding: '0 20px' }}> |
||||||
|
<div className="card" style={{ marginBottom: 14 }}> |
||||||
|
<div style={{ display: 'flex', gap: 16, marginBottom: 12 }}> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> |
||||||
|
<div style={{ width: 10, height: 10, borderRadius: 999, background: 'var(--c-accent)' }} /> |
||||||
|
<span style={{ fontSize: 12, fontWeight: 700 }}>Правое ухо</span> |
||||||
|
</div> |
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> |
||||||
|
<div style={{ width: 10, height: 10, background: 'var(--c-primary-darker)' }} /> |
||||||
|
<span style={{ fontSize: 12, fontWeight: 700 }}>Левое ухо</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<svg width="100%" viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }}> |
||||||
|
{[0, 20, 40, 60, 80, 100, 120].map(db => ( |
||||||
|
<g key={db}> |
||||||
|
<line x1={pl} y1={yFor(db)} x2={w - pr} y2={yFor(db)} stroke="#E4EAF2" strokeWidth="1" /> |
||||||
|
<text x={pl - 6} y={yFor(db) + 4} fontSize="10" fill="#9AA7B4" textAnchor="end">{db}</text> |
||||||
|
</g> |
||||||
|
))} |
||||||
|
{freqs.map((f, i) => ( |
||||||
|
<g key={f}> |
||||||
|
<line x1={xFor(i)} y1={pt} x2={xFor(i)} y2={h - pb} stroke="#E4EAF2" strokeWidth="1" /> |
||||||
|
<text x={xFor(i)} y={h - pb + 14} fontSize="10" fill="#9AA7B4" textAnchor="middle">{f < 1000 ? f : (f/1000) + 'k'}</text> |
||||||
|
</g> |
||||||
|
))} |
||||||
|
<rect x={pl} y={pt} width={iw} height={yFor(25)-pt} fill="#E8F5EE" opacity=".5" /> |
||||||
|
<polyline points={rightDB.map((db,i)=>`${xFor(i)},${yFor(db)}`).join(' ')} fill="none" stroke="#E04E44" strokeWidth="2" /> |
||||||
|
{rightDB.map((db,i)=>(<circle key={i} cx={xFor(i)} cy={yFor(db)} r="4" fill="#fff" stroke="#E04E44" strokeWidth="2" />))} |
||||||
|
<polyline points={leftDB.map((db,i)=>`${xFor(i)},${yFor(db)}`).join(' ')} fill="none" stroke="#166B63" strokeWidth="2" strokeDasharray="4 3" /> |
||||||
|
{leftDB.map((db,i)=>( |
||||||
|
<g key={i} stroke="#166B63" strokeWidth="2"> |
||||||
|
<line x1={xFor(i)-4} y1={yFor(db)-4} x2={xFor(i)+4} y2={yFor(db)+4} /> |
||||||
|
<line x1={xFor(i)+4} y1={yFor(db)-4} x2={xFor(i)-4} y2={yFor(db)+4} /> |
||||||
|
</g> |
||||||
|
))} |
||||||
|
</svg> |
||||||
|
<div className="sub" style={{ fontSize: 11, textAlign: 'center', marginTop: 8 }}>dB HL / частота (Гц) · зелёная зона — норма</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 14 }}> |
||||||
|
<div className="h-row" style={{ marginBottom: 8 }}>Заключение</div> |
||||||
|
<div style={{ fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.55, marginBottom: 12 }}> |
||||||
|
Правое ухо — норма. Левое ухо — лёгкая нейросенсорная тугоухость в области высоких частот (4–8 кГц). |
||||||
|
</div> |
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: 12, background: 'var(--c-primary-50)', borderRadius: 10 }}> |
||||||
|
<I.stetho size={18} style={{ color: 'var(--c-primary-darker)' }} /> |
||||||
|
<div style={{ fontSize: 13, color: 'var(--c-fg-2)' }}>Рекомендован контроль через 6 месяцев</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button className="btn-g" style={{ width: '100%', padding: 14 }}> |
||||||
|
<I.doc size={18} /> Скачать заключение |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function RecoveryScreen({ nav }) { |
||||||
|
const { recovery } = CLINIC_DATA; |
||||||
|
const surgeon = CLINIC_DATA.doctors.find(d => d.id === recovery.surgeon); |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 40 }}> |
||||||
|
<ScreenHeader title="Восстановление" onBack={() => nav.pop()} /> |
||||||
|
<div style={{ padding: '0 20px 16px' }}> |
||||||
|
<div className="card" style={{ padding: 18, marginBottom: 14, background: 'linear-gradient(135deg, var(--c-primary-darker), #0F4A44)', color: '#fff', border: 0 }}> |
||||||
|
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: .8, opacity: .7, marginBottom: 4 }}>Операция</div> |
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 14 }}>{recovery.op}</div> |
||||||
|
<div style={{ height: 8, background: 'rgba(255,255,255,0.2)', borderRadius: 999, overflow: 'hidden', marginBottom: 12 }}> |
||||||
|
<div style={{ width: `${recovery.dayNow/recovery.totalDays*100}%`, height: '100%', background: '#fff', borderRadius: 999 }} /> |
||||||
|
</div> |
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}> |
||||||
|
<span style={{ opacity: .8 }}>День {recovery.dayNow}</span> |
||||||
|
<span style={{ opacity: .8 }}>{recovery.totalDays - recovery.dayNow} дней до выписки</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="h-sec" style={{ marginBottom: 10 }}>Лекарства сегодня</div> |
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 18 }}> |
||||||
|
{recovery.meds.map((m,i)=>( |
||||||
|
<div key={i} className="card" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 14 }}> |
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'var(--c-warning-50)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.pill size={20} style={{ color: 'var(--c-warning)' }} /> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700 }}>{m.name}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{m.freq} · след. {m.nextTake}</div> |
||||||
|
</div> |
||||||
|
{m.total > 0 && ( |
||||||
|
<div style={{ fontSize: 12, color: 'var(--c-fg-3)', fontWeight: 700 }}>{m.taken}/{m.total}</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="h-sec" style={{ marginBottom: 10 }}>План восстановления</div> |
||||||
|
<div className="card" style={{ padding: 0 }}> |
||||||
|
{recovery.steps.map((s, i) => ( |
||||||
|
<div key={i} style={{ display: 'flex', gap: 14, padding: '14px 16px', alignItems: 'flex-start', position: 'relative' }}> |
||||||
|
{i < recovery.steps.length - 1 && ( |
||||||
|
<div style={{ position: 'absolute', left: 27, top: 36, bottom: -10, width: 2, background: s.done ? 'var(--c-primary-darker)' : 'var(--c-divider)' }} /> |
||||||
|
)} |
||||||
|
<div style={{ |
||||||
|
width: 28, height: 28, borderRadius: 999, flexShrink: 0, |
||||||
|
background: s.done ? 'var(--c-primary-darker)' : '#fff', |
||||||
|
border: s.active ? '3px solid var(--c-primary-darker)' : s.done ? 0 : '2px solid var(--c-border-strong)', |
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', |
||||||
|
position: 'relative', zIndex: 1, |
||||||
|
}}> |
||||||
|
{s.done && <I.check size={16} style={{ color: '#fff' }} sw={3} />} |
||||||
|
{s.active && <div style={{ width: 10, height: 10, borderRadius: 999, background: 'var(--c-primary-darker)' }} />} |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1, paddingBottom: 4 }}> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: s.done ? 'var(--c-fg-3)' : 'var(--c-fg-1)', marginBottom: 2 }}>{s.title}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{s.note}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: 14, display: 'flex', gap: 12, alignItems: 'center', padding: 14 }}> |
||||||
|
<Avatar init={surgeon.init} size={44} /> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div className="sub" style={{ fontSize: 11 }}>Ваш хирург</div> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700 }}>{surgeon.name.split(' ').slice(0,2).join(' ')}</div> |
||||||
|
</div> |
||||||
|
<button onClick={() => nav.push('chat')} className="btn-s" style={{ padding: '8px 12px' }}> |
||||||
|
<I.chat size={15} /> Чат |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function AudioTestScreen({ nav }) { |
||||||
|
const [stage, setStage] = useState('intro'); |
||||||
|
const [progress, setProgress] = useState(0); |
||||||
|
const [currFreq, setCurrFreq] = useState(500); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (stage !== 'test') return; |
||||||
|
const id = setInterval(() => { |
||||||
|
setProgress(p => { |
||||||
|
if (p >= 100) { clearInterval(id); setStage('done'); return 100; } |
||||||
|
return p + 2; |
||||||
|
}); |
||||||
|
setCurrFreq([250,500,1000,2000,4000,8000][Math.floor(Math.random()*6)]); |
||||||
|
}, 200); |
||||||
|
return () => clearInterval(id); |
||||||
|
}, [stage]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 40, height: '100%', display: 'flex', flexDirection: 'column' }}> |
||||||
|
<ScreenHeader title="Тест слуха" onBack={() => nav.pop()} /> |
||||||
|
<div style={{ flex: 1, padding: '0 20px', display: 'flex', flexDirection: 'column' }}> |
||||||
|
{stage === 'intro' && ( |
||||||
|
<> |
||||||
|
<div style={{ textAlign: 'center', padding: '24px 0' }}> |
||||||
|
<div style={{ width: 120, height: 120, margin: '0 auto 20px', borderRadius: 999, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.hearing size={60} style={{ color: 'var(--c-primary-darker)' }} /> |
||||||
|
</div> |
||||||
|
<h1 className="h-screen" style={{ marginBottom: 10 }}>Проверим Ваш слух</h1> |
||||||
|
<p className="sub" style={{ fontSize: 14, maxWidth: 300, margin: '0 auto' }}>Тест займёт 3 минуты. Результат не заменяет консультацию сурдолога, но покажет, стоит ли записаться на приём.</p> |
||||||
|
</div> |
||||||
|
<div className="card" style={{ marginBottom: 14 }}> |
||||||
|
<div className="h-row" style={{ marginBottom: 10 }}>Для точного теста</div> |
||||||
|
{[ |
||||||
|
{ i: I.volume, t: 'Наденьте наушники', s: 'Без Bluetooth, проводные лучше' }, |
||||||
|
{ i: I.shield, t: 'Выберите тихое место', s: 'Без фоновых звуков и разговоров' }, |
||||||
|
{ i: I.clock, t: 'Не торопитесь', s: 'Отвечайте, когда уверены' }, |
||||||
|
].map((x,i,a)=>{ |
||||||
|
const XI = x.i; |
||||||
|
return ( |
||||||
|
<div key={i}> |
||||||
|
<div style={{ display: 'flex', gap: 12, padding: '10px 0', alignItems: 'flex-start' }}> |
||||||
|
<XI size={20} style={{ color: 'var(--c-primary-darker)', flexShrink: 0, marginTop: 1 }} /> |
||||||
|
<div> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 2 }}>{x.t}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{x.s}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{i < a.length - 1 && <div className="divider" />} |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1 }} /> |
||||||
|
<button onClick={() => setStage('test')} className="btn-p block" style={{ marginBottom: 10 }}>Начать тест</button> |
||||||
|
<div className="sub" style={{ fontSize: 12, textAlign: 'center' }}>Регулярные тесты помогают заметить изменения слуха вовремя</div> |
||||||
|
</> |
||||||
|
)} |
||||||
|
|
||||||
|
{stage === 'test' && ( |
||||||
|
<> |
||||||
|
<div style={{ padding: '16px 0 8px' }}> |
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}> |
||||||
|
<span className="sub" style={{ fontSize: 12 }}>Правое ухо · {currFreq} Гц</span> |
||||||
|
<span className="sub" style={{ fontSize: 12 }}>{Math.round(progress)}%</span> |
||||||
|
</div> |
||||||
|
<div style={{ height: 6, background: 'var(--c-divider)', borderRadius: 999, overflow: 'hidden' }}> |
||||||
|
<div style={{ width: `${progress}%`, height: '100%', background: 'var(--c-primary-darker)', borderRadius: 999, transition: 'width .2s' }} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<div style={{ position: 'relative', width: 220, height: 220, marginBottom: 24 }}> |
||||||
|
{[0,1,2].map(i => ( |
||||||
|
<div key={i} style={{ |
||||||
|
position: 'absolute', inset: -20 * i, borderRadius: 999, |
||||||
|
border: '2px solid var(--c-primary-200)', |
||||||
|
opacity: 0.5 - i * 0.15, |
||||||
|
animation: `pulse 1.4s ${i * 0.3}s ease-out infinite`, |
||||||
|
}} /> |
||||||
|
))} |
||||||
|
<div style={{ position: 'absolute', inset: 0, borderRadius: 999, background: 'var(--c-primary-darker)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.volume size={70} style={{ color: '#fff' }} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, textAlign: 'center', marginBottom: 8 }}>Слышите звук?</div> |
||||||
|
<div className="sub" style={{ fontSize: 14, textAlign: 'center', maxWidth: 260 }}>Нажмите кнопку сразу, как услышите тон — даже если очень тихо</div> |
||||||
|
</div> |
||||||
|
<button className="btn-p block" style={{ padding: 22, fontSize: 17, marginBottom: 8 }}>Слышу!</button> |
||||||
|
<button className="btn-g" style={{ width: '100%', padding: 14 }}>Не слышу</button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
|
||||||
|
{stage === 'done' && ( |
||||||
|
<> |
||||||
|
<div style={{ textAlign: 'center', padding: '24px 0' }}> |
||||||
|
<div style={{ width: 96, height: 96, margin: '0 auto 20px', borderRadius: 999, background: 'var(--c-success-50)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.check size={48} style={{ color: 'var(--c-success)' }} sw={3} /> |
||||||
|
</div> |
||||||
|
<h1 className="h-screen" style={{ marginBottom: 8 }}>Тест завершён</h1> |
||||||
|
<p className="sub" style={{ fontSize: 14 }}>Ваш слух в пределах нормы</p> |
||||||
|
</div> |
||||||
|
<div className="card" style={{ marginBottom: 14 }}> |
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}> |
||||||
|
<div> |
||||||
|
<div className="sub" style={{ fontSize: 11, marginBottom: 4 }}>Правое ухо</div> |
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--c-success)' }}>Норма</div> |
||||||
|
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>10 дБ, все частоты</div> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<div className="sub" style={{ fontSize: 11, marginBottom: 4 }}>Левое ухо</div> |
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--c-warning)' }}>Почти норма</div> |
||||||
|
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>25 дБ на 4 кГц</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="card" style={{ marginBottom: 14, background: 'var(--c-primary-50)' }}> |
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}> |
||||||
|
<I.stetho size={20} style={{ color: 'var(--c-primary-darker)', flexShrink: 0, marginTop: 2 }} /> |
||||||
|
<div> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 4 }}>Рекомендация</div> |
||||||
|
<div style={{ fontSize: 13, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>Небольшое снижение на левом ухе. Рекомендуем пройти аудиометрию у сурдолога.</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1 }} /> |
||||||
|
<button onClick={() => nav.push('booking-specs')} className="btn-p block" style={{ marginBottom: 10 }}>Записаться к сурдологу</button> |
||||||
|
<button onClick={() => nav.pop()} className="btn-g" style={{ width: '100%', padding: 14 }}>Сохранить и закрыть</button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function ChatTabScreen() { |
||||||
|
const msgs = [ |
||||||
|
{ from: 'doc', t: 'Добрый день, Анна! Как самочувствие после операции?', tm: '14:02' }, |
||||||
|
{ from: 'me', t: 'Здравствуйте! В целом хорошо, немного саднит в носу по утрам.', tm: '14:08' }, |
||||||
|
{ from: 'doc', t: 'Это нормально на 6-й день. Продолжайте промывания Аква Марис 4 раза в день.', tm: '14:10' }, |
||||||
|
{ from: 'doc', t: 'Выходите на осмотр сегодня? Я свободен после 15:00.', tm: '14:11' }, |
||||||
|
{ from: 'me', t: 'Да, буду в 16:00 как запланировано.', tm: '14:14' }, |
||||||
|
{ from: 'doc', t: 'Отлично, жду. Если что-то изменится — напишите.', tm: '14:15' }, |
||||||
|
]; |
||||||
|
const doc = CLINIC_DATA.doctors.find(d => d.id === 'syndaev'); |
||||||
|
return ( |
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}> |
||||||
|
<div style={{ padding: '12px 20px 12px' }}> |
||||||
|
<h1 className="h-screen">Чат</h1> |
||||||
|
</div> |
||||||
|
<div style={{ padding: '0 16px 12px' }}> |
||||||
|
<div className="card" style={{ display: 'flex', gap: 12, alignItems: 'center', padding: 12, background: 'var(--c-primary-50)' }}> |
||||||
|
<Avatar init={doc.init} size={44} /> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700 }}>{doc.name.split(' ').slice(0,2).join(' ')}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}> |
||||||
|
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-success)' }} /> |
||||||
|
Онлайн · отвечает 5 мин |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<button className="btn-s" style={{ padding: 10, borderRadius: 999 }}> |
||||||
|
<I.video size={16} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}> |
||||||
|
{msgs.map((m,i)=>( |
||||||
|
<div key={i} style={{ alignSelf: m.from === 'me' ? 'flex-end' : 'flex-start', maxWidth: '78%' }}> |
||||||
|
<div style={{ |
||||||
|
background: m.from === 'me' ? 'var(--c-primary-darker)' : '#fff', |
||||||
|
color: m.from === 'me' ? '#fff' : 'var(--c-fg-1)', |
||||||
|
padding: '10px 14px', borderRadius: 16, |
||||||
|
borderBottomRightRadius: m.from === 'me' ? 4 : 16, |
||||||
|
borderBottomLeftRadius: m.from === 'me' ? 16 : 4, |
||||||
|
fontSize: 14, lineHeight: 1.45, |
||||||
|
boxShadow: m.from === 'me' ? 'none' : 'var(--sh-sm)', |
||||||
|
}}>{m.t}</div> |
||||||
|
<div className="sub" style={{ fontSize: 11, marginTop: 3, textAlign: m.from === 'me' ? 'right' : 'left', padding: '0 4px' }}>{m.tm}</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
<div style={{ padding: '10px 16px 100px', borderTop: '1px solid var(--c-border)', background: '#fff' }}> |
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> |
||||||
|
<button style={{ width: 38, height: 38, borderRadius: 999, background: 'var(--c-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.plus size={20} /> |
||||||
|
</button> |
||||||
|
<div style={{ flex: 1, background: 'var(--c-bg)', borderRadius: 999, padding: '10px 16px', fontSize: 14, color: 'var(--c-fg-4)' }}>Сообщение...</div> |
||||||
|
<button style={{ width: 38, height: 38, borderRadius: 999, background: 'var(--c-primary-darker)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.mic size={20} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function ProfileTabScreen({ nav }) { |
||||||
|
const sections = [ |
||||||
|
{ |
||||||
|
title: 'Здоровье', |
||||||
|
items: [ |
||||||
|
{ i: I.file, t: 'Медицинская карта', s: 'История, диагнозы', go: 'medcard' }, |
||||||
|
{ i: I.doc, t: 'Анализы', s: '5 результатов', go: 'results' }, |
||||||
|
{ i: I.pill, t: 'Лекарства', s: '3 активных курса', go: 'recovery' }, |
||||||
|
{ i: I.hearing, t: 'История тестов слуха', s: '2 аудиограммы', go: 'results' }, |
||||||
|
] |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Оплата и бонусы', |
||||||
|
items: [ |
||||||
|
{ i: I.card, t: 'Способы оплаты', s: 'Mir •••• 4821' }, |
||||||
|
{ i: I.gift, t: 'Бонусы', s: '2 480 баллов · 5%', badge: 'Серебро' }, |
||||||
|
{ i: I.file, t: 'История платежей', s: '12 операций' }, |
||||||
|
] |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Клиника', |
||||||
|
items: [ |
||||||
|
{ i: I.pin, t: 'Адреса и часы работы', s: '3 клиники' }, |
||||||
|
{ i: I.phone, t: '(342) 207-03-03', s: 'Ежедневно 9:00–21:00' }, |
||||||
|
] |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Настройки', |
||||||
|
items: [ |
||||||
|
{ i: I.bell, t: 'Уведомления' }, |
||||||
|
{ i: I.shield, t: 'Конфиденциальность' }, |
||||||
|
{ i: I.user, t: 'Члены семьи', s: '+ 2 профиля' }, |
||||||
|
] |
||||||
|
}, |
||||||
|
]; |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 100 }}> |
||||||
|
<div style={{ padding: '12px 20px 16px' }}> |
||||||
|
<h1 className="h-screen" style={{ marginBottom: 18 }}>Профиль</h1> |
||||||
|
<div className="card" style={{ padding: 18, display: 'flex', gap: 14, alignItems: 'center', background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))', border: 0 }}> |
||||||
|
<Avatar init="АС" size={64} style={{ fontSize: 24, boxShadow: 'var(--sh-sm)' }} /> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontSize: 18, fontWeight: 700 }}>Анна Сергеевна</div> |
||||||
|
<div className="sub" style={{ fontSize: 13, marginBottom: 6 }}>+7 (912) 485-••-•• · 42 года</div> |
||||||
|
<button onClick={() => nav.push('qr')} className="chip" style={{ fontSize: 12, fontWeight: 700 }}> |
||||||
|
<I.qr size={12} /> QR пациента |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ padding: '0 16px' }}> |
||||||
|
{sections.map((sec, si) => ( |
||||||
|
<div key={si} style={{ marginBottom: 18 }}> |
||||||
|
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: .8, padding: '0 4px 8px' }}>{sec.title}</div> |
||||||
|
<div className="card" style={{ padding: 0 }}> |
||||||
|
{sec.items.map((it, i) => { |
||||||
|
const II = it.i; |
||||||
|
return ( |
||||||
|
<React.Fragment key={i}> |
||||||
|
<button onClick={() => it.go && nav.push(it.go)} className="press" style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 14, padding: '13px 16px', textAlign: 'left' }}> |
||||||
|
<div style={{ width: 34, height: 34, borderRadius: 9, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> |
||||||
|
<II size={18} style={{ color: 'var(--c-primary-darker)' }} /> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1 }}> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{it.t}</div> |
||||||
|
{it.s && <div className="sub" style={{ fontSize: 12 }}>{it.s}</div>} |
||||||
|
</div> |
||||||
|
{it.badge && <span className="chip chip-warm" style={{ fontSize: 11 }}>{it.badge}</span>} |
||||||
|
<I.chev size={15} style={{ color: 'var(--c-fg-4)' }} /> |
||||||
|
</button> |
||||||
|
{i < sec.items.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 64 }} />} |
||||||
|
</React.Fragment> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function QRScreen({ nav }) { |
||||||
|
const cells = []; |
||||||
|
for (let i = 0; i < 441; i++) cells.push(Math.random() > 0.52 ? 1 : 0); |
||||||
|
const marker = (cx, cy) => { |
||||||
|
for (let y = 0; y < 7; y++) { |
||||||
|
for (let x = 0; x < 7; x++) { |
||||||
|
const isEdge = x === 0 || x === 6 || y === 0 || y === 6; |
||||||
|
const isInner = x >= 2 && x <= 4 && y >= 2 && y <= 4; |
||||||
|
cells[(cy + y) * 21 + (cx + x)] = (isEdge || isInner) ? 1 : 0; |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
marker(0, 0); marker(14, 0); marker(0, 14); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 40, height: '100%', display: 'flex', flexDirection: 'column', background: 'linear-gradient(180deg, var(--c-primary-darker) 0%, var(--c-primary-dark) 100%)', color: '#fff' }}> |
||||||
|
<div style={{ display: 'flex', padding: '12px 16px 8px' }}> |
||||||
|
<button onClick={() => nav.pop()} className="press" style={{ width: 38, height: 38, borderRadius: 999, background: 'rgba(255,255,255,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.chevL size={20} style={{ color: '#fff' }} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '0 32px' }}> |
||||||
|
<div style={{ marginBottom: 24, textAlign: 'center' }}> |
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 6 }}>Ваш QR-код</div> |
||||||
|
<div style={{ fontSize: 13, opacity: .75 }}>Покажите на ресепшен, чтобы быстро отметиться</div> |
||||||
|
</div> |
||||||
|
<div style={{ background: '#fff', borderRadius: 24, padding: 20, marginBottom: 20 }}> |
||||||
|
<div style={{ width: 210, height: 210, display: 'grid', gridTemplateColumns: 'repeat(21,1fr)', gap: 0 }}> |
||||||
|
{cells.map((c, i) => ( |
||||||
|
<div key={i} style={{ background: c ? '#0F4A44' : 'transparent' }} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ textAlign: 'center' }}> |
||||||
|
<div style={{ fontSize: 13, opacity: .75, marginBottom: 3 }}>Пациент №</div> |
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, letterSpacing: 1, fontFamily: 'var(--font-narrow)' }}>УГН-2014-00482</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ padding: '0 20px 20px' }}> |
||||||
|
<div style={{ background: 'rgba(255,255,255,0.12)', borderRadius: 16, padding: 14, display: 'flex', gap: 12, alignItems: 'center' }}> |
||||||
|
<I.shield size={22} style={{ color: '#fff', opacity: .8 }} /> |
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.45, opacity: .9 }}>Код обновляется каждые 60 секунд. Не передавайте третьим лицам.</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function TelemedScreen({ nav }) { |
||||||
|
return ( |
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#0F1A20', color: '#fff', position: 'relative' }}> |
||||||
|
<div style={{ flex: 1, background: 'linear-gradient(135deg, #1F8F85, #166B63)', position: 'relative' }}> |
||||||
|
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<div style={{ width: 140, height: 140, borderRadius: 999, background: 'rgba(255,255,255,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-narrow)', fontSize: 56, fontWeight: 700 }}>СА</div> |
||||||
|
</div> |
||||||
|
<div style={{ position: 'absolute', top: 60, left: 0, right: 0, padding: '0 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> |
||||||
|
<button onClick={() => nav.pop()} className="press" style={{ width: 36, height: 36, borderRadius: 999, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.chevD size={22} style={{ color: '#fff' }} /> |
||||||
|
</button> |
||||||
|
<div style={{ background: 'rgba(0,0,0,0.4)', borderRadius: 999, padding: '8px 14px', display: 'flex', alignItems: 'center', gap: 8 }}> |
||||||
|
<span style={{ width: 8, height: 8, borderRadius: 999, background: '#E04E44', animation: 'blink 1.2s infinite' }} /> |
||||||
|
<span style={{ fontSize: 13, fontWeight: 700 }}>05:42</span> |
||||||
|
</div> |
||||||
|
<button className="press" style={{ width: 36, height: 36, borderRadius: 999, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<I.menu size={20} style={{ color: '#fff' }} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<div style={{ position: 'absolute', bottom: 160, left: 0, right: 0, textAlign: 'center' }}> |
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 3 }}>Синдяев А.В.</div> |
||||||
|
<div style={{ fontSize: 13, opacity: .75 }}>ЛОР-хирург</div> |
||||||
|
</div> |
||||||
|
<div style={{ position: 'absolute', top: 110, right: 16, width: 96, height: 130, borderRadius: 14, background: '#2a3540', border: '2px solid rgba(255,255,255,0.2)', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<div style={{ width: 44, height: 44, borderRadius: 999, background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>АС</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div style={{ padding: '24px 0 50px', background: 'rgba(15,26,32,0.9)', backdropFilter: 'blur(10px)', display: 'flex', justifyContent: 'center', gap: 14 }}> |
||||||
|
{[ |
||||||
|
{ i: I.mic, bg: 'rgba(255,255,255,0.15)' }, |
||||||
|
{ i: I.video, bg: 'rgba(255,255,255,0.15)' }, |
||||||
|
{ i: I.chat, bg: 'rgba(255,255,255,0.15)' }, |
||||||
|
{ i: I.phone, bg: '#E04E44' }, |
||||||
|
].map((c,i)=>{ |
||||||
|
const CI = c.i; |
||||||
|
return ( |
||||||
|
<button key={i} className="press" style={{ width: 58, height: 58, borderRadius: 999, background: c.bg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
||||||
|
<CI size={24} style={{ color: '#fff' }} /> |
||||||
|
</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function MedcardScreen({ nav }) { |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 40 }}> |
||||||
|
<ScreenHeader title="Медицинская карта" onBack={() => nav.pop()} /> |
||||||
|
<div style={{ padding: '0 20px' }}> |
||||||
|
<div className="card" style={{ marginBottom: 14 }}> |
||||||
|
<div className="h-row" style={{ marginBottom: 10 }}>Основное</div> |
||||||
|
{[['Пол','Женский'],['Возраст','42 года'],['Рост / Вес','168 см · 62 кг'],['Группа крови','II (A), Rh+']].map(([k,v])=>( |
||||||
|
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0' }}> |
||||||
|
<span className="sub">{k}</span> |
||||||
|
<span style={{ fontSize: 14, fontWeight: 700 }}>{v}</span> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 14 }}> |
||||||
|
<div className="h-row" style={{ marginBottom: 10 }}>Аллергии</div> |
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> |
||||||
|
<span className="chip chip-danger">Пенициллин</span> |
||||||
|
<span className="chip chip-danger">Пыльца берёзы</span> |
||||||
|
<span className="chip chip-soft">+ добавить</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="h-sec" style={{ padding: '4px 4px 10px' }}>История диагнозов</div> |
||||||
|
<div className="card" style={{ padding: 0 }}> |
||||||
|
{[ |
||||||
|
{ d: '12 апр 2026', t: 'Искривление носовой перегородки', doc: 'Синдяев А.В.', tag: 'Операция' }, |
||||||
|
{ d: '8 апр 2026', t: 'Хронический риносинусит', doc: 'Макарова Л.Г.', tag: 'Приём' }, |
||||||
|
{ d: '15 ноя 2025', t: 'ОРВИ', doc: 'Суворова С.В.', tag: 'Приём' }, |
||||||
|
].map((r,i,a)=>( |
||||||
|
<div key={i}> |
||||||
|
<div style={{ padding: '14px 16px' }}> |
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}> |
||||||
|
<span className="sub" style={{ fontSize: 12 }}>{r.d}</span> |
||||||
|
<span className="chip chip-soft" style={{ fontSize: 10 }}>{r.tag}</span> |
||||||
|
</div> |
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 3 }}>{r.t}</div> |
||||||
|
<div className="sub" style={{ fontSize: 12 }}>{r.doc}</div> |
||||||
|
</div> |
||||||
|
{i < a.length - 1 && <div className="divider" />} |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function NotificationsScreen({ nav }) { |
||||||
|
const n = [ |
||||||
|
{ tm: '2 ч назад', t: 'Напоминание', s: 'Приём Амоксиклава в 20:00', i: I.pill, tint: 'warning' }, |
||||||
|
{ tm: 'Сегодня', t: 'Готово заключение', s: 'Макарова Л.Г. — эндоскопия носоглотки', i: I.doc, tint: 'primary' }, |
||||||
|
{ tm: 'Вчера', t: 'Новое сообщение', s: 'Синдяев А.В.: «Как самочувствие?»', i: I.chat, tint: 'primary' }, |
||||||
|
{ tm: '15 апр', t: 'Акция', s: 'Бесплатная консультация хирурга в апреле', i: I.gift, tint: 'warm' }, |
||||||
|
{ tm: '12 апр', t: 'Приём завершён', s: 'Не забудьте оставить отзыв о враче', i: I.star, tint: 'warning' }, |
||||||
|
]; |
||||||
|
const tints = { |
||||||
|
primary: { bg: 'var(--c-primary-100)', c: 'var(--c-primary-darker)' }, |
||||||
|
warning: { bg: 'var(--c-warning-50)', c: 'var(--c-warning)' }, |
||||||
|
warm: { bg: 'var(--c-warm-100)', c: 'var(--c-warm-text)' }, |
||||||
|
}; |
||||||
|
return ( |
||||||
|
<div style={{ paddingBottom: 40 }}> |
||||||
|
<ScreenHeader title="Уведомления" onBack={() => nav.pop()} /> |
||||||
|
<div style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 8 }}> |
||||||
|
{n.map((x,i)=>{ |
||||||
|
const t = tints[x.tint]; |
||||||
|
const XI = x.i; |
||||||
|
return ( |
||||||
|
<div key={i} className="card" style={{ display: 'flex', gap: 12, alignItems: 'flex-start', padding: 14 }}> |
||||||
|
<div style={{ width: 38, height: 38, borderRadius: 10, background: t.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> |
||||||
|
<XI size={18} style={{ color: t.c }} /> |
||||||
|
</div> |
||||||
|
<div style={{ flex: 1, minWidth: 0 }}> |
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 2 }}> |
||||||
|
<span style={{ fontSize: 14, fontWeight: 700 }}>{x.t}</span> |
||||||
|
<span className="sub" style={{ fontSize: 11 }}>{x.tm}</span> |
||||||
|
</div> |
||||||
|
<div className="sub" style={{ fontSize: 13 }}>{x.s}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,107 @@ |
|||||||
|
/* ============================================================ |
||||||
|
Клиника УГН — Colors & Type |
||||||
|
Design tokens: CSS custom properties for colors, type, spacing, |
||||||
|
radii, shadows, and semantic text styles. |
||||||
|
============================================================ */ |
||||||
|
|
||||||
|
:root { |
||||||
|
/* ---------- Colors — Primary ---------- */ |
||||||
|
--color-primary: #2BB4A8; |
||||||
|
--color-primary-dark: #1F8F85; |
||||||
|
--color-primary-darker: #166B63; |
||||||
|
--color-primary-light: #E3F4F2; |
||||||
|
--color-primary-soft: #B5E3DE; |
||||||
|
|
||||||
|
--color-bg-hero: #F5EDDF; |
||||||
|
--color-bg-hero-deep: #E8DCC5; |
||||||
|
--color-bg-news: #FDF8E6; |
||||||
|
--color-bg-news-deep: #F5E9BF; |
||||||
|
|
||||||
|
/* ---------- Colors — Accent ---------- */ |
||||||
|
--color-accent: #E04E44; |
||||||
|
--color-accent-dark: #B63D35; |
||||||
|
--color-accent-light: #FCE8E6; |
||||||
|
|
||||||
|
/* ---------- Colors — Semantic ---------- */ |
||||||
|
--color-success: #2E9B6B; |
||||||
|
--color-success-light: #E4F4EC; |
||||||
|
--color-warning: #E8A13C; |
||||||
|
--color-warning-light: #FBEFD8; |
||||||
|
--color-danger: #D94141; |
||||||
|
--color-danger-light: #FBE4E4; |
||||||
|
--color-info: var(--color-primary); |
||||||
|
--color-info-light: var(--color-primary-light); |
||||||
|
|
||||||
|
/* ---------- Colors — Neutrals ---------- */ |
||||||
|
--color-bg: #FFFFFF; |
||||||
|
--color-bg-alt: #F7F9FC; |
||||||
|
--color-bg-muted: #EEF2F7; |
||||||
|
--color-surface: #FFFFFF; |
||||||
|
--color-border: #E4EAF2; |
||||||
|
--color-border-strong: #CDD6E2; |
||||||
|
|
||||||
|
--color-fg-1: #1F2A37; |
||||||
|
--color-fg-2: #3E4C5D; |
||||||
|
--color-fg-3: #5A6B7B; |
||||||
|
--color-fg-4: #8596A8; |
||||||
|
--color-fg-on-primary: #FFFFFF; |
||||||
|
--color-fg-link: var(--color-primary); |
||||||
|
--color-fg-link-hover: var(--color-primary-dark); |
||||||
|
|
||||||
|
--font-sans: 'PT Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; |
||||||
|
--font-display: 'PT Sans Narrow', 'PT Sans', system-ui, sans-serif; |
||||||
|
--font-mono: 'SF Mono', Menlo, Consolas, monospace; |
||||||
|
|
||||||
|
--text-xs: 12px; |
||||||
|
--text-sm: 13px; |
||||||
|
--text-base: 15px; |
||||||
|
--text-md: 16px; |
||||||
|
--text-lg: 18px; |
||||||
|
--text-xl: 20px; |
||||||
|
--text-2xl: 24px; |
||||||
|
--text-3xl: 28px; |
||||||
|
--text-4xl: 32px; |
||||||
|
--text-5xl: 40px; |
||||||
|
--text-6xl: 52px; |
||||||
|
|
||||||
|
--lh-tight: 1.15; |
||||||
|
--lh-snug: 1.3; |
||||||
|
--lh-normal: 1.5; |
||||||
|
--lh-loose: 1.7; |
||||||
|
|
||||||
|
--fw-regular: 400; |
||||||
|
--fw-bold: 700; |
||||||
|
|
||||||
|
--space-1: 4px; |
||||||
|
--space-2: 8px; |
||||||
|
--space-3: 12px; |
||||||
|
--space-4: 16px; |
||||||
|
--space-5: 20px; |
||||||
|
--space-6: 24px; |
||||||
|
--space-8: 32px; |
||||||
|
--space-10: 40px; |
||||||
|
--space-12: 48px; |
||||||
|
--space-16: 64px; |
||||||
|
--space-20: 80px; |
||||||
|
|
||||||
|
--radius-xs: 2px; |
||||||
|
--radius-sm: 4px; |
||||||
|
--radius-md: 6px; |
||||||
|
--radius-lg: 8px; |
||||||
|
--radius-xl: 12px; |
||||||
|
--radius-full: 9999px; |
||||||
|
|
||||||
|
--shadow-xs: 0 1px 2px rgba(15, 76, 129, 0.06); |
||||||
|
--shadow-sm: 0 2px 8px rgba(15, 76, 129, 0.08); |
||||||
|
--shadow-md: 0 4px 16px rgba(15, 76, 129, 0.12); |
||||||
|
--shadow-lg: 0 12px 32px rgba(15, 76, 129, 0.16); |
||||||
|
--shadow-focus: 0 0 0 3px rgba(30, 111, 181, 0.25); |
||||||
|
|
||||||
|
--ease: cubic-bezier(0.4, 0.0, 0.2, 1); |
||||||
|
--dur-fast: 150ms; |
||||||
|
--dur: 200ms; |
||||||
|
--dur-slow: 320ms; |
||||||
|
|
||||||
|
--container-max: 1200px; |
||||||
|
--container-pad: 24px; |
||||||
|
} |
||||||
Loading…
Reference in new issue