Files
BarevClub/apps/miniapp-client/src/App.tsx
T
zabudko-ruandClaude Opus 5.5 23ffb2bf1d 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>
2026-10-21 01:50:37 +04:00

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>
);
}