Browse Source

Add Home v2 with universal search, contacts, and prices screens

Home v2 leads with a search-first layout: prominent AI-badged search
field, booking CTA, quick tiles (contacts/prices/results/recovery),
upcoming appointment, clinic stats, and articles.

Search screen: real filtering across doctors, services, symptoms,
articles, and upcoming appointments (date-aware matching). Empty
state shows popular query chips and symptom list.

Contacts: clinic phone card + two address cards with CSS-mocked
building illustrations (windows, roof, sign, trees) and mini-map
(streets, park, building footprints, pin), route/call/video buttons,
transport tip.

Prices: service search + category pills, grouped listing with price
range summary, booking CTA, empty-state reset.

Exposed as "Главная 2" in Tweaks screen selector alongside
"Главная 1" (existing home variants remain under it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main
parent
commit
f9a0cb6b87
  1. 6
      src/App.jsx
  2. 8
      src/PhoneApp.jsx
  3. 686
      src/screens/screens-v2.jsx

6
src/App.jsx

@ -48,7 +48,8 @@ const FONT_OPTIONS = [
{ id: 'golos', lb: 'Golos', base: '"Golos Text", system-ui, sans-serif', narrow: '"Oswald", sans-serif' },
];
const SCREEN_OPTIONS = [
{ id: 'home', lb: 'Главная' },
{ id: 'home', lb: 'Главная 1' },
{ id: 'home-v2', lb: 'Главная 2' },
{ id: 'doctors', lb: 'Врачи' },
{ id: 'doctor:syndaev', lb: 'Карточка врача' },
{ id: 'booking-specs', lb: 'Запись: специализация' },
@ -74,6 +75,9 @@ const SCREEN_OPTIONS = [
{ id: 'article:septoplasty-recovery', lb: 'Статья: восстановление после септопластики' },
{ id: 'article:throat-pregnancy', lb: 'Статья: горло при беременности' },
{ id: 'article:hearing-check', lb: 'Статья: когда проверить слух' },
{ id: 'search', lb: 'Поиск' },
{ id: 'contacts', lb: 'Контакты' },
{ id: 'prices', lb: 'Цены' },
];
function applyTheme(tw) {

8
src/PhoneApp.jsx

@ -14,6 +14,7 @@ import {
TelemedScreen, MedcardScreen, NotificationsScreen,
} from './screens/screens-misc.jsx';
import { ArticlesScreen, ArticleDetailScreen } from './screens/screens-articles.jsx';
import { HomeV2Screen, SearchScreen, ContactsScreen, PricesScreen } from './screens/screens-v2.jsx';
function renderScreen(screenId, nav, ctx) {
const parts = screenId.split(':');
@ -21,6 +22,7 @@ function renderScreen(screenId, nav, ctx) {
const HOME = { cards: HomeCardsScreen, list: HomeListScreen, feed: HomeFeedScreen }[ctx.homeVariant] || HomeCardsScreen;
switch (id) {
case 'home': return <HOME nav={nav} ctx={ctx} />;
case 'home-v2': return <HomeV2Screen 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} />;
@ -43,6 +45,9 @@ function renderScreen(screenId, nav, ctx) {
case 'notifications': return <NotificationsScreen nav={nav} />;
case 'articles': return <ArticlesScreen nav={nav} />;
case 'article': return <ArticleDetailScreen nav={nav} articleId={parts[1]} />;
case 'search': return <SearchScreen nav={nav} />;
case 'contacts': return <ContactsScreen nav={nav} />;
case 'prices': return <PricesScreen nav={nav} />;
default: return <div style={{padding: 40, textAlign: 'center'}}>Экран не найден: {screenId}</div>;
}
}
@ -62,7 +67,8 @@ export function PhoneApp({ initialScreen, ctx }) {
}), []);
const current = stack[stack.length - 1];
const tabId = TAB_IDS.includes(current.split(':')[0]) ? current.split(':')[0] : null;
const rootId = current.split(':')[0];
const tabId = rootId === 'home-v2' ? 'home' : (TAB_IDS.includes(rootId) ? rootId : null);
const showTabBar = tabId !== null;
const modalScreens = ['qr', 'telemed', 'booking-success', 'audiotest'];

686
src/screens/screens-v2.jsx

@ -0,0 +1,686 @@
import React, { useMemo, useState } from 'react';
import { I } from '../icons.jsx';
import { CLINIC_DATA } from '../data.js';
import { Avatar, AppointmentCard, SectionHeader, ScreenHeader } from '../components.jsx';
//
// Symptoms dictionary maps natural-language complaints to
// suggested services and specialty.
//
const SYMPTOMS = [
{ id: 'throat', q: 'Боль в горле', spec: 'ЛОР', suggest: ['Приём ЛОР-врача первичный', 'Промывание миндалин'] },
{ id: 'ear', q: 'Боль в ухе', spec: 'ЛОР', suggest: ['Приём ЛОР-врача первичный', 'Эндоскопия ЛОР-органов'] },
{ id: 'hearing', q: 'Тугоухость, плохо слышу', spec: 'Сурдолог', suggest: ['Аудиометрия', 'Тимпанометрия'] },
{ id: 'tinnitus',q: 'Шум в ушах', spec: 'Сурдолог', suggest: ['Аудиометрия', 'Приём ЛОР-врача первичный'] },
{ id: 'snoring', q: 'Храп', spec: 'ЛОР-хирург', suggest: ['Септопластика', 'Приём ЛОР-врача первичный'] },
{ id: 'nose', q: 'Заложен нос', spec: 'ЛОР', suggest: ['Эндоскопия ЛОР-органов', 'Промывание носа «Кукушка»'] },
{ id: 'allergy', q: 'Аллергия, сенная лихорадка',spec: 'Аллерголог', suggest: ['Приём ЛОР-врача первичный'] },
{ id: 'adenoids',q: 'Аденоиды', spec: 'Детский ЛОР', suggest: ['Эндоскопия ЛОР-органов', 'Аденотомия (эндоскопическая)'] },
];
const SUGGESTED = ['аденоиды', 'тугоухость', 'храп', 'Синдяев', 'сегодня', 'аудиометрия'];
function matchesQuery(q, ...fields) {
const Q = String(q).trim().toLowerCase();
if (!Q) return false;
return fields.some(f => f && String(f).toLowerCase().includes(Q));
}
//
// Home V2 search-first layout
//
export function HomeV2Screen({ nav }) {
const { doctors, appointments, clinic, articles, services } = 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 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<div className="sub" style={{ marginBottom: 2 }}>Добрый день,</div>
<div style={{ fontSize: 22, fontWeight: 700, color: 'var(--c-fg-1)' }}>Анна Сергеевна</div>
</div>
<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} />
<span style={{ position: 'absolute', top: 7, right: 9, width: 8, height: 8, borderRadius: 999, background: 'var(--c-accent)' }} />
</button>
</div>
{/* Universal search */}
<button onClick={() => nav.push('search')} className="press" style={{
width: '100%', padding: '14px 16px', borderRadius: 16,
background: '#fff', border: '1px solid var(--c-border)',
display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left',
boxShadow: 'var(--sh-sm)',
}}>
<I.search size={20} style={{ color: 'var(--c-primary-darker)' }} />
<span style={{ flex: 1, color: 'var(--c-fg-4)', fontSize: 15 }}>Найти врача, симптом, услугу</span>
<div style={{
padding: '4px 10px', borderRadius: 8, background: 'var(--c-primary-100)',
color: 'var(--c-primary-darker)', fontSize: 10, fontWeight: 700, letterSpacing: .5,
}}>AI</div>
</button>
</div>
{/* Book CTA */}
<div style={{ padding: '0 20px 16px' }}>
<button onClick={() => nav.push('booking-specs')} className="press" style={{
width: '100%', textAlign: 'left',
background: 'var(--c-primary-darker)', color: '#fff',
borderRadius: 18, padding: 16,
display: 'flex', alignItems: 'center', gap: 14,
boxShadow: '0 10px 30px rgba(22,107,99,.2)',
}}>
<div style={{
width: 44, height: 44, borderRadius: 12, background: 'rgba(255,255,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<I.plus size={22} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}>Записаться на приём</div>
<div style={{ fontSize: 12, opacity: .8 }}>К ЛОР-врачу или сурдологу</div>
</div>
<I.arrow size={20} />
</button>
</div>
{/* Quick tiles — Contacts, Prices, Results, Recovery */}
<div style={{ padding: '0 20px 16px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10 }}>
<button onClick={() => nav.push('contacts')} className="press card" style={{ padding: 14, display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left' }}>
<div style={{ width: 42, height: 42, borderRadius: 12, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.pin size={22} style={{ color: 'var(--c-primary-darker)' }} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>Контакты</div>
<div className="sub" style={{ fontSize: 11 }}>2 адреса · маршрут</div>
</div>
</button>
<button onClick={() => nav.push('prices')} className="press card" style={{ padding: 14, display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left' }}>
<div style={{ width: 42, height: 42, borderRadius: 12, background: 'var(--c-warm-100)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.card size={22} style={{ color: 'var(--c-warm-text)' }} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>Цены</div>
<div className="sub" style={{ fontSize: 11 }}>{services.length} услуг</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: 42, height: 42, borderRadius: 12, background: 'var(--c-accent-50)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.doc size={22} style={{ color: 'var(--c-accent)' }} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, 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: 42, height: 42, borderRadius: 12, background: 'var(--c-success-50)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.shield size={22} style={{ color: 'var(--c-success)' }} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>Восстановление</div>
<div className="sub" style={{ fontSize: 11 }}>День 6 из 14</div>
</div>
</button>
</div>
</div>
{upcoming && upDoc && (
<div style={{ padding: '0 20px 16px' }}>
<SectionHeader title="Ближайший приём" pad="0 0 8px 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>
)}
{/* Clinic stats */}
<div style={{ padding: '0 20px 16px' }}>
<div className="card" style={{
padding: 18, background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))', border: 0,
}}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>Клиника УГН</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>
<div>
<div className="stat-big">27</div>
<div className="sub" style={{ fontSize: 11 }}>врачей</div>
</div>
<div>
<div className="stat-big">6</div>
<div className="sub" style={{ fontSize: 11 }}>к.м.н.</div>
</div>
<div>
<div className="stat-big">{new Date().getFullYear() - 2014}</div>
<div className="sub" style={{ fontSize: 11 }}>лет опыта</div>
</div>
</div>
</div>
</div>
{/* Articles */}
<div style={{ padding: '0 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 => (
<button key={a.id} onClick={() => nav.push('article:' + a.id)} className="press card" style={{
flexShrink: 0, width: 200, padding: 0, overflow: 'hidden', textAlign: 'left',
}}>
<div style={{
height: 80, background: a.hero,
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', padding: 10,
}}>
<span className="chip chip-warm" style={{ background: 'rgba(255,255,255,0.85)' }}>{a.tag}</span>
<span style={{ fontSize: 24, lineHeight: 1 }}>{a.emoji}</span>
</div>
<div style={{ padding: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.3, marginBottom: 6, minHeight: 34 }}>{a.title}</div>
<div className="sub" style={{ fontSize: 11 }}>{a.mins} мин</div>
</div>
</button>
))}
</div>
</div>
</div>
);
}
//
// Search screen
//
function SearchSection({ title, children }) {
return (
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', padding: '12px 4px 8px' }}>{title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>
</div>
);
}
export function SearchScreen({ nav }) {
const [q, setQ] = useState('');
const { doctors, services, articles, appointments, clinic } = CLINIC_DATA;
const results = useMemo(() => {
const Q = q.trim();
if (!Q) return null;
const isDateish = /сегодня|завтра|\b\d{1,2}\s*(?:апр|мар|май|июн)\b|приём|приёмы|визит/i.test(Q);
return {
doctors: doctors.filter(d => matchesQuery(Q, d.name, d.spec)).slice(0, 5),
services: services.filter(s => matchesQuery(Q, s.name, s.cat)).slice(0, 6),
symptoms: SYMPTOMS.filter(s => matchesQuery(Q, s.q, s.spec)).slice(0, 4),
articles: articles.filter(a => matchesQuery(Q, a.title, a.tag, a.lede)).slice(0, 3),
appointments: isDateish ? appointments.filter(a => a.status === 'upcoming').slice(0, 3) : [],
};
}, [q]);
const total = results ? (results.doctors.length + results.services.length + results.symptoms.length + results.articles.length + results.appointments.length) : 0;
return (
<div style={{ paddingBottom: 40, height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{
padding: '12px 16px 10px', display: 'flex', gap: 10, alignItems: 'center',
position: 'sticky', top: 0, zIndex: 10, background: 'var(--c-bg)',
}}>
<button onClick={() => nav.pop()} 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)', flexShrink: 0,
}}>
<I.chevL size={20} />
</button>
<div style={{
flex: 1, background: '#fff', borderRadius: 12, 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
autoFocus
value={q} onChange={e => setQ(e.target.value)}
placeholder="Врач, симптом, услуга, дата…"
style={{ flex: 1, border: 0, outline: 0, fontSize: 15, background: 'transparent' }}
/>
{q && (
<button onClick={() => setQ('')} style={{ padding: 2 }}>
<I.close size={16} style={{ color: 'var(--c-fg-4)' }} />
</button>
)}
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 20px' }}>
{!q.trim() && (
<>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', padding: '8px 4px 10px' }}>Популярные запросы</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{SUGGESTED.map(s => (
<button key={s} onClick={() => setQ(s)} className="pill">{s}</button>
))}
</div>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', padding: '24px 4px 10px' }}>Частые симптомы</div>
<div className="card" style={{ padding: 0 }}>
{SYMPTOMS.slice(0, 6).map((s, i, a) => (
<React.Fragment key={s.id}>
<button onClick={() => setQ(s.q)} className="press" style={{ width: '100%', padding: '12px 16px', display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left' }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<I.stetho size={18} style={{ color: 'var(--c-primary-darker)' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{s.q}</div>
<div className="sub" style={{ fontSize: 12 }}>{s.spec}</div>
</div>
<I.chev size={15} style={{ color: 'var(--c-fg-4)' }} />
</button>
{i < a.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 64 }} />}
</React.Fragment>
))}
</div>
</>
)}
{q.trim() && total === 0 && (
<div style={{ padding: '40px 20px', textAlign: 'center' }}>
<div style={{ fontSize: 40, opacity: .3, marginBottom: 8 }}>🔍</div>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 4 }}>Ничего не найдено</div>
<div className="sub" style={{ fontSize: 13 }}>Попробуйте другой запрос или <button onClick={() => nav.push('chat')} style={{ color: 'var(--c-primary-dark)', fontWeight: 700, display: 'inline' }}>напишите в чат</button></div>
</div>
)}
{results && results.symptoms.length > 0 && (
<SearchSection title={`Симптомы · ${results.symptoms.length}`}>
{results.symptoms.map(s => (
<button key={s.id} onClick={() => nav.push('booking-specs')} className="press card" style={{ width: '100%', 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', flexShrink: 0 }}>
<I.stetho size={20} style={{ color: 'var(--c-primary-darker)' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 2 }}>{s.q}</div>
<div className="sub" style={{ fontSize: 12 }}>{s.spec} · {s.suggest[0]}</div>
</div>
<I.chev size={16} style={{ color: 'var(--c-fg-4)', flexShrink: 0 }} />
</button>
))}
</SearchSection>
)}
{results && results.doctors.length > 0 && (
<SearchSection title={`Врачи · ${results.doctors.length}`}>
{results.doctors.map(d => (
<button key={d.id} onClick={() => nav.push('doctor:' + d.id)} className="press card" style={{ width: '100%', padding: 12, display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left' }}>
<Avatar init={d.init} size={44} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, lineHeight: 1.25 }}>{d.name}</div>
<div className="sub" style={{ fontSize: 12 }}>{d.spec} · {d.exp} лет</div>
</div>
<div className="price" style={{ fontSize: 14, flexShrink: 0 }}>{d.price} </div>
</button>
))}
</SearchSection>
)}
{results && results.services.length > 0 && (
<SearchSection title={`Услуги · ${results.services.length}`}>
<div className="card" style={{ padding: 0 }}>
{results.services.map((s, i, a) => (
<React.Fragment key={s.name}>
<button onClick={() => nav.push('prices')} className="press" style={{ width: '100%', padding: '12px 16px', display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3 }}>{s.name}</div>
<div className="sub" style={{ fontSize: 11 }}>{s.cat}</div>
</div>
<div className="price" style={{ fontSize: 14, flexShrink: 0 }}>{s.price.toLocaleString('ru')} </div>
</button>
{i < a.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)' }} />}
</React.Fragment>
))}
</div>
</SearchSection>
)}
{results && results.articles.length > 0 && (
<SearchSection title={`Статьи · ${results.articles.length}`}>
{results.articles.map(a => (
<button key={a.id} onClick={() => nav.push('article:' + a.id)} className="press card" style={{ width: '100%', padding: 12, display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left' }}>
<div style={{ width: 44, height: 44, borderRadius: 10, background: a.hero, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, flexShrink: 0 }}>{a.emoji}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, lineHeight: 1.25 }}>{a.title}</div>
<div className="sub" style={{ fontSize: 12 }}>{a.tag} · {a.mins} мин</div>
</div>
<I.chev size={16} style={{ color: 'var(--c-fg-4)', flexShrink: 0 }} />
</button>
))}
</SearchSection>
)}
{results && results.appointments.length > 0 && (
<SearchSection title={`Приёмы · ${results.appointments.length}`}>
{results.appointments.map(appt => {
const d = doctors.find(x => x.id === appt.doctor);
const ad = clinic.addresses.find(x => x.id === appt.address);
return <AppointmentCard key={appt.id} appt={appt} doctor={d} addr={ad} onClick={() => nav.push('appt:' + appt.id)} />;
})}
</SearchSection>
)}
</div>
</div>
);
}
//
// Contacts screen building + map mock per address
//
function BuildingMock({ variant = 'teal', title = 'КЛИНИКА УГН' }) {
const palettes = {
teal: {
sky: 'linear-gradient(180deg, #CFE6E2 0%, #E3F4F2 100%)',
ground: '#B5D1C0',
wall: '#E8DCC5',
roof: '#8B6B3A',
window: '#2B4B4A',
windowLit: '#F7D88B',
sign: '#166B63',
trees: '#5A8A5E',
},
warm: {
sky: 'linear-gradient(180deg, #F2E5C7 0%, #FDF8E6 100%)',
ground: '#B4A67D',
wall: '#C7E8E4',
roof: '#5C756A',
window: '#2B3B4A',
windowLit: '#FFE6A3',
sign: '#7A6A2E',
trees: '#6B8B4E',
},
};
const p = palettes[variant];
// Deterministic lit-window pattern (not random, for stable rendering)
const lit = [1,1,0,1,1, 1,0,1,0,1, 0,1,1,1,0, 1,1,0,1,1];
return (
<div style={{
aspectRatio: '16/9', position: 'relative', overflow: 'hidden', borderRadius: 14,
background: p.sky,
}}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '22%', background: p.ground }} />
<div style={{ position: 'absolute', bottom: '16%', left: 8, width: 36, height: 60, background: p.trees, borderRadius: '50% 50% 20% 20%' }} />
<div style={{ position: 'absolute', bottom: '16%', right: 8, width: 36, height: 60, background: p.trees, borderRadius: '50% 50% 20% 20%' }} />
<div style={{
position: 'absolute', bottom: '22%', left: '18%', right: '18%', top: '18%',
background: p.wall,
borderTopLeftRadius: 3, borderTopRightRadius: 3,
}}>
<div style={{
position: 'absolute', top: -10, left: -8, right: -8, height: 12,
background: p.roof, borderTopLeftRadius: 4, borderTopRightRadius: 4,
}} />
<div style={{
position: 'absolute', top: '26%', left: 0, right: 0, display: 'flex', justifyContent: 'center',
}}>
<div style={{
padding: '4px 12px', background: p.sign, color: '#fff',
fontSize: 11, fontWeight: 700, borderRadius: 3, letterSpacing: .5,
}}>{title}</div>
</div>
<div style={{
position: 'absolute', inset: '46% 10% 18%',
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gridTemplateRows: 'repeat(4, 1fr)',
gap: '6% 4%',
}}>
{lit.map((l, i) => (
<div key={i} style={{ background: l ? p.windowLit : p.window, borderRadius: 1 }} />
))}
</div>
<div style={{
position: 'absolute', bottom: 0, left: '50%', transform: 'translateX(-50%)',
width: '14%', height: '14%', background: p.window, borderTopLeftRadius: 3, borderTopRightRadius: 3,
}} />
</div>
</div>
);
}
function MapMock({ variant = 'teal' }) {
const pinColor = variant === 'teal' ? '#E04E44' : '#166B63';
return (
<div style={{
aspectRatio: '16/9', position: 'relative', overflow: 'hidden', borderRadius: 14,
background: '#E6EDE8',
}}>
{/* Main streets */}
<div style={{ position: 'absolute', top: '46%', left: 0, right: 0, height: '5%', background: '#fff' }} />
<div style={{ position: 'absolute', top: 0, bottom: 0, left: '50%', width: '4%', background: '#fff' }} />
<div style={{ position: 'absolute', top: '22%', left: '20%', width: '35%', height: '2%', background: '#fff', opacity: .7 }} />
<div style={{ position: 'absolute', top: '72%', left: '25%', width: '30%', height: '2%', background: '#fff', opacity: .7 }} />
{/* Park block */}
<div style={{ position: 'absolute', top: '58%', left: '6%', width: '32%', height: '28%', background: '#C7DBCA', borderRadius: 4 }} />
<div style={{ position: 'absolute', top: '62%', left: '10%', fontSize: 8, color: '#668F6C', fontWeight: 700 }}>ПАРК</div>
{/* Building footprints */}
<div style={{ position: 'absolute', top: '24%', left: '10%', width: '22%', height: '16%', background: '#D8D2C5', borderRadius: 2 }} />
<div style={{ position: 'absolute', top: '8%', left: '60%', width: '22%', height: '20%', background: '#D8D2C5', borderRadius: 2 }} />
<div style={{ position: 'absolute', top: '58%', right: '6%', width: '24%', height: '20%', background: '#D8D2C5', borderRadius: 2 }} />
{/* Pin */}
<div style={{ position: 'absolute', top: '46%', left: '50%', transform: 'translate(-50%, -100%)', filter: 'drop-shadow(0 3px 6px rgba(0,0,0,0.25))' }}>
<svg width="28" height="36" viewBox="0 0 24 32">
<path d="M12 0C5 0 0 5 0 12c0 8 12 20 12 20s12-12 12-20c0-7-5-12-12-12z" fill={pinColor}/>
<circle cx="12" cy="12" r="5" fill="#fff"/>
</svg>
</div>
{/* Scale indicator */}
<div style={{
position: 'absolute', bottom: 8, right: 8,
background: 'rgba(255,255,255,0.85)', borderRadius: 4,
padding: '3px 6px', fontSize: 9, color: 'var(--c-fg-3)', fontWeight: 700,
}}>100 м</div>
</div>
);
}
export function ContactsScreen({ nav }) {
const { clinic } = CLINIC_DATA;
const addresses = clinic.addresses.filter(a => a.id !== 'krasnokamsk');
const variants = ['teal', 'warm'];
return (
<div style={{ paddingBottom: 40 }}>
<ScreenHeader title="Контакты" onBack={() => nav.pop()} />
{/* Phone + hours */}
<div style={{ padding: '0 20px 14px' }}>
<div className="card" style={{ padding: 18, background: 'var(--c-primary-darker)', color: '#fff', border: 0 }}>
<div style={{ fontSize: 12, opacity: .8, marginBottom: 4 }}>Круглосуточная запись</div>
<div style={{ fontSize: 26, fontWeight: 700, fontFamily: 'var(--font-narrow)', letterSpacing: .5, marginBottom: 10 }}>{clinic.phone}</div>
<div style={{ fontSize: 13, opacity: .85, marginBottom: 14, display: 'flex', alignItems: 'center', gap: 6 }}>
<I.clock size={14} /> {clinic.hours}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button style={{
flex: 1, padding: '11px 0', borderRadius: 10, background: 'rgba(255,255,255,0.2)',
color: '#fff', fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}>
<I.phone size={16} /> Позвонить
</button>
<button onClick={() => nav.push('chat')} style={{
flex: 1, padding: '11px 0', borderRadius: 10, background: 'rgba(255,255,255,0.2)',
color: '#fff', fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}>
<I.chat size={16} /> Написать
</button>
</div>
</div>
</div>
{/* Addresses */}
<div style={{ padding: '0 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{addresses.map((a, i) => {
const variant = variants[i % variants.length];
return (
<div key={a.id} className="card" style={{ padding: 0, overflow: 'hidden' }}>
<BuildingMock variant={variant} />
<div style={{ padding: 16 }}>
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>{a.full}</div>
<div className="sub" style={{ fontSize: 13, marginBottom: 12 }}>{a.note}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 14, fontSize: 12, color: 'var(--c-fg-3)' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<I.clock size={13} /> ПнВс 9:0021:00
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<I.pin size={13} /> 2 этаж
</span>
</div>
<MapMock variant={variant} />
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button className="btn-p" style={{ flex: 2, padding: 12, fontSize: 13 }}>
<I.arrow size={15} /> Маршрут
</button>
<button className="btn-g" style={{ flex: 1, padding: 12, fontSize: 13 }}>
<I.phone size={15} />
</button>
<button className="btn-g" style={{ flex: 1, padding: 12, fontSize: 13 }}>
<I.video size={15} />
</button>
</div>
</div>
</div>
);
})}
</div>
{/* Transport tips */}
<div style={{ padding: '16px 20px 0' }}>
<div className="card" style={{ padding: 14, display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<I.pin size={18} style={{ color: 'var(--c-primary-darker)' }} />
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 2 }}>Как добраться</div>
<div className="sub" style={{ fontSize: 12, lineHeight: 1.5 }}>
Автобус 30, 40, 74 · остановка «Центральный рынок». Бесплатная парковка во дворе.
</div>
</div>
</div>
</div>
</div>
);
}
//
// Prices screen grouped services with search + category filter
//
export function PricesScreen({ nav }) {
const { services } = CLINIC_DATA;
const [q, setQ] = useState('');
const [cat, setCat] = useState('Все');
const categories = useMemo(() => ['Все', ...Array.from(new Set(services.map(s => s.cat)))], [services]);
const filtered = useMemo(() => {
return services.filter(s => {
if (cat !== 'Все' && s.cat !== cat) return false;
if (q.trim() && !matchesQuery(q, s.name, s.cat)) return false;
return true;
});
}, [q, cat, services]);
const grouped = useMemo(() => {
const m = new Map();
filtered.forEach(s => {
if (!m.has(s.cat)) m.set(s.cat, []);
m.get(s.cat).push(s);
});
return [...m.entries()];
}, [filtered]);
const minPrice = filtered.length ? Math.min(...filtered.map(s => s.price)) : 0;
const maxPrice = filtered.length ? Math.max(...filtered.map(s => s.price)) : 0;
return (
<div style={{ paddingBottom: 40 }}>
<ScreenHeader title="Цены" subtitle={`${services.length} услуг`} onBack={() => nav.pop()} />
<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' }}
/>
{q && (
<button onClick={() => setQ('')}>
<I.close size={16} style={{ color: 'var(--c-fg-4)' }} />
</button>
)}
</div>
</div>
<div className="pills" style={{ marginBottom: 12 }}>
{categories.map(c => (
<button key={c} onClick={() => setCat(c)} className={'pill' + (cat === c ? ' on' : '')}>{c}</button>
))}
</div>
{/* Price range summary */}
{filtered.length > 0 && (
<div style={{ padding: '0 20px 14px', display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--c-fg-3)' }}>
<span>Найдено: <strong style={{ color: 'var(--c-fg-1)' }}>{filtered.length}</strong></span>
<span>
от <span className="price" style={{ fontSize: 13 }}>{minPrice.toLocaleString('ru')} </span>
{' '}до <span className="price" style={{ fontSize: 13 }}>{maxPrice.toLocaleString('ru')} </span>
</span>
</div>
)}
<div style={{ padding: '0 16px' }}>
{grouped.length === 0 && (
<div style={{ padding: '40px 20px', textAlign: 'center' }}>
<div style={{ fontSize: 40, opacity: .3, marginBottom: 8 }}>📭</div>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 4 }}>Ничего не найдено</div>
<div className="sub" style={{ fontSize: 13 }}>Попробуйте сбросить фильтры</div>
<button onClick={() => { setQ(''); setCat('Все'); }} className="btn-s" style={{ marginTop: 14 }}>Сбросить</button>
</div>
)}
{grouped.map(([category, items]) => (
<div key={category} style={{ marginBottom: 22 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', padding: '4px 4px 10px', display: 'flex', justifyContent: 'space-between' }}>
<span>{category}</span>
<span>{items.length}</span>
</div>
<div className="card" style={{ padding: 0 }}>
{items.map((s, i) => (
<React.Fragment key={s.name}>
<button onClick={() => nav.push('booking-specs')} className="press" style={{
width: '100%', padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3, marginBottom: 2 }}>{s.name}</div>
<div className="sub" style={{ fontSize: 11 }}>{s.cat}</div>
</div>
<div className="price" style={{ fontSize: 16, flexShrink: 0 }}>{s.price.toLocaleString('ru')} </div>
<I.chev size={15} style={{ color: 'var(--c-fg-4)', flexShrink: 0 }} />
</button>
{i < items.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 16 }} />}
</React.Fragment>
))}
</div>
</div>
))}
{filtered.length > 0 && (
<button onClick={() => nav.push('booking-specs')} className="btn-p block" style={{ marginTop: 8 }}>
<I.plus size={18} /> Записаться на приём
</button>
)}
</div>
</div>
);
}
Loading…
Cancel
Save