Browse Source
Sprint 2 — chat hub: - Chat tab becomes a list of three conversations: AI assistant (featured, gradient card), doctor Syndaev, and clinic administrator - data.js: new `chats` array with kind, participants, message history, online/unread state, time of last message, AI suggestions - screens-chats.jsx: ChatsListScreen and ChatConversationScreen with per-kind UI — AI gets suggestion chips + AI-badge, doctor gets video-call button, operator gets phone button - Recovery surgeon chat button routes to chat:doctor-syndaev directly - Tab bar auto-hides on pushed chat:<id> routes - ChatTabScreen removed from screens-misc.jsx Sprint 3 — color palettes: - ACCENT_OPTIONS extended with accent/accentDark/accent50, p300/success50/fg4 so palette switches change the full theme (primary + warm + accent + muted + success) - New palette "Лагуна" from the design-system screenshot: primary #29AEE3 (sky blue), accent #FFA39C (coral), warm #E9E4D4 (beige) - New palette "Бриз": Лагуна variant with primary #63BAC3 (muted teal) and the bright sky blue #29AEE3 demoted to p300 - All 9 screenshot colors wired: #f2fee6→success-50, #93908f→fg-4, #63bac3→p300 (visible as border on Clinic Stats card in Home V2) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>main
7 changed files with 384 additions and 74 deletions
@ -0,0 +1,268 @@
|
||||
import React from 'react'; |
||||
import { I } from '../icons.jsx'; |
||||
import { CLINIC_DATA } from '../data.js'; |
||||
import { Avatar } from '../components.jsx'; |
||||
|
||||
// ───────────────────────────────────────────────────────────── |
||||
// Chats list — the main chat tab |
||||
// ───────────────────────────────────────────────────────────── |
||||
export function ChatsListScreen({ nav }) { |
||||
const { chats, doctors } = CLINIC_DATA; |
||||
const ai = chats.find(c => c.kind === 'ai'); |
||||
const rest = chats.filter(c => c.kind !== 'ai'); |
||||
|
||||
const subjectFor = (c) => { |
||||
if (c.kind === 'doctor') { |
||||
const d = doctors.find(x => x.id === c.doctorId); |
||||
return { |
||||
avatar: <Avatar init={d.init} size={48} />, |
||||
title: d.name.split(' ').slice(0, 2).join(' '), |
||||
subtitle: d.spec, |
||||
}; |
||||
} |
||||
const bg = c.kind === 'operator' ? 'var(--c-warm-100)' : 'var(--c-primary-100)'; |
||||
return { |
||||
avatar: ( |
||||
<div style={{ |
||||
width: 48, height: 48, borderRadius: 999, background: bg, |
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, |
||||
}}>{c.icon}</div> |
||||
), |
||||
title: c.name, |
||||
subtitle: c.subtitle, |
||||
}; |
||||
}; |
||||
|
||||
return ( |
||||
<div style={{ paddingBottom: 100 }}> |
||||
<div style={{ padding: '12px 20px 12px' }}> |
||||
<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 placeholder="Найти в чатах..." style={{ flex: 1, border: 0, outline: 0, fontSize: 15, background: 'transparent' }} /> |
||||
</div> |
||||
</div> |
||||
|
||||
{/* AI — featured */} |
||||
{ai && ( |
||||
<div style={{ padding: '0 16px 18px' }}> |
||||
<button onClick={() => nav.push('chat:' + ai.id)} className="press" style={{ |
||||
width: '100%', padding: 16, |
||||
background: 'linear-gradient(135deg, var(--c-primary-darker), var(--c-primary-dark))', |
||||
color: '#fff', borderRadius: 20, textAlign: 'left', |
||||
display: 'flex', gap: 14, alignItems: 'center', |
||||
boxShadow: '0 10px 28px rgba(22,107,99,.22)', |
||||
}}> |
||||
<div style={{ |
||||
width: 52, height: 52, borderRadius: 14, |
||||
background: 'rgba(255,255,255,0.18)', |
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', |
||||
fontSize: 26, |
||||
}}>✨</div> |
||||
<div style={{ flex: 1, minWidth: 0 }}> |
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}> |
||||
<span style={{ fontSize: 15, fontWeight: 700 }}>{ai.name}</span> |
||||
<span style={{ padding: '2px 6px', borderRadius: 5, background: 'rgba(255,255,255,0.25)', fontSize: 9, fontWeight: 700, letterSpacing: .5 }}>AI</span> |
||||
</div> |
||||
<div style={{ fontSize: 13, opacity: .85, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> |
||||
{ai.lastMessage} |
||||
</div> |
||||
</div> |
||||
<I.chev size={18} style={{ color: 'rgba(255,255,255,0.8)', flexShrink: 0 }} /> |
||||
</button> |
||||
</div> |
||||
)} |
||||
|
||||
{/* Other chats */} |
||||
<div style={{ padding: '0 16px' }}> |
||||
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', padding: '0 4px 10px' }}>Все диалоги · {rest.length}</div> |
||||
<div className="card" style={{ padding: 0 }}> |
||||
{rest.map((c, i) => { |
||||
const s = subjectFor(c); |
||||
return ( |
||||
<React.Fragment key={c.id}> |
||||
<button onClick={() => nav.push('chat:' + c.id)} className="press" style={{ |
||||
width: '100%', padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left', |
||||
}}> |
||||
<div style={{ position: 'relative', flexShrink: 0 }}> |
||||
{s.avatar} |
||||
{c.online && <span style={{ |
||||
position: 'absolute', bottom: 1, right: 1, |
||||
width: 12, height: 12, borderRadius: 999, background: 'var(--c-success)', |
||||
border: '2px solid #fff', |
||||
}} />} |
||||
</div> |
||||
<div style={{ flex: 1, minWidth: 0 }}> |
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10, marginBottom: 2 }}> |
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--c-fg-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.title}</div> |
||||
<div className="sub" style={{ fontSize: 11, flexShrink: 0, color: c.unread > 0 ? 'var(--c-primary-darker)' : 'var(--c-fg-3)', fontWeight: c.unread > 0 ? 700 : 400 }}>{c.lastTime}</div> |
||||
</div> |
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}> |
||||
<div style={{ fontSize: 13, color: c.unread > 0 ? 'var(--c-fg-1)' : 'var(--c-fg-3)', fontWeight: c.unread > 0 ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>{c.lastMessage}</div> |
||||
{c.unread > 0 && ( |
||||
<span style={{ |
||||
background: 'var(--c-accent)', color: '#fff', fontSize: 11, fontWeight: 700, |
||||
padding: '2px 7px', borderRadius: 999, flexShrink: 0, minWidth: 20, textAlign: 'center', |
||||
}}>{c.unread}</span> |
||||
)} |
||||
</div> |
||||
</div> |
||||
</button> |
||||
{i < rest.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 76 }} />} |
||||
</React.Fragment> |
||||
); |
||||
})} |
||||
</div> |
||||
|
||||
<button onClick={() => nav.push('doctors')} className="btn-g" style={{ |
||||
marginTop: 14, width: '100%', padding: 14, fontSize: 14, |
||||
}}> |
||||
<I.plus size={18} /> Новый чат с врачом |
||||
</button> |
||||
|
||||
<div style={{ marginTop: 14, padding: 12, background: 'var(--c-primary-50)', borderRadius: 12, display: 'flex', gap: 10, alignItems: 'flex-start' }}> |
||||
<I.shield size={18} style={{ color: 'var(--c-primary-darker)', flexShrink: 0, marginTop: 2 }} /> |
||||
<div className="sub" style={{ fontSize: 12, lineHeight: 1.5 }}> |
||||
Чаты с врачом доступны в течение 14 дней после приёма. Экстренные случаи — по телефону (342) 207-03-03. |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
// ───────────────────────────────────────────────────────────── |
||||
// Chat conversation |
||||
// ───────────────────────────────────────────────────────────── |
||||
export function ChatConversationScreen({ nav, chatId }) { |
||||
const chat = CLINIC_DATA.chats.find(c => c.id === chatId) || CLINIC_DATA.chats[0]; |
||||
const isAI = chat.kind === 'ai'; |
||||
const isOp = chat.kind === 'operator'; |
||||
const isDoc = chat.kind === 'doctor'; |
||||
const doc = isDoc ? CLINIC_DATA.doctors.find(d => d.id === chat.doctorId) : null; |
||||
|
||||
const title = isDoc ? doc.name.split(' ').slice(0, 2).join(' ') : chat.name; |
||||
const subtitle = isDoc |
||||
? (chat.online ? 'Онлайн · отвечает 5 мин' : 'Был(а) в сети недавно') |
||||
: isAI |
||||
? (chat.online ? 'Онлайн · отвечает мгновенно' : chat.subtitle) |
||||
: chat.subtitle; |
||||
|
||||
return ( |
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}> |
||||
{/* Header */} |
||||
<div style={{ |
||||
padding: '12px 16px 10px', display: 'flex', gap: 10, alignItems: 'center', |
||||
background: 'var(--c-bg)', borderBottom: '1px solid var(--c-border)', |
||||
flexShrink: 0, |
||||
}}> |
||||
<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={{ position: 'relative', flexShrink: 0 }}> |
||||
{isDoc && <Avatar init={doc.init} size={42} />} |
||||
{!isDoc && ( |
||||
<div style={{ |
||||
width: 42, height: 42, borderRadius: 999, |
||||
background: isAI |
||||
? 'linear-gradient(135deg, var(--c-primary-darker), var(--c-primary-dark))' |
||||
: 'var(--c-warm-100)', |
||||
color: isAI ? '#fff' : 'var(--c-warm-text)', |
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, |
||||
}}>{chat.icon}</div> |
||||
)} |
||||
{chat.online && <span style={{ |
||||
position: 'absolute', bottom: 0, right: 0, |
||||
width: 11, height: 11, borderRadius: 999, background: 'var(--c-success)', |
||||
border: '2px solid var(--c-bg)', |
||||
}} />} |
||||
</div> |
||||
<div style={{ flex: 1, minWidth: 0 }}> |
||||
<div style={{ fontSize: 15, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 6 }}> |
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</span> |
||||
{isAI && <span style={{ padding: '1px 6px', borderRadius: 5, background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)', fontSize: 9, fontWeight: 700, letterSpacing: .5 }}>AI</span>} |
||||
</div> |
||||
<div className="sub" style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}> |
||||
{chat.online && <span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-success)' }} />} |
||||
{subtitle} |
||||
</div> |
||||
</div> |
||||
{isDoc && ( |
||||
<button onClick={() => nav.push('telemed')} className="btn-s" style={{ padding: 0, width: 38, height: 38, borderRadius: 999, flexShrink: 0 }}> |
||||
<I.video size={16} /> |
||||
</button> |
||||
)} |
||||
{isOp && ( |
||||
<button className="btn-s" style={{ padding: 0, width: 38, height: 38, borderRadius: 999, flexShrink: 0 }}> |
||||
<I.phone size={16} /> |
||||
</button> |
||||
)} |
||||
</div> |
||||
|
||||
{/* Messages */} |
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: 10 }}> |
||||
{chat.messages.map((m, i) => { |
||||
const mine = m.from === 'me'; |
||||
return ( |
||||
<div key={i} style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '80%' }}> |
||||
<div style={{ |
||||
background: mine |
||||
? 'var(--c-primary-darker)' |
||||
: isAI |
||||
? 'linear-gradient(135deg, #F2FAF9, #E3F4F2)' |
||||
: '#fff', |
||||
color: mine ? '#fff' : 'var(--c-fg-1)', |
||||
padding: '10px 14px', borderRadius: 16, |
||||
borderBottomRightRadius: mine ? 4 : 16, |
||||
borderBottomLeftRadius: mine ? 16 : 4, |
||||
fontSize: 14, lineHeight: 1.5, |
||||
boxShadow: mine ? 'none' : 'var(--sh-sm)', |
||||
border: !mine && isAI ? '1px solid var(--c-primary-200)' : 'none', |
||||
}}>{m.t}</div> |
||||
<div className="sub" style={{ fontSize: 11, marginTop: 3, textAlign: mine ? 'right' : 'left', padding: '0 4px' }}>{m.tm}</div> |
||||
</div> |
||||
); |
||||
})} |
||||
</div> |
||||
|
||||
{/* Suggested replies (AI only) */} |
||||
{isAI && chat.suggestions && ( |
||||
<div style={{ padding: '4px 16px 8px', display: 'flex', gap: 8, overflowX: 'auto', flexShrink: 0 }} className="noscroll"> |
||||
{chat.suggestions.map((s, i) => ( |
||||
<button key={i} style={{ |
||||
flexShrink: 0, padding: '8px 14px', borderRadius: 999, fontSize: 13, |
||||
background: '#fff', color: 'var(--c-primary-darker)', |
||||
border: '1px solid var(--c-primary-200)', fontWeight: 600, |
||||
}}>{s}</button> |
||||
))} |
||||
</div> |
||||
)} |
||||
|
||||
{/* Input */} |
||||
<div style={{ padding: '10px 16px 100px', borderTop: '1px solid var(--c-border)', background: '#fff', flexShrink: 0 }}> |
||||
<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)', |
||||
}}> |
||||
{isAI ? 'Спросите что-нибудь...' : 'Сообщение...'} |
||||
</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> |
||||
); |
||||
} |
||||
Loading…
Reference in new issue