Support: chat, tickets list, SSE Postgres NOTIFY, read/unread

This commit is contained in:
Fedor
2026-02-25 23:18:45 +03:00
parent d8fe0b605b
commit b3a7396d32
11 changed files with 1615 additions and 14 deletions

View File

@@ -65,9 +65,53 @@
color: #dc2626;
}
.app-bar-item-icon-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.app-bar-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.app-bar-profile-badge {
position: absolute;
top: -4px;
right: -6px;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 11px;
font-weight: 700;
line-height: 16px;
color: #fff;
text-align: center;
background: #dc2626;
border: 1.5px solid #fff;
border-radius: 50%;
box-sizing: border-box;
}
.app-bar-support-badge {
position: absolute;
bottom: -2px;
left: 50%;
transform: translate(-50%, 50%);
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
font-weight: 700;
line-height: 18px;
color: #fff;
text-align: center;
background: #dc2626;
border: 1.5px solid #fff;
border-radius: 9px;
box-sizing: border-box;
}

View File

@@ -3,27 +3,56 @@ import { Home, Headphones, User, LogOut, ArrowLeft } from 'lucide-react';
import './BottomBar.css';
import { miniappLog } from '../utils/miniappLogger';
function getSessionToken(): string | null {
if (typeof sessionStorage !== 'undefined') {
const s = sessionStorage.getItem('session_token');
if (s) return s;
}
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('session_token');
}
return null;
}
interface BottomBarProps {
currentPath: string;
avatarUrl?: string;
profileNeedsAttention?: boolean;
onNavigate?: (path: string) => void;
}
export default function BottomBar({ currentPath, avatarUrl, onNavigate }: BottomBarProps) {
export default function BottomBar({ currentPath, avatarUrl, profileNeedsAttention, onNavigate }: BottomBarProps) {
const isHome = currentPath.startsWith('/hello');
const isProfile = currentPath === '/profile';
const isSupport = currentPath === '/support';
const [backEnabled, setBackEnabled] = useState(false);
const [supportUnreadCount, setSupportUnreadCount] = useState(0);
// Непрочитанные в поддержке — для бейджа на иконке
useEffect(() => {
const token = getSessionToken();
if (!token) {
setSupportUnreadCount(0);
return;
}
const params = new URLSearchParams();
params.set('session_token', token);
fetch(`/api/v1/support/unread-count?${params.toString()}`)
.then((res) => (res.ok ? res.json() : { unread_count: 0 }))
.then((data) => setSupportUnreadCount(data.unread_count ?? 0))
.catch(() => setSupportUnreadCount(0));
}, [currentPath]);
// В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться
useEffect(() => {
if (isHome || isProfile) {
if (isHome || isProfile || isSupport) {
setBackEnabled(false);
return;
}
setBackEnabled(false);
const t = window.setTimeout(() => setBackEnabled(true), 1200);
return () => window.clearTimeout(t);
}, [isHome, isProfile, currentPath]);
}, [isHome, isProfile, isSupport, currentPath]);
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
@@ -153,25 +182,37 @@ export default function BottomBar({ currentPath, avatarUrl, onNavigate }: Bottom
onNavigate('/profile');
}
}}
aria-label={profileNeedsAttention ? 'Профиль — требуется подтверждение данных' : 'Профиль'}
>
{avatarUrl ? (
<img src={avatarUrl} alt="" className="app-bar-avatar" />
) : (
<User size={24} strokeWidth={1.8} />
)}
<span className="app-bar-item-icon-wrap">
{avatarUrl ? (
<img src={avatarUrl} alt="" className="app-bar-avatar" />
) : (
<User size={24} strokeWidth={1.8} />
)}
{profileNeedsAttention && <span className="app-bar-profile-badge" aria-hidden>!</span>}
</span>
<span>Профиль</span>
</a>
<a
href="/hello"
className="app-bar-item"
href="/support"
className={`app-bar-item ${isSupport ? 'app-bar-item--active' : ''}`}
onClick={(e) => {
if (onNavigate && !currentPath.startsWith('/hello')) {
if (onNavigate && currentPath !== '/support') {
e.preventDefault();
onNavigate('/hello');
onNavigate('/support');
}
}}
aria-label={supportUnreadCount > 0 ? `Поддержка: ${supportUnreadCount} непрочитанных` : 'Поддержка'}
>
<Headphones size={24} strokeWidth={1.8} />
<span className="app-bar-item-icon-wrap">
<Headphones size={24} strokeWidth={1.8} />
{supportUnreadCount > 0 && (
<span className="app-bar-support-badge" aria-hidden>
{supportUnreadCount > 99 ? '99+' : supportUnreadCount}
</span>
)}
</span>
<span>Поддержка</span>
</a>
<button type="button" className="app-bar-item app-bar-item--exit" onClick={handleExit} aria-label="Выход">

View File

@@ -0,0 +1,412 @@
/**
* SupportChat — диалог поддержки: список сообщений + ввод.
* Новые ответы приходят по SSE (Postgres NOTIFY), один канал на пользователя.
* Если треда ещё нет — показывается форма первого сообщения; после отправки — чат.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Form, Input, Spin, Typography } from 'antd';
import { Paperclip, X } from 'lucide-react';
const { TextArea } = Input;
export interface SupportMessage {
id: string;
direction: 'user' | 'support';
body: string;
attachments: Array<{ filename?: string; url?: string }>;
created_at: string;
}
export interface SupportThreadResponse {
thread_id: string | null;
messages: SupportMessage[];
ticket_id: string | null;
}
export interface SupportChatProps {
claimId?: string;
source?: 'bar' | 'complaint_card';
compact?: boolean;
onSuccess?: () => void;
hideClaimLabel?: boolean;
}
function getSessionToken(): string | null {
if (typeof sessionStorage !== 'undefined') {
const s = sessionStorage.getItem('session_token');
if (s) return s;
}
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('session_token');
}
return null;
}
function buildThreadUrl(claimId?: string): string {
const token = getSessionToken();
const params = new URLSearchParams();
if (token) params.set('session_token', token);
if (claimId) params.set('claim_id', claimId);
return `/api/v1/support/thread?${params.toString()}`;
}
function buildStreamUrl(): string {
const token = getSessionToken();
if (!token) return '';
const params = new URLSearchParams();
params.set('session_token', token);
return `/api/v1/support/stream?${params.toString()}`;
}
export default function SupportChat({
claimId,
source = 'bar',
compact = false,
onSuccess,
hideClaimLabel = false,
}: SupportChatProps) {
const [threadId, setThreadId] = useState<string | null>(null);
const [messages, setMessages] = useState<SupportMessage[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const [files, setFiles] = useState<File[]>([]);
const [fileInputKey, setFileInputKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const threadIdRef = useRef<string | null>(null);
threadIdRef.current = threadId;
const markRead = useCallback((tid: string) => {
const token = getSessionToken();
if (!token) return;
const params = new URLSearchParams();
params.set('session_token', token);
params.set('thread_id', tid);
fetch(`/api/v1/support/read?${params.toString()}`, { method: 'POST' }).catch(() => {});
}, []);
const fetchThread = useCallback(async () => {
const token = getSessionToken();
if (!token) return;
try {
const res = await fetch(buildThreadUrl(claimId));
if (!res.ok) return;
const data: SupportThreadResponse = await res.json();
setThreadId(data.thread_id || null);
setMessages(data.messages || []);
if (data.thread_id) markRead(data.thread_id);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [claimId, markRead]);
useEffect(() => {
fetchThread();
}, [fetchThread]);
// SSE: один поток на пользователя, новые сообщения от поддержки приходят по Postgres NOTIFY
useEffect(() => {
const url = buildStreamUrl();
if (!url) return;
const es = new EventSource(url);
eventSourceRef.current = es;
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data || '{}');
if (data.event !== 'support_message' || !data.message || !data.thread_id) return;
if (data.thread_id !== threadIdRef.current) return;
const msg = data.message as SupportMessage;
const created_at =
typeof msg.created_at === 'string'
? msg.created_at
: (msg.created_at as unknown as { isoformat?: () => string })?.isoformat?.() ?? new Date().toISOString();
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, { ...msg, created_at, attachments: msg.attachments || [] }];
});
} catch {
// ignore
}
};
es.onerror = () => {
es.close();
eventSourceRef.current = null;
};
return () => {
es.close();
eventSourceRef.current = null;
};
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async () => {
const values = await form.getFieldsValue();
const text = (values.message || '').trim();
if (!text) return;
const token = getSessionToken();
if (!token) return;
const fd = new FormData();
fd.append('message', text);
fd.append('source', source);
fd.append('session_token', token);
if (claimId) fd.append('claim_id', claimId);
if (threadId) fd.append('thread_id', threadId);
files.forEach((file, i) => {
fd.append(`attachments[${i}]`, file, file.name);
});
setSubmitting(true);
try {
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
const data = await res.json();
if (data.thread_id) setThreadId(data.thread_id);
await fetchThread();
form.setFieldValue('message', '');
setFiles([]);
setFileInputKey((k) => k + 1);
} catch (e) {
console.error(e);
} finally {
setSubmitting(false);
}
};
const handleFirstMessage = async () => {
const values = await form.validateFields().catch(() => null);
if (!values?.message?.trim()) return;
const token = getSessionToken();
if (!token) return;
const fd = new FormData();
fd.append('message', values.message.trim());
if (values.subject?.trim()) fd.append('subject', values.subject.trim());
fd.append('source', source);
fd.append('session_token', token);
if (claimId) fd.append('claim_id', claimId);
files.forEach((file, i) => {
fd.append(`attachments[${i}]`, file, file.name);
});
setSubmitting(true);
try {
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
const data = await res.json();
if (data.thread_id) setThreadId(data.thread_id);
await fetchThread();
form.resetFields();
setFiles([]);
setFileInputKey((k) => k + 1);
onSuccess?.();
} catch (e) {
console.error(e);
} finally {
setSubmitting(false);
}
};
const addFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
setFiles((prev) => [...prev, ...selected]);
setFileInputKey((k) => k + 1);
e.target.value = '';
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin />
</div>
);
}
const showChat = threadId && messages.length > 0;
if (!showChat) {
return (
<div className={compact ? 'support-chat support-chat--compact' : 'support-chat'}>
{claimId && !hideClaimLabel && (
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению {claimId}</p>
)}
<Form form={form} layout="vertical" onFinish={handleFirstMessage}>
<Form.Item
name="message"
label="Сообщение"
rules={[{ required: true, message: 'Введите текст' }]}
>
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос..." maxLength={5000} showCount />
</Form.Item>
<Form.Item name="subject" label="Тема (необязательно)">
<Input placeholder="Краткая тема" maxLength={200} />
</Form.Item>
<Form.Item label="Прикрепить файлы">
<input
key={fileInputKey}
type="file"
multiple
style={{ display: 'none' }}
id="support-chat-files"
onChange={addFile}
/>
<Button
type="button"
icon={<Paperclip size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />}
onClick={() => document.getElementById('support-chat-files')?.click()}
>
Прикрепить
</Button>
{files.length > 0 && (
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
{files.map((f, i) => (
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
<button
type="button"
aria-label="Удалить"
onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<X size={14} />
</button>
</li>
))}
</ul>
)}
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={submitting} block={compact}>
Отправить
</Button>
</Form.Item>
</Form>
</div>
);
}
return (
<div className={compact ? 'support-chat support-chat--compact' : 'support-chat'} style={{ display: 'flex', flexDirection: 'column', minHeight: compact ? 320 : 400 }}>
{claimId && !hideClaimLabel && (
<p style={{ marginBottom: 8, color: '#666', fontSize: 13 }}>По обращению {claimId}</p>
)}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '12px 0',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
{messages.map((msg) => (
<div
key={msg.id}
style={{
alignSelf: msg.direction === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
padding: '10px 14px',
borderRadius: 12,
background: msg.direction === 'user' ? '#e3f2fd' : '#f5f5f5',
border: `1px solid ${msg.direction === 'user' ? '#90caf9' : '#e0e0e0'}`,
}}
>
<Typography.Text style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{msg.body}
</Typography.Text>
{msg.attachments?.length > 0 && (
<div style={{ marginTop: 6, fontSize: 12, color: '#666' }}>
{msg.attachments.map((a, i) => (
<div key={i}>{a.filename || a.url || 'Файл'}</div>
))}
</div>
)}
<div style={{ marginTop: 4, fontSize: 11, color: '#999' }}>
{new Date(msg.created_at).toLocaleString('ru-RU')}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<Form form={form} onFinish={handleSend} style={{ flexShrink: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<Form.Item name="message" style={{ flex: 1, marginBottom: 0 }}>
<TextArea
placeholder="Сообщение..."
autoSize={{ minRows: 1, maxRows: 4 }}
maxLength={5000}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
form.submit();
}
}}
/>
</Form.Item>
<input
key={fileInputKey}
type="file"
multiple
style={{ display: 'none' }}
id="support-chat-files-chat"
onChange={addFile}
/>
<Button
type="button"
icon={<Paperclip size={18} />}
onClick={() => document.getElementById('support-chat-files-chat')?.click()}
/>
<Button type="primary" htmlType="submit" loading={submitting}>
Отправить
</Button>
</div>
{files.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{files.map((f, i) => (
<span
key={i}
style={{
fontSize: 12,
padding: '2px 8px',
background: '#f0f0f0',
borderRadius: 4,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
{f.name}
<button
type="button"
aria-label="Удалить"
onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
>
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,191 @@
/**
* Страница «Поддержка» — сначала список тикетов/тредов, по клику — чат или новое обращение.
*/
import { useEffect, useState } from 'react';
import { Button, List, Spin, Typography } from 'antd';
import { ArrowLeft, MessageCirclePlus } from 'lucide-react';
import SupportChat from '../components/SupportChat';
const { Title, Text } = Typography;
export interface SupportThreadItem {
thread_id: string;
claim_id: string | null;
source: string;
ticket_id: string | null;
created_at: string;
updated_at: string;
last_body: string | null;
last_at: string | null;
messages_count: number;
unread_count: number;
}
function getSessionToken(): string | null {
if (typeof sessionStorage !== 'undefined') {
const s = sessionStorage.getItem('session_token');
if (s) return s;
}
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('session_token');
}
return null;
}
interface SupportProps {
onNavigate?: (path: string) => void;
}
export default function Support({ onNavigate }: SupportProps) {
const [view, setView] = useState<'list' | 'chat'>('list');
const [threads, setThreads] = useState<SupportThreadItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedClaimId, setSelectedClaimId] = useState<string | null | undefined>(undefined);
useEffect(() => {
if (view !== 'list') return;
const token = getSessionToken();
if (!token) {
setLoading(false);
return;
}
const params = new URLSearchParams();
params.set('session_token', token);
fetch(`/api/v1/support/threads?${params.toString()}`)
.then((res) => (res.ok ? res.json() : { threads: [] }))
.then((data) => {
setThreads(data.threads || []);
})
.catch(() => setThreads([]))
.finally(() => setLoading(false));
}, [view]);
const handleOpenThread = (claimId: string | null) => {
setSelectedClaimId(claimId);
setView('chat');
};
const handleBack = () => {
setView('list');
setSelectedClaimId(undefined);
};
if (view === 'chat') {
return (
<div style={{ padding: 24, maxWidth: 560, margin: '0 auto' }}>
<Button
type="text"
icon={<ArrowLeft size={18} />}
onClick={handleBack}
style={{ marginBottom: 16, paddingLeft: 0 }}
>
К списку обращений
</Button>
<SupportChat
claimId={selectedClaimId === null ? undefined : selectedClaimId ?? undefined}
source="bar"
onSuccess={() => {
handleBack();
if (onNavigate) onNavigate('/hello');
else {
window.history.pushState({}, '', '/hello');
window.dispatchEvent(new PopStateEvent('popstate'));
}
}}
/>
</div>
);
}
return (
<div style={{ padding: 24, maxWidth: 560, margin: '0 auto' }}>
<Title level={2} style={{ marginBottom: 8 }}>
Поддержка
</Title>
<Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
Ваши обращения и переписка с поддержкой.
</Text>
<Button
type="primary"
icon={<MessageCirclePlus size={18} style={{ marginRight: 6 }} />}
onClick={() => handleOpenThread(null)}
style={{ marginBottom: 24, width: '100%' }}
>
Новое обращение
</Button>
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin />
</div>
) : threads.length === 0 ? (
<Text type="secondary">Пока нет обращений. Нажмите «Новое обращение», чтобы написать.</Text>
) : (
<List
itemLayout="vertical"
dataSource={threads}
renderItem={(item) => {
const title = item.claim_id
? `По заявке №${item.claim_id}`
: 'Общее обращение';
const sub = item.last_at
? new Date(item.last_at).toLocaleString('ru-RU')
: new Date(item.created_at).toLocaleString('ru-RU');
const preview = item.last_body
? item.last_body.replace(/\s+/g, ' ').trim().slice(0, 80) + (item.last_body.length > 80 ? '…' : '')
: 'Нет сообщений';
return (
<List.Item
key={item.thread_id}
style={{
padding: '14px 16px',
background: '#fafafa',
borderRadius: 12,
marginBottom: 10,
cursor: 'pointer',
border: '1px solid #f0f0f0',
}}
onClick={() => handleOpenThread(item.claim_id)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text strong>{title}</Text>
{item.unread_count > 0 && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 20,
height: 20,
padding: '0 6px',
borderRadius: 10,
background: '#ff4d4f',
color: '#fff',
fontSize: 12,
fontWeight: 600,
}}
title="Непрочитанные сообщения"
>
{item.unread_count > 99 ? '99+' : item.unread_count}
</span>
)}
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>{sub}</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 6 }}>{preview}</div>
</div>
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>
{item.messages_count} сообщ.
</Text>
</div>
</List.Item>
);
}}
/>
)}
</div>
);
}