Browse Source
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
3 changed files with 698 additions and 2 deletions
@ -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:00–21: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…
Reference in new issue