- 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>
152 lines
5.8 KiB
TypeScript
152 lines
5.8 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { Center, Icon, Spinner } from '@barev/ui';
|
|
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';
|
|
import { Move } from './screens/Move.tsx';
|
|
import { Segment } from './screens/Segment.tsx';
|
|
import { ServiceRequest } from './screens/ServiceRequest.tsx';
|
|
import { Home } from './screens/Home.tsx';
|
|
import { Membership } from './screens/Membership.tsx';
|
|
import { OrderDetail } from './screens/OrderDetail.tsx';
|
|
import { OrderForm } from './screens/OrderForm.tsx';
|
|
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'];
|
|
|
|
export function App({ hasInitData }: { hasInitData: boolean }) {
|
|
const trpc = useTRPC();
|
|
const me = useQuery({ ...trpc.client.me.queryOptions(), enabled: hasInitData });
|
|
const locale = pickLocale(me.data?.locale, telegramLanguage(), navigator.language);
|
|
|
|
return (
|
|
<I18nProvider initial={locale}>
|
|
{!hasInitData || me.error?.data?.httpStatus === 401 ? (
|
|
<OpenInTelegram />
|
|
) : me.isPending ? (
|
|
<Center>
|
|
<Spinner />
|
|
</Center>
|
|
) : me.error ? (
|
|
<LoadError />
|
|
) : !me.data.consented ? (
|
|
<Consent />
|
|
) : !me.data.segment ? (
|
|
<Segment />
|
|
) : (
|
|
<Routes segment={me.data.segment} />
|
|
)}
|
|
</I18nProvider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Два сценария: MOVING — план переезда, документы, консьерж; LIVING — услуги со скидками, партнёры, заказы.
|
|
* Остальные экраны (услуги, подписка, заказы) доступны из обоих.
|
|
*/
|
|
function Routes({ segment }: { segment: 'MOVING' | 'LIVING' }) {
|
|
const { path } = useLocation();
|
|
useTelegramBack(path, ROOTS);
|
|
let screen: ReactNode;
|
|
let m: Record<string, string> | null;
|
|
if (path === '/') screen = segment === 'MOVING' ? <Move /> : <Home />;
|
|
else if (path === '/docs') screen = <Docs />;
|
|
else if ((m = match('/request/:code', path))) screen = <ServiceRequest code={m.code!} />;
|
|
else if (path === '/services') screen = <Services />;
|
|
else if ((m = match('/order/:code', path))) screen = <OrderForm code={m.code!} />;
|
|
else if (path === '/orders') screen = <Orders />;
|
|
else if ((m = match('/orders/:id', path))) screen = <OrderDetail id={m.id!} />;
|
|
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} /> : <BottomNav path={path} />)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Меню переезда: План / Документы / Консьерж (чат с ботом) / Услуги / Профиль */
|
|
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}>
|
|
<Icon name={icon} size={22} />
|
|
{label}
|
|
</a>
|
|
);
|
|
return (
|
|
<nav className="bc-bottomnav" aria-label={t('client.nav.label')}>
|
|
{item('/', 'globe', t('client.nav.plan'))}
|
|
{item('/docs', 'docs', t('client.nav.docs'))}
|
|
<a href="#/chat" className="bc-bottomnav__plus" aria-label={t('client.concierge.title')}>
|
|
<Icon name="chat" size={24} strokeWidth={2.4} />
|
|
</a>
|
|
{item('/services', 'grid', t('client.nav.services'))}
|
|
{item('/profile', 'user', t('client.nav.profile'))}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
/** ORD-1: Главная / Услуги / + / Скидки / Профиль */
|
|
function BottomNav({ 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}>
|
|
<Icon name={icon} size={22} />
|
|
{label}
|
|
</a>
|
|
);
|
|
return (
|
|
<nav className="bc-bottomnav" aria-label={t('client.nav.label')}>
|
|
{item('/', 'home', t('client.nav.home'))}
|
|
{item('/services', 'grid', t('client.nav.services'))}
|
|
<a href="#/order/cleaning" className="bc-bottomnav__plus" aria-label={t('client.nav.newOrder')}>
|
|
<Icon name="plus" size={26} strokeWidth={2.6} />
|
|
</a>
|
|
{item('/perks', 'gift', t('client.nav.perks'))}
|
|
{item('/profile', 'user', t('client.nav.profile'))}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function OpenInTelegram() {
|
|
const { t } = useI18n();
|
|
return (
|
|
<div className="app app--plain">
|
|
<Center>
|
|
<div className="bc-stack" style={{ textAlign: 'center', alignItems: 'center' }}>
|
|
<Icon name="logo" size={48} style={{ color: 'var(--bc-plum)' }} />
|
|
<h1 className="bc-h2">{t('client.openInTelegram.title')}</h1>
|
|
<p className="bc-muted">{t('client.openInTelegram.text')}</p>
|
|
</div>
|
|
</Center>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LoadError() {
|
|
const { t } = useI18n();
|
|
return (
|
|
<div className="app app--plain">
|
|
<Center>
|
|
<div className="bc-stack" style={{ textAlign: 'center', alignItems: 'center' }}>
|
|
<p>{t('errors.generic')}</p>
|
|
<button className="bc-btn bc-btn--dark" onClick={() => window.location.reload()}>
|
|
{t('common.retry')}
|
|
</button>
|
|
</div>
|
|
</Center>
|
|
</div>
|
|
);
|
|
}
|