Complete Sprint 2 (chat hub) and Sprint 3 (color palettes)

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>
This commit is contained in:
2026-04-20 16:41:52 +05:00
parent fa2d7f6b42
commit 6700e96476
7 changed files with 384 additions and 74 deletions
+268
View File
@@ -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>
);
}
+1 -62
View File
@@ -464,7 +464,7 @@ export function RecoveryScreen({ nav }) {
<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' }}>
<button onClick={() => nav.push('chat:doctor-syndaev')} className="btn-s" style={{ padding: '8px 12px' }}>
<I.chat size={15} /> Чат
</button>
</div>
@@ -606,67 +606,6 @@ export function AudioTestScreen({ nav }) {
);
}
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 = [
{
+2 -1
View File
@@ -142,7 +142,8 @@ export function HomeV2Screen({ nav }) {
{/* 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,
padding: 18, background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))',
border: '1.5px solid var(--c-primary-300)',
}}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>Клиника УГН</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>