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:
1 parent
d452824c62
commit
23ffb2bf1d
46 files changed
+4176
-144
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user