Support: chat, tickets list, SSE Postgres NOTIFY, read/unread
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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="Выход">
|
||||
|
||||
412
frontend/src/components/SupportChat.tsx
Normal file
412
frontend/src/components/SupportChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
frontend/src/pages/Support.tsx
Normal file
191
frontend/src/pages/Support.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user