Backoffice stage 2: chats (support + concierge, files)

- ChatThread per client/provider and channel; messages get files, internal notes,
  source (bot/mini app) and delivery errors; reply templates. Migration moves old
  concierge messages into threads.
- Routing: subscribers write to the concierge, others to support; /support and
  /concierge in the client bot; staff can take, transfer, close and move threads.
- Telegram: photos/documents from clients and providers, replies with the channel
  header, files sent from storage, notifications only to the assignee or the
  channel's staff, "Reply" into a specific thread.
- New admin: /chats with list, conversation, notes, files, templates, client panel;
  "Message" button on the client card; dashboard counts threads awaiting reply.
- Client mini app: in-app chat screen sharing the same conversation.
- Old admin keeps working on the same data (notes hidden there).
- Tests for routing, notes, assign/close/move, lists, recipients; separate test DB
  via TEST_DB_NAME; dev:chats-demo script.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:50:37 +04:00
1 parent d452824c62
commit 23ffb2bf1d
46 files changed
+4176 -144

No files matched your search

+9 -7
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Center, Icon, Spinner } from '@barev/ui';
import { I18nProvider, match, openTelegram, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
import { I18nProvider, match, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
import type { ReactNode } from 'react';
import { Consent } from './screens/Consent.tsx';
import { Docs } from './screens/Docs.tsx';
@@ -15,6 +15,7 @@ import { Orders } from './screens/Orders.tsx';
import { Perks } from './screens/Perks.tsx';
import { Profile } from './screens/Profile.tsx';
import { Services } from './screens/Services.tsx';
import { Chat } from './screens/Chat.tsx';
const ROOTS = ['/', '/services', '/perks', '/profile', '/docs'];
@@ -38,7 +39,7 @@ export function App({ hasInitData }: { hasInitData: boolean }) {
) : !me.data.segment ? (
<Segment />
) : (
<Routes segment={me.data.segment} conciergeUrl={me.data.conciergeUrl} />
<Routes segment={me.data.segment} />
)}
</I18nProvider>
);
@@ -48,7 +49,7 @@ export function App({ hasInitData }: { hasInitData: boolean }) {
* Два сценария: MOVING — план переезда, документы, консьерж; LIVING — услуги со скидками, партнёры, заказы.
* Остальные экраны (услуги, подписка, заказы) доступны из обоих.
*/
function Routes({ segment, conciergeUrl }: { segment: 'MOVING' | 'LIVING'; conciergeUrl: string }) {
function Routes({ segment }: { segment: 'MOVING' | 'LIVING' }) {
const { path } = useLocation();
useTelegramBack(path, ROOTS);
let screen: ReactNode;
@@ -63,18 +64,19 @@ function Routes({ segment, conciergeUrl }: { segment: 'MOVING' | 'LIVING'; conci
else if (path === '/perks') screen = <Perks />;
else if (path === '/profile') screen = <Profile />;
else if (path === '/membership') screen = <Membership />;
else if (path === '/chat') screen = <Chat />;
else screen = segment === 'MOVING' ? <Move /> : <Home />;
const withNav = ROOTS.includes(path) || path === '/orders';
return (
<>
{screen}
{withNav && (segment === 'MOVING' ? <MoveNav path={path} conciergeUrl={conciergeUrl} /> : <BottomNav path={path} />)}
{withNav && (segment === 'MOVING' ? <MoveNav path={path} /> : <BottomNav path={path} />)}
</>
);
}
/** Меню переезда: План / Документы / Консьерж (чат с ботом) / Услуги / Профиль */
function MoveNav({ path, conciergeUrl }: { path: string; conciergeUrl: string }) {
function MoveNav({ path }: { path: string }) {
const { t } = useI18n();
const item = (href: string, icon: Parameters<typeof Icon>[0]['name'], label: string) => (
<a href={`#${href}`} aria-current={path === href ? 'page' : undefined}>
@@ -86,9 +88,9 @@ function MoveNav({ path, conciergeUrl }: { path: string; conciergeUrl: string })
<nav className="bc-bottomnav" aria-label={t('client.nav.label')}>
{item('/', 'globe', t('client.nav.plan'))}
{item('/docs', 'docs', t('client.nav.docs'))}
<button type="button" className="bc-bottomnav__plus" onClick={() => openTelegram(conciergeUrl)} aria-label={t('client.concierge.title')}>
<a href="#/chat" className="bc-bottomnav__plus" aria-label={t('client.concierge.title')}>
<Icon name="chat" size={24} strokeWidth={2.4} />
</button>
</a>
{item('/services', 'grid', t('client.nav.services'))}
{item('/profile', 'user', t('client.nav.profile'))}
</nav>
+154
View File
@@ -779,3 +779,157 @@
background: var(--bc-bg);
color: var(--bc-ink-3);
}
/* ───── чат с командой ───── */
.chat-screen {
max-width: 560px;
margin: 0 auto;
height: 100dvh;
display: flex;
flex-direction: column;
}
.chat-screen__head {
padding: 16px 16px 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-switch {
display: flex;
padding: 4px;
gap: 4px;
border-radius: 999px;
background: var(--bc-surface);
}
.chat-switch button {
flex: 1;
height: 36px;
border: 0;
border-radius: 999px;
background: none;
font: inherit;
font-weight: 600;
font-size: 14px;
color: var(--bc-ink-2);
}
.chat-switch button[aria-pressed='true'] {
background: var(--bc-plum);
color: #f6e9dc;
}
.chat-upsell {
align-self: flex-start;
padding: 6px 12px;
border-radius: 999px;
background: var(--bc-pink);
color: var(--bc-plum);
font-weight: 600;
text-decoration: none;
}
.chat-list {
flex: 1;
overflow-y: auto;
padding: 8px 16px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-hint {
text-align: center;
margin: 4px 12px 8px;
}
.chat-day {
align-self: center;
margin: 10px 0 4px;
padding: 3px 12px;
border-radius: 999px;
background: var(--bc-surface);
font-size: 12px;
font-weight: 600;
color: var(--bc-ink-3);
}
.chat-msg {
align-self: flex-start;
max-width: 82%;
padding: 9px 13px 7px;
border-radius: 20px 20px 20px 6px;
background: var(--bc-surface);
line-height: 1.4;
overflow-wrap: anywhere;
}
.chat-msg--mine {
align-self: flex-end;
border-radius: 20px 20px 6px 20px;
background: var(--bc-plum);
color: #f6e9dc;
}
.chat-msg__who {
font-size: 12px;
font-weight: 700;
color: var(--bc-accent);
margin-bottom: 2px;
}
.chat-msg__text {
white-space: pre-wrap;
}
.chat-msg__time {
font-size: 11px;
opacity: 0.6;
text-align: right;
margin-top: 2px;
}
.chat-msg__img {
display: block;
max-width: 100%;
max-height: 260px;
border-radius: 14px;
margin: 2px -5px 6px;
}
.chat-msg__file {
display: block;
font-weight: 600;
color: inherit;
margin-bottom: 4px;
}
.chat-compose {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 8px 12px calc(10px + env(safe-area-inset-bottom));
background: var(--bc-bg);
}
.chat-compose__input {
flex: 1;
min-height: 44px;
max-height: 140px;
padding: 11px 16px;
border-radius: 22px;
border: 1.5px solid var(--bc-line);
background: var(--bc-surface);
font: inherit;
font-size: 16px;
resize: none;
field-sizing: content;
}
.chat-compose__input:focus {
outline: none;
border-color: var(--bc-accent);
}
.chat-compose__btn,
.chat-compose__send {
width: 44px;
height: 44px;
flex: none;
border-radius: 50%;
border: 0;
display: grid;
place-items: center;
background: var(--bc-surface);
color: var(--bc-ink);
}
.chat-compose__send {
background: var(--bc-accent);
color: #fff;
}
.chat-compose__send:disabled {
opacity: 0.4;
}
+162
View File
@@ -0,0 +1,162 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Center, Icon, Spinner } from '@barev/ui';
import { errorText, getInitData, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { PageHead } from '../lib.tsx';
const MAX_FILE = 10 * 1024 * 1024;
/**
* Чат с командой Barev Club: та же переписка, что в чате с ботом.
* Подписчик пишет личному консьержу, остальные — в поддержку; ответ приходит и сюда, и в Telegram.
*/
export function Chat() {
const { t, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const chat = useQuery({ ...trpc.client.chat.get.queryOptions(), refetchInterval: 5_000 });
const [text, setText] = useState('');
const [uploading, setUploading] = useState(false);
const list = useRef<HTMLDivElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const refresh = () => qc.invalidateQueries({ queryKey: trpc.client.chat.get.queryKey() });
const send = useMutation(
trpc.client.chat.send.mutationOptions({
onMutate: () => haptic.tap(),
onSuccess: () => {
setText('');
void refresh();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const setChannel = useMutation(trpc.client.chat.setChannel.mutationOptions({ onSuccess: () => void refresh(), onError: (e) => toast(errorText(t, e), 'error') }));
const lastId = chat.data?.messages.at(-1)?.id;
useLayoutEffect(() => {
list.current?.scrollTo({ top: list.current.scrollHeight });
}, [lastId]);
const upload = async (file: File) => {
if (file.size > MAX_FILE) return toast(t('client.chat.fileTooBig'), 'error');
setUploading(true);
try {
const form = new FormData();
form.set('text', text);
form.set('file', file);
const res = await fetch('/files/client/chat', { method: 'POST', body: form, headers: { authorization: `tma ${getInitData()}` } });
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? String(res.status));
setText('');
void refresh();
} catch (e) {
toast(errorText(t, e), 'error');
} finally {
setUploading(false);
}
};
if (!chat.data)
return (
<Center>
<Spinner />
</Center>
);
const d = chat.data;
let prevDay = '';
return (
<main className="chat-screen">
<div className="chat-screen__head">
<PageHead title={t('client.chat.title')} />
{d.canConcierge ? (
<div className="chat-switch" role="group">
{(['CONCIERGE', 'SUPPORT'] as const).map((c) => (
<button key={c} type="button" aria-pressed={d.channel === c} disabled={setChannel.isPending} onClick={() => d.channel !== c && setChannel.mutate({ channel: c })}>
{t(c === 'CONCIERGE' ? 'client.chat.concierge' : 'client.chat.support')}
</button>
))}
</div>
) : (
<a href="#/membership" className="chat-upsell bc-small">
{t('client.chat.conciergeOnly')} →
</a>
)}
</div>
<div className="chat-list" ref={list}>
<p className="chat-hint bc-small bc-muted">{t('client.chat.hint')}</p>
{d.messages.length === 0 && <p className="chat-hint">{t('client.chat.empty')}</p>}
{d.messages.map((m) => {
const day = dt(m.createdAt, { day: 'numeric', month: 'long' });
const sep = day !== prevDay ? <div className="chat-day">{day}</div> : null;
prevDay = day;
return (
<Fragment key={m.id}>
{sep}
<div className={`chat-msg ${m.mine ? 'chat-msg--mine' : ''}`}>
{!m.mine && <div className="chat-msg__who">{t(m.channel === 'CONCIERGE' ? 'client.chat.concierge' : 'client.chat.support')}</div>}
{m.file && <ChatFile id={m.id} file={m.file} />}
{m.text && <div className="chat-msg__text">{m.text}</div>}
<div className="chat-msg__time">{dt(m.createdAt, { hour: '2-digit', minute: '2-digit' })}</div>
</div>
</Fragment>
);
})}
</div>
<form
className="chat-compose"
onSubmit={(e) => {
e.preventDefault();
if (text.trim() && !send.isPending) send.mutate({ text });
}}
>
<button type="button" className="chat-compose__btn" onClick={() => fileInput.current?.click()} disabled={uploading} aria-label={t('client.chat.attach')}>
{uploading ? <Spinner /> : <Icon name="camera" size={22} />}
</button>
<input
ref={fileInput}
type="file"
hidden
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void upload(f);
}}
/>
<textarea className="chat-compose__input" rows={1} value={text} maxLength={4000} placeholder={t('client.chat.placeholder')} onChange={(e) => setText(e.target.value)} aria-label={t('client.chat.placeholder')} />
<button type="submit" className="chat-compose__send" disabled={!text.trim() || send.isPending} aria-label={t('client.chat.send')}>
<Icon name="arrow" size={22} strokeWidth={2.4} />
</button>
</form>
</main>
);
}
/** Файл из переписки: картинку показываем, остальное — ссылкой. Доступ по initData, поэтому через fetch */
function ChatFile({ id, file }: { id: string; file: { name: string | null; mime: string | null } }) {
const { t } = useI18n();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let revoke: string | null = null;
fetch(`/files/client/chat-file/${id}`, { headers: { authorization: `tma ${getInitData()}` } })
.then((r) => (r.ok ? r.blob() : null))
.then((b) => {
if (!b) return;
revoke = URL.createObjectURL(b);
setUrl(revoke);
})
.catch(() => {});
return () => {
if (revoke) URL.revokeObjectURL(revoke);
};
}, [id]);
if (file.mime?.startsWith('image/') && url) return <img className="chat-msg__img" src={url} alt={file.name ?? ''} />;
return (
<a className="chat-msg__file" href={url ?? undefined} download={file.name ?? undefined} target="_blank" rel="noreferrer">
📎 {file.name ?? t('client.chat.file')}
</a>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Card, Center, Icon, Spinner } from '@barev/ui';
import { errorText, haptic, openTelegram, useI18n, useToast, useTRPC } from '@barev/web';
import { errorText, haptic, navigate, useI18n, useToast, useTRPC } from '@barev/web';
import { itemHint, itemName, PageHead } from '../lib.tsx';
/** Чек-лист документов, которые лучше собрать до переезда; отметки сохраняются в заявке на переезд */
@@ -60,7 +60,7 @@ export function Docs() {
</ul>
</Card>
{me.data && (
<button className="bc-btn bc-btn--light bc-btn--block" onClick={() => openTelegram(me.data.conciergeUrl)}>
<button className="bc-btn bc-btn--light bc-btn--block" onClick={() => navigate('/chat')}>
<Icon name="chat" size={18} />
{t('client.docs.ask')}
</button>
+2 -2
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Card, Center, Icon, Spinner } from '@barev/ui';
import { openTelegram, useI18n, useTRPC } from '@barev/web';
import { navigate, useI18n, useTRPC } from '@barev/web';
import { itemName, LevelBadge, type Level } from '../lib.tsx';
import { MoveForm } from './MoveForm.tsx';
@@ -86,7 +86,7 @@ export function Move() {
<b>{t('client.docs.title')}</b>
<span className="bc-small">{t('client.docs.progress', { done: docsDone, total: lead.docs.length })}</span>
</a>
<button className="move-action bc-tone-learn" onClick={() => openTelegram(me.data.conciergeUrl)}>
<button className="move-action bc-tone-learn" onClick={() => navigate('/chat')}>
<Icon name="chat" size={24} />
<b>{t('client.concierge.title')}</b>
<span className="bc-small">{t('client.concierge.short')}</span>
+3 -1
View File
@@ -2,12 +2,14 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
// API_PROXY — другой локальный API (например, второй экземпляр на соседнем порту)
const api = process.env.API_PROXY ?? 'http://localhost:4000';
export default defineConfig({
plugins: [react()],
server: {
host: true,
allowedHosts: true,
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
proxy: { '/trpc': api, '/files': api, '/client-log': api },
},
build: { target: 'es2022', sourcemap: true },
});