Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -0,0 +1,11 @@
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/dist
|
||||
**/.turbo
|
||||
**/*.tsbuildinfo
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
docs/design
|
||||
i18n-review
|
||||
infra/prod/.env*
|
||||
@@ -47,3 +47,8 @@ SENTRY_DSN=
|
||||
# В production не используется.
|
||||
DEV_CLIENT_BOT_TOKEN=000000:dev-client-token
|
||||
DEV_PRO_BOT_TOKEN=000000:dev-pro-token
|
||||
|
||||
# Страница /ru/apartments: квартиры AREV из CRM (crm.yandp.tech) и заявки на просмотр.
|
||||
# Ключ — companies.settings.barevClub.key организации AREV в CRM. Пусто — страница показывает «список недоступен».
|
||||
CRM_URL=https://crm.yandp.tech
|
||||
CRM_BAREV_KEY=
|
||||
@@ -9,3 +9,9 @@ coverage
|
||||
!.env.example
|
||||
.DS_Store
|
||||
packages/db/src/generated
|
||||
|
||||
# личные документы в корне (выписки, паспорта) — не коммитим
|
||||
/*.pdf
|
||||
|
||||
# рабочие копии веток Claude Code
|
||||
.claude/worktrees
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Barev Club · Admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,700;0,800;1,400&family=Onest:wght@400;500;600&family=Noto+Sans+Armenian:wght@400;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@barev/admin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173 --strictPort",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@barev/core": "workspace:*",
|
||||
"@barev/i18n": "workspace:*",
|
||||
"@barev/ui": "workspace:*",
|
||||
"@barev/web": "workspace:*",
|
||||
"@tanstack/react-query": "^5.104.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@barev/api": "workspace:*",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"@types/qrcode": "^1.5.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { LOCALE_LABELS, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Button, Center, Chip, Icon, Logo, Spinner, type IconName } from '@barev/ui';
|
||||
import { I18nProvider, pickLocale, useI18n, useLocation, useTRPC } from '@barev/web';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Login } from './pages/Login.tsx';
|
||||
import { Kanban } from './pages/Kanban.tsx';
|
||||
import { Providers } from './pages/Providers.tsx';
|
||||
import { Presets } from './pages/Presets.tsx';
|
||||
import { Companies } from './pages/Companies.tsx';
|
||||
import { Services } from './pages/Services.tsx';
|
||||
import { Agencies } from './pages/Agencies.tsx';
|
||||
import { Clients } from './pages/Clients.tsx';
|
||||
import { Settings } from './pages/Settings.tsx';
|
||||
import { WebRequests } from './pages/WebRequests.tsx';
|
||||
import { Leads } from './pages/Leads.tsx';
|
||||
import { Requests } from './pages/Requests.tsx';
|
||||
import { Concierge } from './pages/Concierge.tsx';
|
||||
import { Partners } from './pages/Partners.tsx';
|
||||
import { Templates } from './pages/Templates.tsx';
|
||||
import { Telegram } from './pages/Telegram.tsx';
|
||||
|
||||
const LOCALE_KEY = 'barev.admin.locale';
|
||||
function savedLocale(): Locale {
|
||||
try {
|
||||
return pickLocale(localStorage.getItem(LOCALE_KEY), navigator.language);
|
||||
} catch {
|
||||
return 'ru';
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [locale] = useState(savedLocale);
|
||||
return (
|
||||
<I18nProvider initial={locale}>
|
||||
<Gate />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Gate() {
|
||||
const trpc = useTRPC();
|
||||
const me = useQuery({ ...trpc.admin.auth.me.queryOptions(), retry: false });
|
||||
if (me.isPending)
|
||||
return (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
);
|
||||
if (!me.data || !me.data.verified) return <Login stage={me.data ? (me.data.totpEnabled ? 'totp' : 'totp_setup') : 'password'} />;
|
||||
return <Shell role={me.data.role} name={me.data.name} />;
|
||||
}
|
||||
|
||||
const NAV: { path: string; key: string; icon: IconName; owner?: boolean }[] = [
|
||||
{ path: '/', key: 'admin.nav.orders', icon: 'grid' },
|
||||
{ path: '/leads', key: 'admin.nav.leads', icon: 'globe' },
|
||||
{ path: '/templates', key: 'admin.nav.templates', icon: 'docs' },
|
||||
{ path: '/concierge', key: 'admin.nav.concierge', icon: 'chat' },
|
||||
{ path: '/app-requests', key: 'admin.nav.requests', icon: 'star' },
|
||||
{ path: '/requests', key: 'admin.nav.webRequests', icon: 'chat' },
|
||||
{ path: '/clients', key: 'admin.nav.clients', icon: 'user' },
|
||||
{ path: '/providers', key: 'admin.nav.providers', icon: 'star' },
|
||||
{ path: '/presets', key: 'admin.nav.presets', icon: 'repeat' },
|
||||
{ path: '/companies', key: 'admin.nav.companies', icon: 'home' },
|
||||
{ path: '/services', key: 'admin.nav.services', icon: 'docs' },
|
||||
{ path: '/agencies', key: 'admin.nav.agencies', icon: 'gift' },
|
||||
{ path: '/partners', key: 'admin.nav.partners', icon: 'gift' },
|
||||
{ path: '/telegram', key: 'admin.nav.telegram', icon: 'chat' },
|
||||
{ path: '/settings', key: 'admin.nav.settings', icon: 'clock', owner: true },
|
||||
];
|
||||
|
||||
function Shell({ role, name }: { role: 'OWNER' | 'OPERATOR'; name: string }) {
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const { path } = useLocation();
|
||||
const logout = useMutation(trpc.admin.auth.logout.mutationOptions({ onSuccess: () => qc.invalidateQueries() }));
|
||||
const isOwner = role === 'OWNER';
|
||||
// новые заявки с сайта — счётчик в меню, обновляется сам
|
||||
const openRequests = useQuery({ ...trpc.admin.webRequests.openCount.queryOptions(), refetchInterval: 30_000 });
|
||||
const openLeads = useQuery({ ...trpc.admin.leads.openCount.queryOptions(), refetchInterval: 30_000 });
|
||||
const openAppRequests = useQuery({ ...trpc.admin.requests.openCount.queryOptions(), refetchInterval: 30_000 });
|
||||
const unreadChats = useQuery({ ...trpc.admin.concierge.unreadCount.queryOptions(), refetchInterval: 15_000 });
|
||||
const counts: Record<string, number | undefined> = { '/requests': openRequests.data, '/leads': openLeads.data, '/app-requests': openAppRequests.data, '/concierge': unreadChats.data };
|
||||
|
||||
let page: ReactNode;
|
||||
if (path === '/leads') page = <Leads />;
|
||||
else if (path === '/concierge') page = <Concierge />;
|
||||
else if (path === '/app-requests') page = <Requests />;
|
||||
else if (path === '/partners') page = <Partners />;
|
||||
else if (path === '/templates') page = <Templates />;
|
||||
else if (path === '/telegram') page = <Telegram />;
|
||||
else if (path === '/requests') page = <WebRequests />;
|
||||
else if (path === '/clients') page = <Clients isOwner={isOwner} />;
|
||||
else if (path === '/providers') page = <Providers />;
|
||||
else if (path === '/presets') page = <Presets isOwner={isOwner} />;
|
||||
else if (path === '/companies') page = <Companies />;
|
||||
else if (path === '/services') page = <Services isOwner={isOwner} />;
|
||||
else if (path === '/agencies') page = <Agencies isOwner={isOwner} />;
|
||||
else if (path === '/settings' && isOwner) page = <Settings />;
|
||||
else page = <Kanban />;
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<aside className="sidebar">
|
||||
<div style={{ padding: '4px 10px 16px' }}>
|
||||
<Logo size={22} />
|
||||
</div>
|
||||
{NAV.filter((n) => !n.owner || isOwner).map((n) => (
|
||||
<a key={n.path} href={`#${n.path}`} aria-current={path === n.path ? 'page' : undefined}>
|
||||
<Icon name={n.icon} size={18} />
|
||||
{t(n.key)}
|
||||
{!!counts[n.path] && <span className="nav-count">{counts[n.path]}</span>}
|
||||
</a>
|
||||
))}
|
||||
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 8, padding: '0 6px' }}>
|
||||
<div className="bc-chips">
|
||||
{LOCALES.map((l) => (
|
||||
<Chip
|
||||
key={l}
|
||||
pressed={l === locale}
|
||||
onClick={() => {
|
||||
setLocale(l);
|
||||
try {
|
||||
localStorage.setItem(LOCALE_KEY, l);
|
||||
} catch {
|
||||
/* приватный режим */
|
||||
}
|
||||
}}
|
||||
style={{ minHeight: 32, padding: '4px 10px', fontSize: 12 }}
|
||||
>
|
||||
{LOCALE_LABELS[l]}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<div className="bc-small bc-muted">
|
||||
{name} · {t(`admin.roles.${role}`)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => logout.mutate()}>
|
||||
{t('admin.logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">{page}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 232px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
padding: 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: var(--bc-surface);
|
||||
border-right: 1px solid var(--bc-line);
|
||||
}
|
||||
.sidebar a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar a[aria-current='page'] {
|
||||
background: var(--bc-dark);
|
||||
color: #fff;
|
||||
}
|
||||
/* счётчик новых заявок с сайта */
|
||||
.nav-count {
|
||||
margin-left: auto;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--bc-accent);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.main {
|
||||
padding: 24px 28px 48px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.toolbar .bc-input {
|
||||
width: auto;
|
||||
min-height: 44px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.summary .bc-card {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.summary__value {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 28px;
|
||||
}
|
||||
.kanban {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols, 4), minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.column {
|
||||
background: #f3ece1;
|
||||
border-radius: 24px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 200px;
|
||||
}
|
||||
.column__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 6px 6px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.ocard {
|
||||
text-align: left;
|
||||
border: 2px solid transparent;
|
||||
background: var(--bc-surface);
|
||||
border-radius: 18px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
.ocard:hover {
|
||||
border-color: var(--bc-ink);
|
||||
}
|
||||
.ocard__stripe {
|
||||
height: 6px;
|
||||
width: 36px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(30, 27, 26, 0.35);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.drawer {
|
||||
width: min(680px, 100%);
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: var(--bc-bg);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
table.grid {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background: var(--bc-surface);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
table.grid th,
|
||||
table.grid td {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--bc-line);
|
||||
vertical-align: top;
|
||||
}
|
||||
table.grid th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-ink-2);
|
||||
background: #f8f3eb;
|
||||
}
|
||||
table.grid tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
table.grid tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
table.grid tr.clickable:hover td {
|
||||
background: #fcfaf6;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.form-grid .full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.schedule {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 1fr 60px;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.schedule .bc-input {
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.candidate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: var(--bc-surface);
|
||||
}
|
||||
.history-item {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dashed var(--bc-line);
|
||||
}
|
||||
.auth-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.auth-card {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.main {
|
||||
padding: 16px;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* шаги плана переезда в карточке заявки */
|
||||
.lead-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.lead-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
background: var(--bc-surface);
|
||||
}
|
||||
|
||||
/* чат консьержа */
|
||||
.chat {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-height: 70vh;
|
||||
}
|
||||
.chat__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.chat__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: 16px;
|
||||
background: var(--bc-surface);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat__item[aria-current='true'] {
|
||||
border-color: var(--bc-plum);
|
||||
}
|
||||
.chat__preview {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chat__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px;
|
||||
background: var(--bc-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat__head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--bc-line);
|
||||
}
|
||||
.chat__messages {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
background: var(--bc-bg);
|
||||
}
|
||||
.msg {
|
||||
max-width: 70%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 18px 18px 18px 6px;
|
||||
background: var(--bc-surface);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.msg span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.msg[data-author='OPERATOR'] {
|
||||
align-self: flex-end;
|
||||
border-radius: 18px 18px 6px 18px;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
}
|
||||
.chat__form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--bc-line);
|
||||
}
|
||||
.chat__form textarea {
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
}
|
||||
.lead-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.lead-docs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 4px 12px;
|
||||
}
|
||||
.lead-doc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
}
|
||||
.lead-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
cursor: text;
|
||||
}
|
||||
.lead-x {
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: var(--bc-ink-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.lead-x:hover {
|
||||
color: var(--bc-danger);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge, Button, Card, Field, Icon, Spinner } from '@barev/ui';
|
||||
import { confirmDialog, errorText, paramsExtras, paramsSummary, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** ADM-3, ADM-4: карточка заказа и назначение исполнителя */
|
||||
export function OrderDrawer({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const { t, amd, dt, locale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const order = useQuery({ ...trpc.admin.orders.get.queryOptions({ id }), refetchInterval: 10_000 });
|
||||
const methods = useQuery(trpc.admin.dictionaries.paymentMethods.queryOptions());
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const done = (msg: string) => () => {
|
||||
toast(msg);
|
||||
void qc.invalidateQueries();
|
||||
};
|
||||
const fail = (e: unknown) => toast(errorText(t, e), 'error');
|
||||
const transition = useMutation(trpc.admin.orders.transition.mutationOptions({ onSuccess: done(t('admin.order.statusChanged')), onError: fail }));
|
||||
const cancel = useMutation(trpc.admin.orders.cancel.mutationOptions({ onSuccess: done(t('admin.order.cancelled')), onError: fail }));
|
||||
const unassign = useMutation(trpc.admin.orders.unassign.mutationOptions({ onSuccess: done(t('admin.order.unassigned')), onError: fail }));
|
||||
const markPaid = useMutation(trpc.admin.orders.markPaid.mutationOptions({ onSuccess: done(t('admin.order.paid')), onError: fail }));
|
||||
|
||||
const o = order.data;
|
||||
const pendingOffer = o?.offers.find((f) => f.status === 'OFFERED');
|
||||
const canAssign = o && !o.providerId && !pendingOffer && o.status === o.flowSteps[0];
|
||||
const isFinal = o && !o.canCancel;
|
||||
|
||||
return (
|
||||
<div className="drawer-backdrop" onClick={onClose}>
|
||||
<aside className="drawer" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
{!o ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h2">
|
||||
#{o.number} · {t(`services.${o.service.code}.name`)}
|
||||
</h2>
|
||||
<button className="bc-btn bc-btn--light bc-btn--sm" onClick={onClose} aria-label={t('common.close')}>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="bc-row" style={{ flexWrap: 'wrap' }}>
|
||||
<Badge style={{ background: 'var(--bc-dark)', color: '#fff' }}>{t(`status.${o.service.flow}.${o.status}`)}</Badge>
|
||||
<Badge>{t(`payment.${o.paymentStatus}`)}</Badge>
|
||||
{pendingOffer && (
|
||||
<Badge style={{ background: 'var(--bc-perks)' }}>
|
||||
{pendingOffer.company && !pendingOffer.provider
|
||||
? t('admin.kanban.toCompany', { name: pendingOffer.company.name })
|
||||
: t('admin.kanban.awaiting', { name: pendingOffer.provider?.name ?? '' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="bc-stack">
|
||||
<dl className="kv" style={{ margin: 0, display: 'grid', gridTemplateColumns: '160px 1fr', gap: '6px 12px' }}>
|
||||
<dt className="bc-muted">{t('admin.order.when')}</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} · {o.durationMinutes} {t('admin.order.min')}
|
||||
</dd>
|
||||
<dt className="bc-muted">{t('admin.order.params')}</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
{paramsSummary(t, o.service.code, o.params as Record<string, unknown>)}
|
||||
{paramsExtras(t, o.params as Record<string, unknown>).map((e) => (
|
||||
<div key={e.code} className="bc-small">
|
||||
+ {e.label}
|
||||
</div>
|
||||
))}
|
||||
</dd>
|
||||
<dt className="bc-muted">{t('admin.order.address')}</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
{o.isOnline ? t('common.online') : `${o.district ? t(`districts.${o.district.code}`) + ', ' : ''}${o.addressText ?? ''}`}
|
||||
</dd>
|
||||
{o.note && (
|
||||
<>
|
||||
<dt className="bc-muted">{t('admin.order.note')}</dt>
|
||||
<dd style={{ margin: 0 }}>{o.note}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="bc-muted">{t('admin.order.client')}</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
{o.client.user.firstName} {o.client.user.lastName ?? ''} {o.client.user.username ? `@${o.client.user.username}` : ''} · {o.locale.toUpperCase()}
|
||||
{o.client.agency ? ` · ${t('admin.order.agency')}: ${o.client.agency.name}` : ''}
|
||||
</dd>
|
||||
<dt className="bc-muted">{t('admin.order.provider')}</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
{o.provider?.name ?? '—'}
|
||||
{o.company ? ` · ${o.company.name}` : ''}
|
||||
</dd>
|
||||
<dt className="bc-muted">{t('admin.order.price')}</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
{amd(o.basePrice)}
|
||||
{o.discountAmount > 0 ? ` − ${amd(o.discountAmount)} (Barev+ ${o.discountPct}%)` : ''} = <b>{amd(o.totalPrice)}</b>
|
||||
{o.cancelFee > 0 ? ` · ${t('admin.order.cancelFee')}: ${amd(o.cancelFee)}` : ''}
|
||||
</dd>
|
||||
<dt className="bc-muted">{t('admin.order.payment')}</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
{o.paymentMethod ? (o.paymentMethod.name as Record<string, string>)[locale] || (o.paymentMethod.name as Record<string, string>).ru : '—'} · {t(`payment.${o.paymentStatus}`)}
|
||||
{o.paidAt ? ` · ${dt(o.paidAt)}` : ''}
|
||||
</dd>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{canAssign && <Candidates orderId={o.id} />}
|
||||
|
||||
<Card className="bc-stack">
|
||||
<h3 className="bc-h3">{t('admin.order.actions')}</h3>
|
||||
<div className="bc-row" style={{ flexWrap: 'wrap' }}>
|
||||
{o.adminTransitions.map((s) => (
|
||||
<Button key={s} size="sm" variant="light" disabled={transition.isPending} onClick={() => transition.mutate({ orderId: o.id, to: s, comment: comment || undefined })}>
|
||||
→ {t(`status.${o.service.flow}.${s}`)}
|
||||
</Button>
|
||||
))}
|
||||
{(o.providerId || pendingOffer) && !isFinal && (
|
||||
<Button size="sm" variant="outline" disabled={unassign.isPending} onClick={async () => (await confirmDialog(t('admin.order.confirmUnassign'))) && unassign.mutate({ orderId: o.id })}>
|
||||
{t('admin.order.unassign')}
|
||||
</Button>
|
||||
)}
|
||||
{o.paymentStatus === 'UNPAID' && o.status !== 'CANCELLED' && (
|
||||
<Button size="sm" variant="dark" disabled={markPaid.isPending} onClick={async () => (await confirmDialog(t('admin.order.confirmPaid', { amount: amd(o.totalPrice) }))) && markPaid.mutate({ orderId: o.id, paymentMethodId: o.paymentMethodId ?? methods.data?.[0]?.id })}>
|
||||
{t('admin.order.markPaid')}
|
||||
</Button>
|
||||
)}
|
||||
{o.canCancel && (
|
||||
<Button size="sm" variant="danger" disabled={cancel.isPending} onClick={async () => (await confirmDialog(t('admin.order.confirmCancel'))) && cancel.mutate({ orderId: o.id, reason: comment || undefined })}>
|
||||
{t('admin.order.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Field label={t('admin.order.comment')}>
|
||||
<input className="bc-input" value={comment} onChange={(e) => setComment(e.target.value)} maxLength={500} />
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
{o.checklist.length > 0 && (
|
||||
<Card className="bc-stack">
|
||||
<h3 className="bc-h3">{t('admin.order.checklist')}</h3>
|
||||
{o.checklist.map((c) => (
|
||||
<div key={c.id} className="bc-row">
|
||||
<Icon name={c.checkedAt ? 'check' : 'close'} size={16} style={{ color: c.checkedAt ? 'var(--bc-ok)' : 'var(--bc-ink-3)' }} />
|
||||
<span>{t(`checklist.${c.code}`)}</span>
|
||||
{c.fileId && <FileLink fileId={c.fileId} />}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<h3 className="bc-h3" style={{ marginBottom: 8 }}>
|
||||
{t('admin.order.history')}
|
||||
</h3>
|
||||
{o.history.map((h) => (
|
||||
<div key={h.id} className="history-item">
|
||||
<span className="bc-muted">{dt(h.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>
|
||||
{h.event ? t(`admin.events.${h.event}`) : `${h.fromStatus ? t(`status.${o.service.flow}.${h.fromStatus}`) + ' → ' : ''}${t(`status.${o.service.flow}.${h.toStatus}`)}`}
|
||||
<span className="bc-muted"> · {t(`admin.actors.${h.actorType}`)}</span>
|
||||
{h.comment ? <div className="bc-small">{h.comment}</div> : null}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{o.offers.length > 0 && (
|
||||
<>
|
||||
<h4 style={{ margin: '12px 0 4px' }}>{t('admin.order.offers')}</h4>
|
||||
{o.offers.map((f) => (
|
||||
<div key={f.id} className="history-item">
|
||||
<span className="bc-muted">{dt(f.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>
|
||||
{f.provider?.name ?? f.company?.name} · {t(`admin.offerStatus.${f.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Candidates({ orderId }: { orderId: string }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const cands = useQuery(trpc.admin.orders.candidates.queryOptions({ id: orderId }));
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const assign = useMutation(
|
||||
trpc.admin.orders.assign.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.order.offered'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
if (!cands.data) return <Spinner />;
|
||||
const [best, ...rest] = cands.data.ranked;
|
||||
return (
|
||||
<Card tone="perks" className="bc-stack">
|
||||
<h3 className="bc-h3">{t('admin.order.assignTitle')}</h3>
|
||||
{best ? (
|
||||
<Button block disabled={assign.isPending} onClick={() => assign.mutate({ orderId, target: { kind: best.kind, id: best.id } })}>
|
||||
{t('admin.order.assignTo', { name: best.name })}
|
||||
{best.kind === 'company' ? ` (${t('admin.order.company')})` : ''}
|
||||
</Button>
|
||||
) : (
|
||||
<p style={{ margin: 0 }}>{t('admin.order.noCandidates')}</p>
|
||||
)}
|
||||
{best && <Reasons reasons={best.reasons} />}
|
||||
{rest.length > 0 && (
|
||||
<button className="bc-btn bc-btn--ghost bc-btn--sm" onClick={() => setShowAll(!showAll)}>
|
||||
{t(showAll ? 'admin.order.hideOthers' : 'admin.order.chooseManually', { count: rest.length })}
|
||||
</button>
|
||||
)}
|
||||
{showAll &&
|
||||
rest.map((c) => (
|
||||
<div key={`${c.kind}-${c.id}`} className="candidate">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{c.name}
|
||||
{c.kind === 'company' ? ` · ${t('admin.order.company')}` : ''}
|
||||
</div>
|
||||
<div className="bc-small bc-muted">
|
||||
{c.languages.map((l) => l.toUpperCase()).join(' ')} · {t('admin.order.load', { count: c.load })}
|
||||
{c.rating ? ` · ★ ${c.rating}` : ''}
|
||||
</div>
|
||||
<Reasons reasons={c.reasons} />
|
||||
</div>
|
||||
<Button size="sm" variant="light" disabled={assign.isPending} onClick={() => assign.mutate({ orderId, target: { kind: c.kind, id: c.id } })}>
|
||||
{t('admin.order.assign')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{cands.data.busy.length > 0 && (
|
||||
<div className="bc-small">
|
||||
{t('admin.order.busy')}: {cands.data.busy.map((b) => b.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Reasons({ reasons }: { reasons: string[] }) {
|
||||
const { t } = useI18n();
|
||||
if (!reasons.length) return null;
|
||||
return (
|
||||
<div className="bc-row" style={{ flexWrap: 'wrap', gap: 4 }}>
|
||||
{reasons.map((r) => (
|
||||
<Badge key={r} style={{ fontSize: 11 }}>
|
||||
{t(`admin.reasons.${r}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileLink({ fileId }: { fileId: string }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const url = useQuery({ ...trpc.admin.orders.fileUrl.queryOptions({ fileId }), enabled });
|
||||
useEffect(() => {
|
||||
if (url.data) window.open(url.data.url, '_blank', 'noopener');
|
||||
}, [url.data]);
|
||||
return (
|
||||
<button className="bc-btn bc-btn--ghost bc-btn--sm" onClick={() => setEnabled(true)}>
|
||||
<Icon name="camera" size={16} /> {t('admin.order.photo')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { LOCALES } from '@barev/i18n';
|
||||
import { Chip } from '@barev/ui';
|
||||
import { useI18n } from '@barev/web';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
/** Мультивыбор чипами (языки, районы, роли) */
|
||||
export function MultiChips<T extends string>({ value, options, onChange, label }: { value: T[]; options: { value: T; label: ReactNode }[]; onChange: (v: T[]) => void; label?: string }) {
|
||||
return (
|
||||
<div className="bc-chips" role="group" aria-label={label}>
|
||||
{options.map((o) => {
|
||||
const on = value.includes(o.value);
|
||||
return (
|
||||
<Chip key={o.value} pressed={on} onClick={() => onChange(on ? value.filter((v) => v !== o.value) : [...value, o.value])} style={{ minHeight: 36, padding: '4px 12px' }}>
|
||||
{o.label}
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const LOCALE_OPTIONS = LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }));
|
||||
|
||||
export type ScheduleRow = { weekday: number; startMinute: number; endMinute: number };
|
||||
|
||||
const toHm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
||||
const fromHm = (s: string) => {
|
||||
const [h, m] = s.split(':').map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
};
|
||||
|
||||
/** Недельный график исполнителя */
|
||||
export function ScheduleEditor({ value, onChange }: { value: ScheduleRow[]; onChange: (v: ScheduleRow[]) => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="schedule">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map((wd) => {
|
||||
const row = value.find((r) => r.weekday === wd);
|
||||
const update = (patch: Partial<ScheduleRow>) => onChange(value.map((r) => (r.weekday === wd ? { ...r, ...patch } : r)));
|
||||
return (
|
||||
<div key={wd} style={{ display: 'contents' }}>
|
||||
<span style={{ fontWeight: 600 }}>{t(`admin.weekdays.${wd}`)}</span>
|
||||
<input className="bc-input" type="time" value={row ? toHm(row.startMinute) : ''} disabled={!row} onChange={(e) => update({ startMinute: fromHm(e.target.value) })} aria-label={t('admin.schedule.from')} />
|
||||
<input className="bc-input" type="time" value={row ? toHm(row.endMinute) : ''} disabled={!row} onChange={(e) => update({ endMinute: fromHm(e.target.value) })} aria-label={t('admin.schedule.to')} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!row}
|
||||
aria-label={t('admin.schedule.works')}
|
||||
onChange={(e) => onChange(e.target.checked ? [...value, { weekday: wd, startMinute: 540, endMinute: 1200 }] : value.filter((r) => r.weekday !== wd))}
|
||||
style={{ width: 22, height: 22 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Локальная копия значения, сбрасывается при смене исходного */
|
||||
export function useDraft<T>(source: T | undefined) {
|
||||
const [draft, setDraft] = useState<T | undefined>(source);
|
||||
useEffect(() => setDraft(source), [source]);
|
||||
return [draft, setDraft] as const;
|
||||
}
|
||||
|
||||
export function Copyable({ text }: { text: string }) {
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<span className="bc-row" style={{ gap: 6 }}>
|
||||
<code className="mono" style={{ wordBreak: 'break-all' }}>
|
||||
{text}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="bc-btn bc-btn--ghost bc-btn--sm"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}}
|
||||
>
|
||||
{copied ? t('admin.copied') : t('admin.copy')}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import './admin.css';
|
||||
import { ApiProvider, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApiProvider withCredentials>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ApiProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Field } from '@barev/ui';
|
||||
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { Copyable } from '../components/form.tsx';
|
||||
|
||||
interface Draft {
|
||||
id?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
commissionPct: number;
|
||||
contact: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** ADM-5: агентства — код для deep-link/QR и % вознаграждения. Полный отчёт ADM-8 — Этап 2. */
|
||||
export function Agencies({ isOwner }: { isOwner: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const list = useQuery(trpc.admin.agencies.list.queryOptions());
|
||||
const config = useQuery(trpc.admin.dictionaries.config.queryOptions());
|
||||
const settings = useQuery(trpc.admin.settings.get.queryOptions());
|
||||
const [d, setD] = useState<Draft | null>(null);
|
||||
const onDone = () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
setD(null);
|
||||
};
|
||||
const create = useMutation(trpc.admin.agencies.create.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
|
||||
const update = useMutation(trpc.admin.agencies.update.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.nav.agencies')}</h1>
|
||||
{isOwner && (
|
||||
<Button size="sm" onClick={() => setD({ code: '', name: '', commissionPct: settings.data?.['agency.defaultCommissionPct'] ?? 10, contact: '', active: true })}>
|
||||
{t('admin.agencies.add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
||||
{t('admin.agencies.hint', { months: settings.data?.['agency.attributionMonths'] ?? 12 })}
|
||||
</p>
|
||||
{d && (
|
||||
<Card className="bc-stack">
|
||||
<div className="form-grid">
|
||||
<Field label={t('admin.agencies.code')}>
|
||||
<input className="bc-input mono" value={d.code} onChange={(e) => setD({ ...d, code: e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '') })} maxLength={32} />
|
||||
</Field>
|
||||
<Field label={t('admin.agencies.name')}>
|
||||
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('admin.agencies.commission')}>
|
||||
<input className="bc-input" type="number" min={0} max={100} value={d.commissionPct} onChange={(e) => setD({ ...d, commissionPct: Number(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label={t('admin.agencies.contact')}>
|
||||
<input className="bc-input" value={d.contact} onChange={(e) => setD({ ...d, contact: e.target.value })} />
|
||||
</Field>
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="bc-row">
|
||||
<Button
|
||||
disabled={d.code.length < 2 || !d.name}
|
||||
onClick={() => {
|
||||
const payload = { ...d, contact: d.contact || null };
|
||||
if (d.id) update.mutate({ ...payload, id: d.id });
|
||||
else create.mutate(payload);
|
||||
}}
|
||||
>
|
||||
{t('admin.save')}
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setD(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.agencies.name')}</th>
|
||||
<th>{t('admin.agencies.code')}</th>
|
||||
<th>{t('admin.agencies.commission')}</th>
|
||||
<th>{t('admin.agencies.leads')}</th>
|
||||
<th>{t('admin.agencies.links')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data?.map((a) => (
|
||||
<tr key={a.id} className={isOwner ? 'clickable' : undefined} style={{ opacity: a.active ? 1 : 0.5 }} onClick={() => isOwner && setD({ id: a.id, code: a.code, name: a.name, commissionPct: a.commissionPct, contact: a.contact ?? '', active: a.active })}>
|
||||
<td>
|
||||
<b>{a.name}</b>
|
||||
{a.contact && <div className="bc-small bc-muted">{a.contact}</div>}
|
||||
</td>
|
||||
<td className="mono">{a.code}</td>
|
||||
<td>{a.commissionPct}%</td>
|
||||
<td>{a.leads}</td>
|
||||
<td onClick={(e) => e.stopPropagation()} className="bc-stack" style={{ gap: 4 }}>
|
||||
{config.data && (
|
||||
<>
|
||||
<span className="bc-small">{t('admin.agencies.bot')}</span>
|
||||
<Copyable text={`https://t.me/${config.data.clientBot}?start=a-${a.code}`} />
|
||||
<span className="bc-small">{t('admin.agencies.landing')}</span>
|
||||
<Copyable text={`${config.data.landingUrl}/?a=${a.code}`} />
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge, Button } from '@barev/ui';
|
||||
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
|
||||
/** ADM-6 (базово): клиенты, источник, статус Barev+ */
|
||||
export function Clients({ isOwner }: { isOwner: boolean }) {
|
||||
const { t, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const list = useQuery(trpc.admin.clients.list.queryOptions({ search: search.trim() || undefined }));
|
||||
const grant = useMutation(
|
||||
trpc.admin.clients.grantBarevPlus.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.nav.clients')}</h1>
|
||||
<input className="bc-input" type="search" placeholder={t('admin.kanban.search')} value={search} onChange={(e) => setSearch(e.target.value)} style={{ width: 280 }} />
|
||||
</div>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.clients.name')}</th>
|
||||
<th>{t('admin.clients.language')}</th>
|
||||
<th>{t('admin.clients.source')}</th>
|
||||
<th>{t('admin.clients.membership')}</th>
|
||||
<th>{t('admin.clients.orders')}</th>
|
||||
<th>{t('admin.clients.since')}</th>
|
||||
{isOwner && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data?.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<b>
|
||||
{c.user.firstName} {c.user.lastName ?? ''}
|
||||
</b>
|
||||
{c.user.username && <div className="bc-small bc-muted">@{c.user.username}</div>}
|
||||
<a href={`#/concierge?client=${c.id}`} className="bc-small" style={{ fontWeight: 600 }}>
|
||||
{t('admin.concierge.write')}
|
||||
</a>
|
||||
{!c.consented && <Badge style={{ background: 'var(--bc-docs)' }}>{t('admin.clients.noConsent')}</Badge>}
|
||||
</td>
|
||||
<td>{c.user.locale.toUpperCase()}</td>
|
||||
<td>
|
||||
{c.agency ? `${c.agency.name} (${c.agency.code})` : t(`admin.clients.source_${c.source}`)}
|
||||
{c.utm && <div className="bc-small bc-muted mono">{Object.entries(c.utm as Record<string, string>).map(([k, v]) => `${k.replace('utm_', '')}=${v}`).join(' ')}</div>}
|
||||
</td>
|
||||
<td>
|
||||
{c.barevPlusUntil ? (
|
||||
<Badge style={{ background: c.plan === 'ELITE' ? 'var(--bc-perks)' : 'var(--bc-pink)' }}>
|
||||
{c.plan === 'ELITE' ? 'Elite' : 'Barev+'} · {dt(c.barevPlusUntil, { day: 'numeric', month: 'short' })}
|
||||
</Badge>
|
||||
) : (
|
||||
'Barev'
|
||||
)}
|
||||
{c.segment && <div className="bc-small bc-muted">{t(`admin.clients.segment_${c.segment}`)}</div>}
|
||||
</td>
|
||||
<td>{c.orders}</td>
|
||||
<td className="bc-small">{dt(c.createdAt, { dateStyle: 'medium' })}</td>
|
||||
{isOwner && (
|
||||
<td>
|
||||
<div className="bc-chips">
|
||||
<Button size="sm" variant="light" onClick={async () => (await confirmDialog(t('admin.clients.grantConfirm'))) && grant.mutate({ clientId: c.id, days: 30, plan: 'BAREV_PLUS' })}>
|
||||
{t('admin.clients.grant')}
|
||||
</Button>
|
||||
<Button size="sm" variant="light" onClick={async () => (await confirmDialog(t('admin.clients.grantEliteConfirm'))) && grant.mutate({ clientId: c.id, days: 30, plan: 'ELITE' })}>
|
||||
{t('admin.clients.grantElite')}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { PROVIDER_ROLES, type ProviderRole } from '@barev/core';
|
||||
import { Button, Card, Field } from '@barev/ui';
|
||||
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { Copyable, MultiChips } from '../components/form.tsx';
|
||||
|
||||
interface Draft {
|
||||
id?: string;
|
||||
name: string;
|
||||
roles: ProviderRole[];
|
||||
rateType: 'FIXED' | 'PERCENT';
|
||||
rateValue: number;
|
||||
contactPhone: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** PRO-7, PRO-8: компании-партнёры, их диспетчеры и договорная ставка */
|
||||
export function Companies() {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const list = useQuery(trpc.admin.companies.list.queryOptions());
|
||||
const config = useQuery(trpc.admin.dictionaries.config.queryOptions());
|
||||
const [d, setD] = useState<Draft | null>(null);
|
||||
const onDone = () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
setD(null);
|
||||
};
|
||||
const create = useMutation(trpc.admin.companies.create.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
|
||||
const update = useMutation(trpc.admin.companies.update.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
|
||||
const removeDispatcher = useMutation(trpc.admin.companies.removeDispatcher.mutationOptions({ onSuccess: () => qc.invalidateQueries() }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.nav.companies')}</h1>
|
||||
<Button size="sm" onClick={() => setD({ name: '', roles: ['CLEANER'], rateType: 'PERCENT', rateValue: 65, contactPhone: '', active: true })}>
|
||||
{t('admin.companies.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{d && (
|
||||
<Card className="bc-stack">
|
||||
<div className="form-grid">
|
||||
<Field label={t('admin.companies.name')}>
|
||||
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('admin.companies.phone')}>
|
||||
<input className="bc-input" value={d.contactPhone} onChange={(e) => setD({ ...d, contactPhone: e.target.value })} />
|
||||
</Field>
|
||||
<div className="full">
|
||||
<Field label={t('admin.companies.roles')}>
|
||||
<MultiChips value={d.roles} options={PROVIDER_ROLES.map((r) => ({ value: r, label: t(`roles.${r}`) }))} onChange={(roles) => setD({ ...d, roles })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('admin.providers.rateType')}>
|
||||
<select className="bc-input" value={d.rateType} onChange={(e) => setD({ ...d, rateType: e.target.value as Draft['rateType'] })}>
|
||||
<option value="PERCENT">{t('admin.rate.PERCENT')}</option>
|
||||
<option value="FIXED">{t('admin.rate.FIXED')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={d.rateType === 'PERCENT' ? t('admin.providers.ratePercent') : t('admin.providers.rateFixed')}>
|
||||
<input className="bc-input" type="number" min={0} value={d.rateValue} onChange={(e) => setD({ ...d, rateValue: Number(e.target.value) })} />
|
||||
</Field>
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="bc-row">
|
||||
<Button
|
||||
disabled={!d.name || !d.roles.length}
|
||||
onClick={() => {
|
||||
const payload = { ...d, contactPhone: d.contactPhone || null };
|
||||
if (d.id) update.mutate({ ...payload, id: d.id });
|
||||
else create.mutate(payload);
|
||||
}}
|
||||
>
|
||||
{t('admin.save')}
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setD(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{list.data?.map((c) => (
|
||||
<Card key={c.id} className="bc-stack" style={{ opacity: c.active ? 1 : 0.6 }}>
|
||||
<div className="bc-between">
|
||||
<div>
|
||||
<h2 className="bc-h3">{c.name}</h2>
|
||||
<div className="bc-small bc-muted">
|
||||
{c.roles.map((r) => t(`roles.${r}`)).join(', ')} · {c.rateType === 'PERCENT' ? `${c.rateValue}%` : `${c.rateValue} ֏`}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="light" onClick={() => setD({ id: c.id, name: c.name, roles: c.roles, rateType: c.rateType, rateValue: c.rateValue, contactPhone: c.contactPhone ?? '', active: c.active })}>
|
||||
{t('admin.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bc-small">
|
||||
<b>{t('admin.companies.employees')}:</b> {c.employees.map((e) => e.name).join(', ') || '—'}
|
||||
</div>
|
||||
<div className="bc-small">
|
||||
<b>{t('admin.companies.dispatchers')}:</b>{' '}
|
||||
{c.dispatchers.length
|
||||
? c.dispatchers.map((x) => (
|
||||
<span key={x.id} className="bc-row" style={{ display: 'inline-flex', marginRight: 10 }}>
|
||||
{x.user.firstName ?? x.user.username}
|
||||
<button className="bc-btn bc-btn--ghost bc-btn--sm" onClick={() => removeDispatcher.mutate({ id: x.id })}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
: '—'}
|
||||
</div>
|
||||
{config.data && (
|
||||
<div className="bc-small">
|
||||
<b>{t('admin.companies.dispatcherInvite')}:</b> <Copyable text={`https://t.me/${config.data.proBot}?start=${c.inviteCode}`} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Chip } from '@barev/ui';
|
||||
import { errorText, navigate, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Peer = { clientId: string } | { providerId: string };
|
||||
const keyOf = (p: Peer) => ('clientId' in p ? `c:${p.clientId}` : `p:${p.providerId}`);
|
||||
|
||||
/**
|
||||
* Переписка: клиенты пишут клиентскому боту, исполнители — рабочему; оператор отвечает здесь,
|
||||
* ответ приходит в тот же чат с ботом. Открыть диалог с кем угодно: #/concierge?client=… или ?provider=…
|
||||
*/
|
||||
export function Concierge() {
|
||||
const { t, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const { query } = useLocation();
|
||||
const list = useQuery({ ...trpc.admin.concierge.list.queryOptions(), refetchInterval: 10_000 });
|
||||
const [tab, setTab] = useState<'all' | 'clients' | 'providers'>('all');
|
||||
const fromUrl: Peer | null = query.get('client') ? { clientId: query.get('client')! } : query.get('provider') ? { providerId: query.get('provider')! } : null;
|
||||
const items = (list.data ?? []).filter((c) => (tab === 'all' ? true : tab === 'clients' ? !!c.clientId : !!c.providerId));
|
||||
const first = items[0];
|
||||
const selected: Peer | null = fromUrl ?? (first ? (first.clientId ? { clientId: first.clientId } : { providerId: first.providerId! }) : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.nav.concierge')}</h1>
|
||||
<div className="bc-chips">
|
||||
{(['all', 'clients', 'providers'] as const).map((x) => (
|
||||
<Chip key={x} pressed={tab === x} onClick={() => setTab(x)}>
|
||||
{t(`admin.concierge.tab_${x}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!selected ? (
|
||||
<p className="bc-muted">{t('admin.concierge.empty')}</p>
|
||||
) : (
|
||||
<div className="chat">
|
||||
<ul className="chat__list">
|
||||
{items.map((c) => (
|
||||
<li key={c.key}>
|
||||
<button className="chat__item" aria-current={c.key === keyOf(selected)} onClick={() => navigate(`/concierge?${c.clientId ? `client=${c.clientId}` : `provider=${c.providerId}`}`)}>
|
||||
<span className="bc-between">
|
||||
<b>{c.name}</b>
|
||||
{c.unread > 0 && <span className="nav-count">{c.unread}</span>}
|
||||
</span>
|
||||
<span className="bc-small bc-muted chat__preview">
|
||||
{c.last.author === 'OPERATOR' ? `${t('admin.concierge.you')}: ` : ''}
|
||||
{c.last.text}
|
||||
</span>
|
||||
<span className="bc-small bc-muted">
|
||||
{dt(c.last.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
{' · '}
|
||||
{c.providerRole ? `${t('admin.concierge.provider')} · ${t(`roles.${c.providerRole}`)}` : c.segment ? t(`admin.clients.segment_${c.segment}`) : t('admin.concierge.client')}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Conversation key={keyOf(selected)} peer={selected} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Conversation({ peer }: { peer: Peer }) {
|
||||
const { t, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [text, setText] = useState('');
|
||||
const end = useRef<HTMLDivElement>(null);
|
||||
const data = useQuery({ ...trpc.admin.concierge.get.queryOptions(peer), refetchInterval: 10_000 });
|
||||
const reply = useMutation(
|
||||
trpc.admin.concierge.reply.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setText('');
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const send = () => text.trim() && reply.mutate({ ...peer, text: text.trim() });
|
||||
useEffect(() => {
|
||||
end.current?.scrollIntoView({ block: 'end' });
|
||||
}, [data.data?.messages.length]);
|
||||
// открыли диалог — счётчики непрочитанных обновятся
|
||||
useEffect(() => {
|
||||
if (data.data) void qc.invalidateQueries({ queryKey: trpc.admin.concierge.list.queryKey() });
|
||||
}, [data.data, qc, trpc]);
|
||||
const p = data.data?.peer;
|
||||
|
||||
return (
|
||||
<section className="chat__body">
|
||||
<div className="chat__head">
|
||||
<b>{p?.name}</b>
|
||||
{p?.username && (
|
||||
<a href={`https://t.me/${p.username}`} target="_blank" rel="noopener" className="bc-small">
|
||||
@{p.username}
|
||||
</a>
|
||||
)}
|
||||
{p && <span className="bc-small bc-muted">{t('admin.concierge.replyLang', { lang: p.locale.toUpperCase() })}</span>}
|
||||
</div>
|
||||
<div className="chat__messages">
|
||||
{data.data?.messages.length === 0 && <p className="bc-muted bc-small">{t('admin.concierge.startHint')}</p>}
|
||||
{data.data?.messages.map((m) => (
|
||||
<div key={m.id} className="msg" data-author={m.author === 'OPERATOR' ? 'OPERATOR' : 'PEER'}>
|
||||
<div>{m.text}</div>
|
||||
<span className="bc-small">{dt(m.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={end} />
|
||||
</div>
|
||||
{p && !p.reachable ? (
|
||||
<p className="bc-small bc-muted" style={{ padding: 12, margin: 0 }}>
|
||||
{t('errors.PROVIDER_NOT_LINKED')}
|
||||
</p>
|
||||
) : (
|
||||
<form
|
||||
className="chat__form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
className="bc-input"
|
||||
value={text}
|
||||
maxLength={4000}
|
||||
placeholder={t('admin.concierge.placeholder')}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) send();
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" disabled={reply.isPending || !text.trim()}>
|
||||
{t('admin.concierge.send')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CATALOG, CATEGORY_CODES } from '@barev/core';
|
||||
import { Badge, Card, Chip } from '@barev/ui';
|
||||
import { useI18n, useLocation, navigate, useTRPC } from '@barev/web';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { OrderDrawer } from '../components/OrderDrawer.tsx';
|
||||
|
||||
const COLUMNS = ['new', 'assigned', 'in_progress', 'done'] as const;
|
||||
const CAT_COLOR: Record<string, string> = { HOME: 'var(--bc-home)', DOCS: 'var(--bc-docs)', LEARNING: 'var(--bc-learn)', PERKS: 'var(--bc-perks)' };
|
||||
|
||||
/** ADM-1, ADM-2: сводка и канбан заказов с фильтрами */
|
||||
export function Kanban() {
|
||||
const { t, amd, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const { query } = useLocation();
|
||||
const openId = query.get('order');
|
||||
const [category, setCategory] = useState('');
|
||||
const [serviceCode, setServiceCode] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showCancelled, setShowCancelled] = useState(false);
|
||||
|
||||
const filter = { category: category || undefined, serviceCode: serviceCode || undefined, date: date || undefined, search: search.trim() || undefined, includeCancelled: showCancelled };
|
||||
const board = useQuery({ ...trpc.admin.orders.kanban.queryOptions(filter), refetchInterval: 10_000 });
|
||||
const summary = useQuery({ ...trpc.admin.orders.summary.queryOptions(), refetchInterval: 15_000 });
|
||||
|
||||
// ORD-5: новый заказ — звуковой/визуальный сигнал оператору
|
||||
const known = useRef<Set<string> | null>(null);
|
||||
const [fresh, setFresh] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!board.data) return;
|
||||
const ids = new Set(board.data.new.map((o) => o.id));
|
||||
if (known.current) {
|
||||
const added = [...ids].filter((id) => !known.current!.has(id));
|
||||
if (added.length) {
|
||||
setFresh(new Set(added));
|
||||
document.title = `● ${t('admin.kanban.newOrderTitle')}`;
|
||||
setTimeout(() => (document.title = 'Barev Club · Admin'), 8000);
|
||||
}
|
||||
}
|
||||
known.current = ids;
|
||||
}, [board.data, t]);
|
||||
|
||||
const cols = showCancelled ? [...COLUMNS, 'cancelled' as const] : COLUMNS;
|
||||
const services = CATALOG.filter((s) => !category || s.category === category);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.kanban.title')}</h1>
|
||||
</div>
|
||||
|
||||
<div className="summary">
|
||||
<Card tone="pink">
|
||||
<div className="bc-small">{t('admin.summary.ordersToday')}</div>
|
||||
<div className="summary__value">{summary.data?.ordersToday ?? '—'}</div>
|
||||
</Card>
|
||||
<Card tone={summary.data?.unassigned ? 'perks' : 'white'}>
|
||||
<div className="bc-small">{t('admin.summary.unassigned')}</div>
|
||||
<div className="summary__value">{summary.data?.unassigned ?? '—'}</div>
|
||||
</Card>
|
||||
<Card tone="home">
|
||||
<div className="bc-small">{t('admin.summary.revenueToday')}</div>
|
||||
<div className="summary__value">{summary.data ? amd(summary.data.revenueToday) : '—'}</div>
|
||||
</Card>
|
||||
<Card tone="blue">
|
||||
<div className="bc-small">{t('admin.summary.freeProviders')}</div>
|
||||
<div className="summary__value">
|
||||
{summary.data ? `${summary.data.freeProviders} / ${summary.data.providersOnShift}` : '—'}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<select className="bc-input" value={category} onChange={(e) => (setCategory(e.target.value), setServiceCode(''))} aria-label={t('admin.kanban.category')}>
|
||||
<option value="">{t('admin.kanban.allCategories')}</option>
|
||||
{CATEGORY_CODES.filter((c) => c !== 'PERKS').map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`categories.${c}.name`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="bc-input" value={serviceCode} onChange={(e) => setServiceCode(e.target.value)} aria-label={t('admin.kanban.service')}>
|
||||
<option value="">{t('admin.kanban.allServices')}</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.code} value={s.code}>
|
||||
{t(`services.${s.code}.name`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input className="bc-input" type="date" value={date} onChange={(e) => setDate(e.target.value)} aria-label={t('admin.kanban.date')} />
|
||||
<input className="bc-input" type="search" placeholder={t('admin.kanban.search')} value={search} onChange={(e) => setSearch(e.target.value)} style={{ minWidth: 240 }} />
|
||||
<Chip pressed={showCancelled} onClick={() => setShowCancelled(!showCancelled)}>
|
||||
{t('admin.kanban.showCancelled')}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
<div className="kanban" style={{ ['--cols' as string]: cols.length }}>
|
||||
{cols.map((col) => {
|
||||
const items = board.data?.[col] ?? [];
|
||||
return (
|
||||
<section key={col} className="column" aria-label={t(`admin.kanban.col.${col}`)}>
|
||||
<div className="column__head">
|
||||
<span>{t(`admin.kanban.col.${col}`)}</span>
|
||||
<Badge>{items.length}</Badge>
|
||||
</div>
|
||||
{items.map((o) => (
|
||||
<button key={o.id} className="ocard" onClick={() => navigate(`/?order=${o.id}`)} style={fresh.has(o.id) ? { borderColor: 'var(--bc-accent)' } : undefined}>
|
||||
<div className="bc-between">
|
||||
<span className="ocard__stripe" style={{ background: CAT_COLOR[o.category] }} />
|
||||
<span className="bc-small bc-muted">#{o.number}</span>
|
||||
</div>
|
||||
<div style={{ fontWeight: 700 }}>{t(`services.${o.serviceCode}.name`)}</div>
|
||||
<div className="bc-small">{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</div>
|
||||
<div className="bc-small bc-muted">
|
||||
{o.clientName} · {o.clientLocale.toUpperCase()}
|
||||
{o.districtCode ? ` · ${t(`districts.${o.districtCode}`)}` : ''}
|
||||
</div>
|
||||
<div className="bc-between">
|
||||
<span className="bc-small" style={{ fontWeight: 600 }}>
|
||||
{amd(o.totalPrice)}
|
||||
</span>
|
||||
<span className="bc-small">{t(`payment.${o.paymentStatus}`)}</span>
|
||||
</div>
|
||||
{o.pending ? (
|
||||
<Badge style={{ background: o.pending.kind === 'company' ? 'var(--bc-docs)' : 'var(--bc-perks)' }}>
|
||||
{o.pending.kind === 'company' ? t('admin.kanban.toCompany', { name: o.pending.name }) : t('admin.kanban.awaiting', { name: o.pending.name })}
|
||||
</Badge>
|
||||
) : o.providerName ? (
|
||||
<Badge style={{ background: 'var(--bc-bg)' }}>
|
||||
{o.providerName}
|
||||
{o.companyName ? ` · ${o.companyName}` : ''}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="bc-small bc-muted">{t(`status.${flowOf(o.serviceCode)}.${o.status}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{openId && <OrderDrawer id={openId} onClose={() => navigate('/')} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const flowOf = (serviceCode: string) => CATALOG.find((s) => s.code === serviceCode)?.flow ?? 'CLEANING';
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { formatAmd } from '@barev/i18n';
|
||||
import { Badge, Button, Chip } from '@barev/ui';
|
||||
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
const STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
|
||||
type Status = (typeof STATUSES)[number];
|
||||
const STATUS_TONE: Record<Status, string> = {
|
||||
NEW: 'var(--bc-perks)',
|
||||
IN_WORK: 'var(--bc-pink)',
|
||||
DONE: 'var(--bc-home)',
|
||||
CANCELLED: 'var(--bc-line)',
|
||||
};
|
||||
const YES_NO = ['flat', 'socialCard', 'bankCard', 'registration', 'residence'] as const;
|
||||
|
||||
interface Options {
|
||||
airport?: 'none' | 'standard' | 'premium';
|
||||
stay?: 'none' | 'flat' | 'hotel';
|
||||
stayBudgetFrom?: number;
|
||||
stayBudgetTo?: number;
|
||||
flat?: boolean;
|
||||
socialCard?: boolean;
|
||||
bankCard?: boolean;
|
||||
registration?: boolean;
|
||||
residence?: boolean;
|
||||
}
|
||||
|
||||
/** Заявки из квиза на главной (barevclub.com): появляются после первого шага, дальше дописываются ответы */
|
||||
export function Leads() {
|
||||
const { t, dt, locale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [status, setStatus] = useState<Status | undefined>();
|
||||
const list = useQuery({ ...trpc.admin.leads.list.queryOptions({ status }), refetchInterval: 15_000 });
|
||||
const update = useMutation(
|
||||
trpc.admin.leads.update.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Что человек отметил в конструкторе — одной строкой */
|
||||
function describe(o: Options) {
|
||||
const parts: string[] = [];
|
||||
if (o.airport && o.airport !== 'none') parts.push(`${t('landing.quiz.airport')}: ${t(`landing.quiz.airport_${o.airport}`)}`);
|
||||
if (o.stay && o.stay !== 'none') {
|
||||
const budget = o.stayBudgetFrom || o.stayBudgetTo ? ` (${t('admin.leads.budget', { from: o.stayBudgetFrom ? formatAmd(locale, o.stayBudgetFrom) : '…', to: o.stayBudgetTo ? formatAmd(locale, o.stayBudgetTo) : '…' })})` : '';
|
||||
parts.push(`${t('landing.quiz.stay')}: ${t(`landing.quiz.stay_${o.stay}`)}${budget}`);
|
||||
}
|
||||
for (const k of YES_NO) if (o[k]) parts.push(t(`landing.quiz.${k}`));
|
||||
return parts;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.leads.title')}</h1>
|
||||
<div className="bc-chips">
|
||||
<Chip pressed={!status} onClick={() => setStatus(undefined)}>
|
||||
{t('admin.leads.all')}
|
||||
</Chip>
|
||||
{STATUSES.map((s) => (
|
||||
<Chip key={s} pressed={status === s} onClick={() => setStatus(s)}>
|
||||
{t(`admin.leads.status_${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{list.data?.length === 0 ? (
|
||||
<p className="bc-muted">{t('admin.leads.empty')}</p>
|
||||
) : (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.leads.number')}</th>
|
||||
<th>{t('admin.leads.contact')}</th>
|
||||
<th>{t('admin.leads.plan')}</th>
|
||||
<th>{t('admin.leads.package')}</th>
|
||||
<th>{t('admin.leads.status')}</th>
|
||||
<th>{t('admin.leads.note')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data?.map((r) => {
|
||||
const href = r.contactType === 'TELEGRAM' ? `https://t.me/${r.contact.slice(1)}` : `tel:${r.contact}`;
|
||||
const opts = r.package === 'CUSTOM' && r.options ? describe(r.options as Options) : [];
|
||||
return (
|
||||
<Fragment key={r.id}>
|
||||
<tr>
|
||||
<td>
|
||||
<b>{r.number}</b>
|
||||
<div className="bc-small bc-muted">{t('admin.leads.created', { date: dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) })}</div>
|
||||
</td>
|
||||
<td>
|
||||
<b>{r.name}</b>
|
||||
<div>
|
||||
<a href={href} target="_blank" rel="noopener">
|
||||
{r.contact}
|
||||
</a>
|
||||
</div>
|
||||
<div className="bc-small bc-muted">
|
||||
{r.locale.toUpperCase()}
|
||||
{r.agency ? ` · ${r.agency}` : ''}
|
||||
{r.client ? ` · ${t('admin.leads.inApp')}` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td className="bc-small">
|
||||
{r.moved ? (
|
||||
<b>{t('admin.leads.moved')}</b>
|
||||
) : r.moveWhen === 'date' && r.moveDate ? (
|
||||
dt(r.moveDate, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
|
||||
) : r.moveWhen ? (
|
||||
t(`landing.quiz.when_${r.moveWhen}`)
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td className="bc-small">
|
||||
{r.package ? <b>{t(`landing.quiz.package_${r.package}`)}</b> : '—'}
|
||||
{opts.length > 0 && (
|
||||
<ul style={{ margin: '4px 0 0', paddingLeft: 16 }}>
|
||||
{opts.map((o) => (
|
||||
<li key={o}>{o}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{!r.submittedAt && <div className="bc-muted">{t('admin.leads.unfinished')}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<Badge style={{ background: STATUS_TONE[r.status] }}>{t(`admin.leads.status_${r.status}`)}</Badge>
|
||||
<div className="bc-chips" style={{ marginTop: 8 }}>
|
||||
{STATUSES.filter((s) => s !== r.status).map((s) => (
|
||||
<Button key={s} size="sm" variant="light" onClick={() => update.mutate({ id: r.id, status: s })}>
|
||||
{t(`admin.leads.status_${s}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ minWidth: 200 }}>
|
||||
<NoteEditor initial={r.adminNote ?? ''} onSave={(adminNote) => update.mutate({ id: r.id, adminNote })} />
|
||||
</td>
|
||||
</tr>
|
||||
{r.package && !r.moved && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ background: 'var(--bc-bg)' }}>
|
||||
<LeadPlan lead={r} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteEditor({ initial, onSave }: { initial: string; onSave: (v: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<div className="bc-stack" style={{ gap: 6 }}>
|
||||
<textarea className="bc-input" style={{ minHeight: 60 }} value={value} maxLength={1000} placeholder={t('admin.webRequests.notePlaceholder')} onChange={(e) => setValue(e.target.value)} />
|
||||
{value !== initial && (
|
||||
<Button size="sm" variant="outline" onClick={() => onSave(value)}>
|
||||
{t('admin.webRequests.saveNote')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEP_STATUSES = ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED'] as const;
|
||||
type Names = Record<string, { name: Record<string, string>; hint: Record<string, string> | null }>;
|
||||
const nameOf = (names: Names | undefined, kind: 'STEP' | 'DOC', it: { code: string; title: string | null }, locale: string) => {
|
||||
const n = names?.[`${kind}:${it.code}`]?.name;
|
||||
return it.title || n?.[locale] || n?.ru || it.code;
|
||||
};
|
||||
|
||||
function useSaved() {
|
||||
const { t } = useI18n();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
return {
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: unknown) => toast(errorText(t, e), 'error'),
|
||||
};
|
||||
}
|
||||
|
||||
/** План клиента: шаги и документы — копия общего списка, которую можно менять только для этого клиента */
|
||||
function LeadPlan({ lead }: { lead: { id: string; client: unknown; steps: { id: string; code: string; title: string | null; status: (typeof STEP_STATUSES)[number]; note: string | null }[]; docs: { id: string; code: string; title: string | null; checked: boolean }[] } }) {
|
||||
const { t, locale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const saved = useSaved();
|
||||
const names = useQuery(trpc.admin.leads.names.queryOptions());
|
||||
const templates = useQuery(trpc.admin.leads.templates.queryOptions());
|
||||
const addStep = useMutation(trpc.admin.leads.addStep.mutationOptions(saved));
|
||||
const addDoc = useMutation(trpc.admin.leads.addDoc.mutationOptions(saved));
|
||||
const updateDoc = useMutation(trpc.admin.leads.updateDoc.mutationOptions(saved));
|
||||
const removeDoc = useMutation(trpc.admin.leads.removeDoc.mutationOptions(saved));
|
||||
const free = (kind: 'STEP' | 'DOC', taken: { code: string }[]) => (templates.data ?? []).filter((x) => x.kind === kind && x.active && !taken.some((y) => y.code === x.code));
|
||||
|
||||
return (
|
||||
<div className="lead-plan">
|
||||
<div className="bc-small bc-muted">{lead.client ? t('admin.leads.stepsInApp') : t('admin.leads.stepsNoApp')}</div>
|
||||
<div className="lead-steps">
|
||||
{lead.steps.map((s) => (
|
||||
<StepEditor key={s.id} step={s} name={nameOf(names.data, 'STEP', s, locale)} />
|
||||
))}
|
||||
</div>
|
||||
<AddItem label={t('admin.leads.addStep')} options={free('STEP', lead.steps).map((x) => ({ code: x.code, name: (x.name as Record<string, string>)[locale] || (x.name as Record<string, string>).ru! }))} onAdd={(v) => addStep.mutate({ leadId: lead.id, ...v })} />
|
||||
|
||||
<div className="bc-small bc-muted" style={{ marginTop: 10 }}>
|
||||
{t('admin.leads.docsTitle', { done: lead.docs.filter((d) => d.checked).length, total: lead.docs.length })}
|
||||
</div>
|
||||
<div className="lead-docs">
|
||||
{lead.docs.map((d) => (
|
||||
<div key={d.id} className="lead-doc">
|
||||
<input type="checkbox" checked={d.checked} onChange={(e) => updateDoc.mutate({ docId: d.id, checked: e.target.checked })} aria-label={nameOf(names.data, 'DOC', d, locale)} />
|
||||
<InlineTitle value={nameOf(names.data, 'DOC', d, locale)} onSave={(title) => updateDoc.mutate({ docId: d.id, title })} />
|
||||
<button className="lead-x" title={t('admin.leads.remove')} onClick={() => removeDoc.mutate({ docId: d.id })}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AddItem label={t('admin.leads.addDoc')} options={free('DOC', lead.docs).map((x) => ({ code: x.code, name: (x.name as Record<string, string>)[locale] || (x.name as Record<string, string>).ru! }))} onAdd={(v) => addDoc.mutate({ leadId: lead.id, ...v })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Добавить пункт: из общего списка или свой текст */
|
||||
function AddItem({ label, options, onAdd }: { label: string; options: { code: string; name: string }[]; onAdd: (v: { code?: string; title?: string }) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
return (
|
||||
<div className="bc-row" style={{ flexWrap: 'wrap', gap: 6 }}>
|
||||
<select className="bc-input" style={{ minHeight: 36, width: 'auto' }} value={value} onChange={(e) => setValue(e.target.value)}>
|
||||
<option value="">{label}…</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.code} value={o.code}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom">{t('admin.leads.custom')}</option>
|
||||
</select>
|
||||
{value === '__custom' && <input className="bc-input" style={{ minHeight: 36, width: 260 }} value={title} maxLength={120} placeholder={t('admin.leads.customPlaceholder')} onChange={(e) => setTitle(e.target.value)} />}
|
||||
{value && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={value === '__custom' && !title.trim()}
|
||||
onClick={() => {
|
||||
onAdd(value === '__custom' ? { title: title.trim() } : { code: value });
|
||||
setValue('');
|
||||
setTitle('');
|
||||
}}
|
||||
>
|
||||
{t('admin.add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Название, которое правится по клику */
|
||||
function InlineTitle({ value, onSave }: { value: string; onSave: (v: string) => void }) {
|
||||
const [edit, setEdit] = useState<string | null>(null);
|
||||
if (edit === null)
|
||||
return (
|
||||
<span className="lead-title" onClick={() => setEdit(value)} title="✎">
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<input
|
||||
className="bc-input"
|
||||
autoFocus
|
||||
style={{ minHeight: 32, flex: 1 }}
|
||||
value={edit}
|
||||
maxLength={120}
|
||||
onChange={(e) => setEdit(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (edit.trim() && edit.trim() !== value) onSave(edit.trim());
|
||||
setEdit(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
if (e.key === 'Escape') setEdit(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Шаг плана: название, статус и комментарий, который клиент увидит в приложении и в боте */
|
||||
function StepEditor({ step, name }: { step: { id: string; code: string; title: string | null; status: (typeof STEP_STATUSES)[number]; note: string | null }; name: string }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const saved = useSaved();
|
||||
const [note, setNote] = useState(step.note ?? '');
|
||||
const save = useMutation(trpc.admin.leads.updateStep.mutationOptions(saved));
|
||||
const remove = useMutation(trpc.admin.leads.removeStep.mutationOptions(saved));
|
||||
return (
|
||||
<div className="lead-step">
|
||||
<div className="bc-row" style={{ gap: 6 }}>
|
||||
<InlineTitle value={name} onSave={(title) => save.mutate({ stepId: step.id, title })} />
|
||||
<button className="lead-x" title={t('admin.leads.remove')} onClick={async () => (await confirmDialog(t('admin.leads.removeStepConfirm', { name }))) && remove.mutate({ stepId: step.id })}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<select className="bc-input" value={step.status} onChange={(e) => save.mutate({ stepId: step.id, status: e.target.value as (typeof STEP_STATUSES)[number] })} style={{ minHeight: 36 }}>
|
||||
{STEP_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`client.move.status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input className="bc-input" style={{ minHeight: 36 }} value={note} maxLength={500} placeholder={t('admin.leads.stepNote')} onChange={(e) => setNote(e.target.value)} />
|
||||
{note !== (step.note ?? '') && (
|
||||
<Button size="sm" variant="outline" onClick={() => save.mutate({ stepId: step.id, note })}>
|
||||
{t('admin.webRequests.saveNote')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Field, Logo } from '@barev/ui';
|
||||
import { errorText, useI18n, useTRPC } from '@barev/web';
|
||||
import QRCode from 'qrcode';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Вход: пароль → (первый раз) настройка 2FA → код из приложения-аутентификатора */
|
||||
export function Login({ stage }: { stage: 'password' | 'totp' | 'totp_setup' }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [setup, setSetup] = useState<{ secret: string; qr: string } | null>(null);
|
||||
|
||||
const refresh = () => qc.invalidateQueries();
|
||||
const login = useMutation(trpc.admin.auth.login.mutationOptions({ onSuccess: refresh, onError: (e) => setError(errorText(t, e)) }));
|
||||
const totpSetup = useMutation(
|
||||
trpc.admin.auth.totpSetup.mutationOptions({
|
||||
onSuccess: async (r) => setSetup({ secret: r.secret, qr: await QRCode.toDataURL(r.uri, { margin: 1, width: 220 }) }),
|
||||
onError: (e) => setError(errorText(t, e)),
|
||||
}),
|
||||
);
|
||||
const verify = useMutation(trpc.admin.auth.totpVerify.mutationOptions({ onSuccess: refresh, onError: (e) => setError(errorText(t, e)) }));
|
||||
|
||||
useEffect(() => {
|
||||
if (stage === 'totp_setup' && !setup && !totpSetup.isPending) totpSetup.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stage]);
|
||||
|
||||
return (
|
||||
<div className="auth-wrap">
|
||||
<Card className="auth-card bc-stack" style={{ padding: 28, gap: 16 }}>
|
||||
<Logo />
|
||||
{stage === 'password' ? (
|
||||
<form
|
||||
className="bc-stack"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
login.mutate({ email, password });
|
||||
}}
|
||||
>
|
||||
<h1 className="bc-h2">{t('admin.login.title')}</h1>
|
||||
<Field label={t('admin.login.email')} htmlFor="email">
|
||||
<input id="email" className="bc-input" type="text" autoCapitalize="none" autoComplete="username" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label={t('admin.login.password')} htmlFor="password">
|
||||
<input id="password" className="bc-input" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
</Field>
|
||||
{error && <div className="bc-error">{error}</div>}
|
||||
<Button type="submit" block disabled={login.isPending}>
|
||||
{t('admin.login.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="bc-stack"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
verify.mutate({ code });
|
||||
}}
|
||||
>
|
||||
<h1 className="bc-h2">{t(stage === 'totp_setup' ? 'admin.login.setupTitle' : 'admin.login.totpTitle')}</h1>
|
||||
{stage === 'totp_setup' && (
|
||||
<>
|
||||
<p style={{ margin: 0 }}>{t('admin.login.setupText')}</p>
|
||||
{setup && (
|
||||
<div className="bc-stack" style={{ alignItems: 'center' }}>
|
||||
<img src={setup.qr} alt={t('admin.login.qrAlt')} width={220} height={220} style={{ borderRadius: 16, background: '#fff' }} />
|
||||
<code className="mono" style={{ wordBreak: 'break-all' }}>
|
||||
{setup.secret}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Field label={t('admin.login.code')} htmlFor="code">
|
||||
<input id="code" className="bc-input" inputMode="numeric" autoComplete="one-time-code" pattern="\d{6}" maxLength={6} value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))} required autoFocus />
|
||||
</Field>
|
||||
{error && <div className="bc-error">{error}</div>}
|
||||
<Button type="submit" block disabled={verify.isPending || code.length !== 6}>
|
||||
{t('admin.login.verify')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { LOCALES } from '@barev/i18n';
|
||||
import { Badge, Button, Card, Field } from '@barev/ui';
|
||||
import QRCode from 'qrcode';
|
||||
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
|
||||
const CATEGORIES = ['cafe', 'restaurant', 'shop', 'beauty', 'other'] as const;
|
||||
type Draft = { id?: string; category: (typeof CATEGORIES)[number]; name: string; offer: Record<string, string>; address: string; active: boolean };
|
||||
const EMPTY: Draft = { category: 'cafe', name: '', offer: { ru: '', en: '', zh: '', hy: '', es: '' }, address: '', active: true };
|
||||
|
||||
/** Партнёры клуба: что получает клиент по клубной карте — показывается в приложении на вкладке «Скидки» */
|
||||
export function Partners() {
|
||||
const { t, locale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const list = useQuery(trpc.admin.partners.list.queryOptions());
|
||||
const [d, setD] = useState<Draft | null>(null);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const save = useMutation(
|
||||
trpc.admin.partners.save.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
setD(null);
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.nav.partners')}</h1>
|
||||
<Button size="sm" onClick={() => setD(EMPTY)}>
|
||||
{t('admin.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{d && (
|
||||
<Card className="form-grid">
|
||||
<Field label={t('admin.partners.name')}>
|
||||
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('admin.partners.category')}>
|
||||
<select className="bc-input" value={d.category} onChange={(e) => setD({ ...d, category: e.target.value as Draft['category'] })}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`client.partners.cat.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{LOCALES.map((l) => (
|
||||
<Field key={l} label={`${t('admin.partners.offer')} · ${l.toUpperCase()}`}>
|
||||
<input className="bc-input" value={d.offer[l] ?? ''} placeholder={l === 'ru' ? '−15% на всё меню' : ''} onChange={(e) => setD({ ...d, offer: { ...d.offer, [l]: e.target.value } })} />
|
||||
</Field>
|
||||
))}
|
||||
<Field label={t('admin.partners.address')}>
|
||||
<input className="bc-input" value={d.address} onChange={(e) => setD({ ...d, address: e.target.value })} />
|
||||
</Field>
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
|
||||
</label>
|
||||
<div className="full bc-row">
|
||||
<Button
|
||||
disabled={!d.name.trim() || !d.offer.ru?.trim()}
|
||||
onClick={() => save.mutate({ ...d, address: d.address.trim() || null, offer: { ru: d.offer.ru ?? '', en: d.offer.en ?? '', zh: d.offer.zh ?? '', hy: d.offer.hy ?? '', es: d.offer.es ?? '' } })}
|
||||
>
|
||||
{t('admin.save')}
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setD(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.partners.name')}</th>
|
||||
<th>{t('admin.partners.offer')}</th>
|
||||
<th>{t('admin.partners.address')}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data?.map((p) => {
|
||||
const offer = p.offer as Record<string, string>;
|
||||
return (
|
||||
<Fragment key={p.id}>
|
||||
<tr style={{ opacity: p.active ? 1 : 0.5 }}>
|
||||
<td>
|
||||
<b>{p.name}</b>
|
||||
<div className="bc-small bc-muted">{t(`client.partners.cat.${p.category}`)}</div>
|
||||
<div className="bc-small bc-muted">
|
||||
{t('admin.partners.summary', { offers: p.offers.length, members: p.members.length, visits: p._count.usages })}
|
||||
</div>
|
||||
</td>
|
||||
<td>{offer[locale] || offer.ru}</td>
|
||||
<td className="bc-small">{p.address ?? '—'}</td>
|
||||
<td>
|
||||
<div className="bc-chips">
|
||||
<Button size="sm" variant="light" onClick={() => setOpen(open === p.id ? null : p.id)}>
|
||||
{t('admin.partners.manage')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setD({ id: p.id, category: p.category as Draft['category'], name: p.name, offer, address: p.address ?? '', active: p.active })}>
|
||||
{t('admin.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{open === p.id && (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ background: 'var(--bc-bg)' }}>
|
||||
<PartnerDetails partner={p} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Offer = { id: string; title: unknown; minLevel: 'BAREV_PLUS' | 'ELITE' | null; limit: 'NONE' | 'ONCE' | 'WEEK' | 'MONTH'; limitCount: number; active: boolean };
|
||||
type OfferDraft = { id?: string; title: Record<string, string>; minLevel: Offer['minLevel']; limit: Offer['limit']; limitCount: number; active: boolean };
|
||||
const LEVELS = [null, 'BAREV_PLUS', 'ELITE'] as const;
|
||||
const LIMITS = ['NONE', 'ONCE', 'WEEK', 'MONTH'] as const;
|
||||
|
||||
/** Предложения партнёра по уровням и лимитам, ссылка для собственника, команда */
|
||||
function PartnerDetails({ partner }: { partner: { id: string; offers: Offer[]; members: { id: string; role: 'OWNER' | 'STAFF'; user: { firstName: string | null; lastName: string | null; username: string | null } }[] } }) {
|
||||
const { t, locale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [od, setOd] = useState<OfferDraft | null>(null);
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const [qr, setQr] = useState('');
|
||||
useEffect(() => {
|
||||
if (link) void QRCode.toString(link, { type: 'svg', margin: 1 }).then(setQr);
|
||||
}, [link]);
|
||||
const done = {
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
setOd(null);
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: unknown) => toast(errorText(t, e), 'error'),
|
||||
};
|
||||
const saveOffer = useMutation(trpc.admin.partners.saveOffer.mutationOptions(done));
|
||||
const removeMember = useMutation(trpc.admin.partners.removeMember.mutationOptions(done));
|
||||
const owner = useMutation(trpc.admin.partners.ownerInvite.mutationOptions({ onSuccess: (r) => setLink(r.url), onError: done.onError }));
|
||||
const levelName = (l: Offer['minLevel']) => (l === 'ELITE' ? 'Barev Elite' : l === 'BAREV_PLUS' ? 'Barev+' : t('admin.partners.allMembers'));
|
||||
const limitName = (o: { limit: Offer['limit']; limitCount: number }) => t(`admin.partners.limit_${o.limit}`, { count: o.limitCount });
|
||||
|
||||
return (
|
||||
<div className="bc-stack" style={{ gap: 12 }}>
|
||||
<div className="bc-between">
|
||||
<b>{t('admin.partners.offers')}</b>
|
||||
<Button size="sm" onClick={() => setOd({ title: { ru: '', en: '', zh: '', hy: '', es: '' }, minLevel: null, limit: 'NONE', limitCount: 1, active: true })}>
|
||||
{t('admin.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{od && (
|
||||
<div className="form-grid" style={{ background: 'var(--bc-surface)', padding: 12, borderRadius: 16 }}>
|
||||
{LOCALES.map((l) => (
|
||||
<Field key={l} label={`${t('admin.partners.offerTitle')} · ${l.toUpperCase()}`}>
|
||||
<input className="bc-input" value={od.title[l] ?? ''} placeholder={l === 'ru' ? 'Кофе в подарок' : ''} onChange={(e) => setOd({ ...od, title: { ...od.title, [l]: e.target.value } })} />
|
||||
</Field>
|
||||
))}
|
||||
<Field label={t('admin.partners.minLevel')}>
|
||||
<select className="bc-input" value={od.minLevel ?? ''} onChange={(e) => setOd({ ...od, minLevel: (e.target.value || null) as Offer['minLevel'] })}>
|
||||
{LEVELS.map((l) => (
|
||||
<option key={l ?? 'all'} value={l ?? ''}>
|
||||
{levelName(l)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('admin.partners.limit')}>
|
||||
<select className="bc-input" value={od.limit} onChange={(e) => setOd({ ...od, limit: e.target.value as Offer['limit'] })}>
|
||||
{LIMITS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{t(`admin.partners.limitKind_${l}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{(od.limit === 'WEEK' || od.limit === 'MONTH') && (
|
||||
<Field label={t('admin.partners.limitCount')}>
|
||||
<input className="bc-input" type="number" min={1} max={100} value={od.limitCount} onChange={(e) => setOd({ ...od, limitCount: Number(e.target.value) || 1 })} />
|
||||
</Field>
|
||||
)}
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={od.active} onChange={(e) => setOd({ ...od, active: e.target.checked })} /> {t('admin.active')}
|
||||
</label>
|
||||
<div className="full bc-row">
|
||||
<Button disabled={!od.title.ru?.trim()} onClick={() => saveOffer.mutate({ ...od, partnerId: partner.id, title: { ru: od.title.ru ?? '', en: od.title.en ?? '', zh: od.title.zh ?? '', hy: od.title.hy ?? '', es: od.title.es ?? '' } })}>
|
||||
{t('admin.save')}
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setOd(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{partner.offers.length === 0 && <p className="bc-small bc-muted" style={{ margin: 0 }}>{t('admin.partners.noOffers')}</p>}
|
||||
{partner.offers.map((o) => {
|
||||
const title = o.title as Record<string, string>;
|
||||
return (
|
||||
<div key={o.id} className="bc-between" style={{ opacity: o.active ? 1 : 0.5 }}>
|
||||
<span>
|
||||
<b>{title[locale] || title.ru}</b>{' '}
|
||||
<Badge style={{ background: o.minLevel === 'ELITE' ? 'var(--bc-perks)' : o.minLevel ? 'var(--bc-pink)' : 'var(--bc-surface)' }}>{levelName(o.minLevel)}</Badge>{' '}
|
||||
<span className="bc-small bc-muted">{limitName(o)}</span>
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOd({ id: o.id, title: { ...title }, minLevel: o.minLevel, limit: o.limit, limitCount: o.limitCount, active: o.active })}>
|
||||
{t('admin.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="bc-between" style={{ marginTop: 6 }}>
|
||||
<b>{t('admin.partners.team')}</b>
|
||||
<Button size="sm" variant="outline" onClick={() => owner.mutate({ partnerId: partner.id })}>
|
||||
{t('admin.partners.ownerLink')}
|
||||
</Button>
|
||||
</div>
|
||||
{link && (
|
||||
<div className="bc-row" style={{ alignItems: 'flex-start', gap: 14 }}>
|
||||
<div style={{ width: 160, height: 160, background: '#fff', borderRadius: 12, padding: 6 }} dangerouslySetInnerHTML={{ __html: qr }} />
|
||||
<div className="bc-stack" style={{ gap: 6, flex: 1 }}>
|
||||
<span className="bc-small">{t('admin.partners.ownerLinkHint')}</span>
|
||||
<input className="bc-input mono" readOnly value={link} onFocus={(e) => e.target.select()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{partner.members.length === 0 && <p className="bc-small bc-muted" style={{ margin: 0 }}>{t('admin.partners.noTeam')}</p>}
|
||||
{partner.members.map((m) => (
|
||||
<div key={m.id} className="bc-between">
|
||||
<span>
|
||||
<b>
|
||||
{m.user.firstName} {m.user.lastName ?? ''}
|
||||
</b>
|
||||
{m.user.username && <span className="bc-small bc-muted"> @{m.user.username}</span>}
|
||||
<span className="bc-small bc-muted"> · {t(m.role === 'OWNER' ? 'pro.partner.owner' : 'pro.partner.staff')}</span>
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={async () => (await confirmDialog(t('admin.partners.removeConfirm'))) && removeMember.mutate({ memberId: m.id })}>
|
||||
{t('pro.partner.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CATALOG, type PayoutRule, type PayoutRules } from '@barev/core';
|
||||
import { Badge, Button, Card, Field } from '@barev/ui';
|
||||
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
|
||||
const cleaning = CATALOG.find((s) => s.code === 'cleaning')!;
|
||||
const VARIANTS = cleaning.pricing.kind === 'rooms' ? Object.keys(cleaning.pricing.tiers) : [];
|
||||
const EXTRAS = cleaning.pricing.kind === 'rooms' ? Object.keys(cleaning.pricing.extras) : [];
|
||||
|
||||
interface Draft {
|
||||
id?: string;
|
||||
name: string;
|
||||
rules: PayoutRules;
|
||||
active: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/** Пресеты ставок клинеров (джун / мидл / прайват): по каждой строке цены — фикс или % */
|
||||
export function Presets({ isOwner }: { isOwner: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const list = useQuery(trpc.admin.presets.list.queryOptions());
|
||||
const [edit, setEdit] = useState<Draft | null>(null);
|
||||
const empty = (): Draft => ({
|
||||
name: '',
|
||||
active: true,
|
||||
sortOrder: (list.data?.length ?? 0) + 1,
|
||||
rules: {
|
||||
base: Object.fromEntries(VARIANTS.map((v) => [v, { type: 'PERCENT' as const, value: 60 }])),
|
||||
room: { type: 'PERCENT', value: 60 },
|
||||
bathroom: { type: 'PERCENT', value: 60 },
|
||||
extras: Object.fromEntries(EXTRAS.map((c) => [c, { type: 'PERCENT' as const, value: 60 }])),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.nav.presets')}</h1>
|
||||
{isOwner && (
|
||||
<Button size="sm" onClick={() => setEdit(empty())}>
|
||||
{t('admin.presets.add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
||||
{t('admin.presets.hint')}
|
||||
</p>
|
||||
{edit && <PresetForm draft={edit} onClose={() => setEdit(null)} />}
|
||||
<div className="summary">
|
||||
{list.data?.map((p) => (
|
||||
<Card key={p.id} className="bc-stack" style={{ opacity: p.active ? 1 : 0.5 }}>
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h3">{p.name}</h2>
|
||||
<Badge style={{ background: 'var(--bc-bg)' }}>{t('admin.presets.providers', { count: p.providers })}</Badge>
|
||||
</div>
|
||||
<div className="bc-small">
|
||||
{VARIANTS.map((v) => (
|
||||
<div key={v}>
|
||||
{t(`params.cleaning.cleaningType.${v}`)}: <RuleText rule={p.rules.base[v]} />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
{t('admin.presets.room')}: <RuleText rule={p.rules.room} />
|
||||
</div>
|
||||
<div>
|
||||
{t('admin.presets.bathroom')}: <RuleText rule={p.rules.bathroom} />
|
||||
</div>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<Button size="sm" variant="light" onClick={() => setEdit({ id: p.id, name: p.name, rules: p.rules, active: p.active, sortOrder: p.sortOrder })}>
|
||||
{t('admin.edit')}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleText({ rule }: { rule: PayoutRule | undefined }) {
|
||||
const { amd } = useI18n();
|
||||
if (!rule) return <>—</>;
|
||||
return <b>{rule.type === 'PERCENT' ? `${rule.value}%` : amd(rule.value)}</b>;
|
||||
}
|
||||
|
||||
function PresetForm({ draft, onClose }: { draft: Draft; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [d, setD] = useState(draft);
|
||||
const save = useMutation(
|
||||
trpc.admin.presets.save.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const rules = d.rules;
|
||||
const setRules = (r: PayoutRules) => setD({ ...d, rules: r });
|
||||
|
||||
/** Применить один процент ко всем строкам — быстрый старт */
|
||||
const [bulk, setBulk] = useState(60);
|
||||
const applyAll = () => {
|
||||
const r: PayoutRule = { type: 'PERCENT', value: bulk };
|
||||
setRules({
|
||||
base: Object.fromEntries(VARIANTS.map((v) => [v, r])),
|
||||
room: r,
|
||||
bathroom: r,
|
||||
extras: Object.fromEntries(EXTRAS.map((c) => [c, r])),
|
||||
});
|
||||
};
|
||||
|
||||
const rows: { key: string; label: string; unit: string; rule: PayoutRule | undefined; set: (r: PayoutRule) => void }[] = [
|
||||
...VARIANTS.map((v) => ({
|
||||
key: `base:${v}`,
|
||||
label: `${t('services.cleaning.name')} · ${t(`params.cleaning.cleaningType.${v}`)}`,
|
||||
unit: t('admin.presets.perOrder'),
|
||||
rule: rules.base[v],
|
||||
set: (r: PayoutRule) => setRules({ ...rules, base: { ...rules.base, [v]: r } }),
|
||||
})),
|
||||
{ key: 'room', label: t('admin.presets.room'), unit: t('admin.presets.perRoom'), rule: rules.room, set: (r) => setRules({ ...rules, room: r }) },
|
||||
{ key: 'bathroom', label: t('admin.presets.bathroom'), unit: t('admin.presets.perBathroom'), rule: rules.bathroom, set: (r) => setRules({ ...rules, bathroom: r }) },
|
||||
...EXTRAS.map((c) => ({
|
||||
key: `extra:${c}`,
|
||||
label: t(`extras.${c}.name`),
|
||||
unit: cleaning.pricing.kind === 'rooms' && cleaning.pricing.extras[c]?.unit !== 'service' ? t(`extras.${c}.per`) : t('admin.presets.perOrder'),
|
||||
rule: rules.extras[c],
|
||||
set: (r: PayoutRule) => setRules({ ...rules, extras: { ...rules.extras, [c]: r } }),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="bc-stack">
|
||||
<div className="form-grid">
|
||||
<Field label={t('admin.presets.name')}>
|
||||
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} maxLength={60} />
|
||||
</Field>
|
||||
<Field label={t('admin.presets.applyAll')}>
|
||||
<div className="bc-row">
|
||||
<input className="bc-input" type="number" min={0} max={100} value={bulk} onChange={(e) => setBulk(Number(e.target.value))} style={{ width: 100 }} />
|
||||
<span>%</span>
|
||||
<Button size="sm" variant="light" onClick={applyAll}>
|
||||
{t('common.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.presets.line')}</th>
|
||||
<th>{t('admin.presets.type')}</th>
|
||||
<th>{t('admin.presets.value')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const rule = r.rule ?? { type: 'PERCENT' as const, value: 0 };
|
||||
return (
|
||||
<tr key={r.key}>
|
||||
<td>{r.label}</td>
|
||||
<td>
|
||||
<select className="bc-input" value={rule.type} style={{ minHeight: 38, padding: '6px 10px' }} onChange={(e) => r.set({ ...rule, type: e.target.value as PayoutRule['type'] })}>
|
||||
<option value="PERCENT">{t('admin.presets.percent')}</option>
|
||||
<option value="FIXED">{t('admin.presets.fixed', { unit: r.unit })}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div className="bc-row">
|
||||
<input
|
||||
className="bc-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={rule.type === 'PERCENT' ? 100 : undefined}
|
||||
value={rule.value}
|
||||
style={{ minHeight: 38, padding: '6px 10px', width: 120 }}
|
||||
onChange={(e) => r.set({ ...rule, value: Math.max(0, Math.round(Number(e.target.value) || 0)) })}
|
||||
/>
|
||||
<span className="bc-small bc-muted">{rule.type === 'PERCENT' ? '%' : `֏ ${r.unit}`}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
|
||||
</label>
|
||||
<div className="bc-row">
|
||||
<Button disabled={!d.name.trim() || save.isPending} onClick={() => save.mutate({ ...d, name: d.name.trim(), role: 'CLEANER' })}>
|
||||
{t('admin.save')}
|
||||
</Button>
|
||||
<Button variant="light" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { averageRating, PROVIDER_ROLES, type Locale, type ProviderRole } from '@barev/core';
|
||||
import { Badge, Button, Card, Field } from '@barev/ui';
|
||||
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { Copyable, LOCALE_OPTIONS, MultiChips, ScheduleEditor, type ScheduleRow } from '../components/form.tsx';
|
||||
|
||||
interface Draft {
|
||||
id?: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
role: ProviderRole;
|
||||
companyId: string;
|
||||
languages: Locale[];
|
||||
districtIds: string[];
|
||||
schedule: ScheduleRow[];
|
||||
rateType: 'FIXED' | 'PERCENT';
|
||||
rateValue: number;
|
||||
payoutPresetId: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const EMPTY: Draft = {
|
||||
name: '',
|
||||
phone: '',
|
||||
role: 'CLEANER',
|
||||
companyId: '',
|
||||
languages: ['ru'],
|
||||
districtIds: [],
|
||||
schedule: [0, 1, 2, 3, 4, 5].map((weekday) => ({ weekday, startMinute: 540, endMinute: 1200 })),
|
||||
rateType: 'PERCENT',
|
||||
rateValue: 70,
|
||||
payoutPresetId: '',
|
||||
active: true,
|
||||
};
|
||||
|
||||
/** ADM-5: исполнители — роль, языки, районы, график, ставка (PRO-8) */
|
||||
export function Providers() {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const list = useQuery(trpc.admin.providers.list.queryOptions());
|
||||
const config = useQuery(trpc.admin.dictionaries.config.queryOptions());
|
||||
const [edit, setEdit] = useState<Draft | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.nav.providers')}</h1>
|
||||
<Button size="sm" onClick={() => setEdit({ ...EMPTY })}>
|
||||
{t('admin.providers.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{edit && <ProviderForm draft={edit} onClose={() => setEdit(null)} />}
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.providers.name')}</th>
|
||||
<th>{t('admin.providers.role')}</th>
|
||||
<th>{t('admin.providers.languages')}</th>
|
||||
<th>{t('admin.providers.districts')}</th>
|
||||
<th>{t('admin.providers.rate')}</th>
|
||||
<th>{t('admin.providers.rating')}</th>
|
||||
<th>{t('admin.providers.telegram')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data?.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="clickable"
|
||||
style={{ opacity: p.active ? 1 : 0.5 }}
|
||||
onClick={() =>
|
||||
setEdit({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
phone: p.phone ?? '',
|
||||
role: p.role,
|
||||
companyId: p.companyId ?? '',
|
||||
languages: p.languages,
|
||||
districtIds: p.districts.map((d) => d.districtId),
|
||||
schedule: p.schedule.map((s) => ({ weekday: s.weekday, startMinute: s.startMinute, endMinute: s.endMinute })),
|
||||
rateType: p.rateType,
|
||||
rateValue: p.rateValue,
|
||||
payoutPresetId: p.payoutPresetId ?? '',
|
||||
active: p.active,
|
||||
})
|
||||
}
|
||||
>
|
||||
<td>
|
||||
<b>{p.name}</b>
|
||||
{p.company && <div className="bc-small bc-muted">{p.company.name}</div>}
|
||||
<a href={`#/concierge?provider=${p.id}`} className="bc-small" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||
{t('admin.concierge.write')}
|
||||
</a>
|
||||
</td>
|
||||
<td>{t(`roles.${p.role}`)}</td>
|
||||
<td>{p.languages.map((l) => l.toUpperCase()).join(' ')}</td>
|
||||
<td className="bc-small">{p.districts.map((d) => t(`districts.${d.district.code}`)).join(', ')}</td>
|
||||
<td>{p.company ? t('admin.providers.companyRate') : p.payoutPreset ? p.payoutPreset.name : p.rateType === 'PERCENT' ? `${p.rateValue}%` : `${p.rateValue} ֏`}</td>
|
||||
<td>{averageRating(p.ratingSum, p.ratingCount) ?? '—'}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
{p.userId ? <Badge style={{ background: 'var(--bc-home)' }}>{t('admin.providers.linked')}</Badge> : config.data && <Copyable text={`https://t.me/${config.data.proBot}?start=${p.inviteCode}`} />}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderForm({ draft: initial, onClose }: { draft: Draft; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const districts = useQuery(trpc.admin.dictionaries.districts.queryOptions());
|
||||
const companies = useQuery(trpc.admin.companies.list.queryOptions());
|
||||
const presets = useQuery(trpc.admin.presets.list.queryOptions());
|
||||
const [d, setD] = useState(initial);
|
||||
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD({ ...d, [k]: v });
|
||||
const onDone = () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
onClose();
|
||||
};
|
||||
const create = useMutation(trpc.admin.providers.create.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
|
||||
const update = useMutation(trpc.admin.providers.update.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
|
||||
const reset = useMutation(trpc.admin.providers.resetInvite.mutationOptions({ onSuccess: onDone }));
|
||||
|
||||
function save() {
|
||||
const payload = { ...d, phone: d.phone || null, companyId: d.companyId || null, payoutPresetId: d.payoutPresetId || null };
|
||||
if (d.id) update.mutate({ ...payload, id: d.id });
|
||||
else create.mutate(payload);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bc-stack">
|
||||
<h2 className="bc-h3">{d.id ? d.name : t('admin.providers.add')}</h2>
|
||||
<div className="form-grid">
|
||||
<Field label={t('admin.providers.name')}>
|
||||
<input className="bc-input" value={d.name} onChange={(e) => set('name', e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('admin.providers.phone')}>
|
||||
<input className="bc-input" value={d.phone} onChange={(e) => set('phone', e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('admin.providers.role')}>
|
||||
<select className="bc-input" value={d.role} onChange={(e) => set('role', e.target.value as ProviderRole)}>
|
||||
{PROVIDER_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{t(`roles.${r}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('admin.providers.company')}>
|
||||
<select className="bc-input" value={d.companyId} onChange={(e) => set('companyId', e.target.value)}>
|
||||
<option value="">{t('admin.providers.freelancer')}</option>
|
||||
{companies.data?.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="full">
|
||||
<Field label={t('admin.providers.languages')}>
|
||||
<MultiChips value={d.languages} options={LOCALE_OPTIONS} onChange={(v) => set('languages', v)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="full">
|
||||
<Field label={t('admin.providers.districts')}>
|
||||
<MultiChips value={d.districtIds} options={(districts.data ?? []).map((x) => ({ value: x.id, label: t(`districts.${x.code}`) }))} onChange={(v) => set('districtIds', v)} />
|
||||
</Field>
|
||||
</div>
|
||||
{!d.companyId && (
|
||||
<Field label={t('admin.providers.preset')}>
|
||||
<select className="bc-input" value={d.payoutPresetId} onChange={(e) => set('payoutPresetId', e.target.value)}>
|
||||
<option value="">{t('admin.providers.ownRate')}</option>
|
||||
{presets.data
|
||||
?.filter((p) => p.role === d.role && (p.active || p.id === d.payoutPresetId))
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{!d.companyId && !d.payoutPresetId && (
|
||||
<>
|
||||
<Field label={t('admin.providers.rateType')}>
|
||||
<select className="bc-input" value={d.rateType} onChange={(e) => set('rateType', e.target.value as Draft['rateType'])}>
|
||||
<option value="PERCENT">{t('admin.rate.PERCENT')}</option>
|
||||
<option value="FIXED">{t('admin.rate.FIXED')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={d.rateType === 'PERCENT' ? t('admin.providers.ratePercent') : t('admin.providers.rateFixed')}>
|
||||
<input className="bc-input" type="number" min={0} value={d.rateValue} onChange={(e) => set('rateValue', Number(e.target.value))} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<div className="full">
|
||||
<Field label={t('admin.providers.schedule')}>
|
||||
<ScheduleEditor value={d.schedule} onChange={(v) => set('schedule', v)} />
|
||||
</Field>
|
||||
</div>
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={d.active} onChange={(e) => set('active', e.target.checked)} /> {t('admin.active')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="bc-row">
|
||||
<Button onClick={save} disabled={create.isPending || update.isPending || !d.name || !d.languages.length}>
|
||||
{t('admin.save')}
|
||||
</Button>
|
||||
<Button variant="light" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{d.id && (
|
||||
<Button variant="ghost" onClick={() => reset.mutate({ id: d.id! })}>
|
||||
{t('admin.providers.resetInvite')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge, Button, Chip } from '@barev/ui';
|
||||
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
|
||||
const STATUSES = ['NEW', 'DONE', 'CANCELLED'] as const;
|
||||
type Status = (typeof STATUSES)[number];
|
||||
const TONE: Record<Status, string> = { NEW: 'var(--bc-perks)', DONE: 'var(--bc-home)', CANCELLED: 'var(--bc-line)' };
|
||||
|
||||
/** Заявки из приложения: подписка (включить одной кнопкой) или «перезвоните» по услуге */
|
||||
export function Requests() {
|
||||
const { t, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [status, setStatus] = useState<Status | undefined>('NEW');
|
||||
const list = useQuery({ ...trpc.admin.requests.list.queryOptions({ status }), refetchInterval: 15_000 });
|
||||
const done = {
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: unknown) => toast(errorText(t, e), 'error'),
|
||||
};
|
||||
const update = useMutation(trpc.admin.requests.update.mutationOptions(done));
|
||||
const activate = useMutation(trpc.admin.requests.activate.mutationOptions(done));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.nav.requests')}</h1>
|
||||
<div className="bc-chips">
|
||||
<Chip pressed={!status} onClick={() => setStatus(undefined)}>
|
||||
{t('admin.leads.all')}
|
||||
</Chip>
|
||||
{STATUSES.map((s) => (
|
||||
<Chip key={s} pressed={status === s} onClick={() => setStatus(s)}>
|
||||
{t(`admin.requests.status_${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{list.data?.length === 0 ? (
|
||||
<p className="bc-muted">{t('admin.requests.empty')}</p>
|
||||
) : (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.leads.number')}</th>
|
||||
<th>{t('admin.requests.client')}</th>
|
||||
<th>{t('admin.requests.what')}</th>
|
||||
<th>{t('admin.requests.comment')}</th>
|
||||
<th>{t('admin.leads.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data?.map((r) => {
|
||||
const u = r.client.user;
|
||||
const plan = r.plan === 'ELITE' ? 'Barev Elite' : 'Barev+';
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<b>{r.number}</b>
|
||||
<div className="bc-small bc-muted">{dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</div>
|
||||
</td>
|
||||
<td>
|
||||
<b>
|
||||
{u.firstName} {u.lastName ?? ''}
|
||||
</b>
|
||||
{u.username && (
|
||||
<div>
|
||||
<a href={`https://t.me/${u.username}`} target="_blank" rel="noopener">
|
||||
@{u.username}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{u.phone && <div className="bc-small">{u.phone}</div>}
|
||||
<div className="bc-small bc-muted">{u.locale.toUpperCase()}</div>
|
||||
</td>
|
||||
<td>
|
||||
{r.kind === 'SUBSCRIPTION' ? (
|
||||
<Badge style={{ background: r.plan === 'ELITE' ? 'var(--bc-perks)' : 'var(--bc-pink)' }}>{t('admin.requests.subscription', { plan })}</Badge>
|
||||
) : (
|
||||
<b>{t(`services.${r.serviceCode}.name`)}</b>
|
||||
)}
|
||||
</td>
|
||||
<td className="bc-small" style={{ maxWidth: 320 }}>
|
||||
{r.comment ?? '—'}
|
||||
</td>
|
||||
<td>
|
||||
<Badge style={{ background: TONE[r.status] }}>{t(`admin.requests.status_${r.status}`)}</Badge>
|
||||
{r.status === 'NEW' && (
|
||||
<div className="bc-chips" style={{ marginTop: 8 }}>
|
||||
{r.kind === 'SUBSCRIPTION' && (
|
||||
<Button size="sm" onClick={async () => (await confirmDialog(t('admin.requests.activateConfirm', { plan }))) && activate.mutate({ id: r.id, days: 30 })}>
|
||||
{t('admin.requests.activate')}
|
||||
</Button>
|
||||
)}
|
||||
{r.kind === 'SERVICE' && (
|
||||
<Button size="sm" variant="light" onClick={() => update.mutate({ id: r.id, status: 'DONE' })}>
|
||||
{t('admin.requests.markDone')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="light" onClick={() => update.mutate({ id: r.id, status: 'CANCELLED' })}>
|
||||
{t('admin.webRequests.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { pricingSchema, slotConfigSchema, type Pricing, type SlotConfig } from '@barev/core';
|
||||
import { Badge, Button, Card, Field } from '@barev/ui';
|
||||
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
|
||||
/** ADM-5: направления и услуги — цены, доступность, окно записи, удержание; скидки Barev+ */
|
||||
export function Services({ isOwner }: { isOwner: boolean }) {
|
||||
const { t, amd } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const list = useQuery(trpc.admin.services.list.queryOptions());
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const updateCategory = useMutation(
|
||||
trpc.admin.services.updateCategory.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="bc-h2">{t('admin.nav.services')}</h1>
|
||||
{list.data?.map((c) => (
|
||||
<Card key={c.id} className="bc-stack">
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h3">{t(`categories.${c.code}.name`)}</h2>
|
||||
{c.code !== 'PERKS' && (
|
||||
<label className="bc-row bc-small">
|
||||
{t('admin.services.plusDiscount')}
|
||||
<input
|
||||
className="bc-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={c.barevPlusDiscountPct}
|
||||
disabled={!isOwner}
|
||||
style={{ width: 90, minHeight: 40 }}
|
||||
onBlur={(e) => Number(e.target.value) !== c.barevPlusDiscountPct && updateCategory.mutate({ id: c.id, barevPlusDiscountPct: Number(e.target.value) })}
|
||||
/>
|
||||
%
|
||||
<span style={{ marginLeft: 12 }}>{t('admin.services.eliteDiscount')}</span>
|
||||
<input
|
||||
className="bc-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={c.eliteDiscountPct}
|
||||
disabled={!isOwner}
|
||||
style={{ width: 90, minHeight: 40 }}
|
||||
onBlur={(e) => Number(e.target.value) !== c.eliteDiscountPct && updateCategory.mutate({ id: c.id, eliteDiscountPct: Number(e.target.value) })}
|
||||
/>
|
||||
%
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{c.services.map((s) => {
|
||||
const pricing = pricingSchema.parse(s.pricing);
|
||||
const from = pricing.kind === 'fixed' ? pricing.price : Math.min(...Object.values(pricing.tiers).map((x) => x.base));
|
||||
return (
|
||||
<div key={s.id} className="bc-stack" style={{ gap: 8, borderTop: '1px solid var(--bc-line)', paddingTop: 10 }}>
|
||||
<div className="bc-between">
|
||||
<div className="bc-row">
|
||||
<b>{t(`services.${s.code}.name`)}</b>
|
||||
<Badge style={{ background: s.active ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(s.active ? 'admin.services.active' : 'admin.services.inactive')}</Badge>
|
||||
<span className="bc-small bc-muted">{t('common.from', { price: amd(from) })}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="light" onClick={() => setOpen(open === s.id ? null : s.id)}>
|
||||
{t(open === s.id ? 'common.close' : 'admin.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
{open === s.id && <ServiceEditor service={{ ...s, pricing, slotConfig: slotConfigSchema.parse(s.slotConfig) }} onSaved={() => setOpen(null)} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceEditor({
|
||||
service,
|
||||
onSaved,
|
||||
}: {
|
||||
service: { id: string; code: string; active: boolean; lateCancelFeePct: number; pricing: Pricing; slotConfig: SlotConfig; checklist: { code: string; requiresPhoto: boolean }[] };
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [active, setActive] = useState(service.active);
|
||||
const [fee, setFee] = useState(service.lateCancelFeePct);
|
||||
const [pricing, setPricing] = useState<Pricing>(service.pricing);
|
||||
const [slot, setSlot] = useState<SlotConfig>(service.slotConfig);
|
||||
const save = useMutation(
|
||||
trpc.admin.services.update.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
onSaved();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const num = (v: string) => Math.max(0, Math.round(Number(v) || 0));
|
||||
|
||||
return (
|
||||
<div className="bc-stack" style={{ background: 'var(--bc-bg)', borderRadius: 20, padding: 16 }}>
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> {t('admin.services.availableToOrder')}
|
||||
</label>
|
||||
<h4 style={{ margin: 0 }}>{t('admin.services.prices')}</h4>
|
||||
{pricing.kind === 'rooms' ? (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.services.variant')}</th>
|
||||
<th>{t('admin.services.base')}</th>
|
||||
<th>{t('admin.services.perRoom')}</th>
|
||||
<th>{t('admin.services.baseMinutes')}</th>
|
||||
<th>{t('admin.services.perRoomMinutes')}</th>
|
||||
<th>{t('admin.services.perBathroom')}</th>
|
||||
<th>{t('admin.services.perBathroomMinutes')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(pricing.tiers).map(([variant, tier]) => (
|
||||
<tr key={variant}>
|
||||
<td>{t(`params.${service.code}.${pricing.variantParam}.${variant}`)}</td>
|
||||
{(['base', 'perRoom', 'baseMinutes', 'perRoomMinutes', 'perBathroom', 'perBathroomMinutes'] as const).map((k) => (
|
||||
<td key={k}>
|
||||
<input
|
||||
className="bc-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={tier[k]}
|
||||
style={{ minHeight: 38, padding: '6px 10px' }}
|
||||
onChange={(e) => setPricing({ ...pricing, tiers: { ...pricing.tiers, [variant]: { ...tier, [k]: num(e.target.value) } } })}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
{pricing.kind === 'rooms' && Object.keys(pricing.extras).length > 0 && (
|
||||
<>
|
||||
<h4 style={{ margin: 0 }}>{t('admin.services.extras')}</h4>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.services.extra')}</th>
|
||||
<th>{t('admin.services.extraPrice')}</th>
|
||||
<th>{t('admin.services.extraMinutes')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(pricing.extras).map(([code, e]) => (
|
||||
<tr key={code}>
|
||||
<td>
|
||||
{t(`extras.${code}.name`)}
|
||||
{e.unit !== 'service' && <span className="bc-small bc-muted"> {t(`extras.${code}.per`)}</span>}
|
||||
</td>
|
||||
{(['price', 'minutes'] as const).map((k) => (
|
||||
<td key={k}>
|
||||
<input
|
||||
className="bc-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={e[k]}
|
||||
style={{ minHeight: 38, padding: '6px 10px' }}
|
||||
onChange={(ev) => setPricing({ ...pricing, extras: { ...pricing.extras, [code]: { ...e, [k]: num(ev.target.value) } } })}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
{pricing.kind === 'fixed' && (
|
||||
<div className="form-grid">
|
||||
<Field label={t('admin.services.price')}>
|
||||
<input className="bc-input" type="number" min={0} value={pricing.price} onChange={(e) => setPricing({ ...pricing, price: num(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label={t('admin.services.minutes')}>
|
||||
<input className="bc-input" type="number" min={1} value={pricing.minutes} onChange={(e) => setPricing({ ...pricing, minutes: Math.max(1, num(e.target.value)) })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-grid">
|
||||
<Field label={t('admin.services.lateFee')}>
|
||||
<input className="bc-input" type="number" min={0} max={100} value={fee} onChange={(e) => setFee(Math.min(100, num(e.target.value)))} />
|
||||
</Field>
|
||||
<Field label={t('admin.services.step')}>
|
||||
<input className="bc-input" type="number" min={15} step={15} value={slot.stepMinutes} onChange={(e) => setSlot({ ...slot, stepMinutes: Math.max(15, num(e.target.value)) })} />
|
||||
</Field>
|
||||
<Field label={t('admin.services.dayStart')}>
|
||||
<input className="bc-input" type="number" min={0} max={24} value={slot.dayStartMinute / 60} onChange={(e) => setSlot({ ...slot, dayStartMinute: Math.min(24, num(e.target.value)) * 60 })} />
|
||||
</Field>
|
||||
<Field label={t('admin.services.dayEnd')}>
|
||||
<input className="bc-input" type="number" min={0} max={24} value={slot.dayEndMinute / 60} onChange={(e) => setSlot({ ...slot, dayEndMinute: Math.min(24, num(e.target.value)) * 60 })} />
|
||||
</Field>
|
||||
<Field label={t('admin.services.leadHours')}>
|
||||
<input className="bc-input" type="number" min={0} value={slot.leadHours} onChange={(e) => setSlot({ ...slot, leadHours: num(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label={t('admin.services.horizon')}>
|
||||
<input className="bc-input" type="number" min={1} value={slot.horizonDays} onChange={(e) => setSlot({ ...slot, horizonDays: Math.max(1, num(e.target.value)) })} />
|
||||
</Field>
|
||||
</div>
|
||||
{service.checklist.length > 0 && (
|
||||
<div className="bc-small">
|
||||
<b>{t('admin.order.checklist')}:</b> {service.checklist.map((c) => `${t(`checklist.${c.code}`)}${c.requiresPhoto ? ' 📷' : ''}`).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button onClick={() => save.mutate({ id: service.id, active, lateCancelFeePct: fee, pricing, slotConfig: slot })} disabled={save.isPending}>
|
||||
{t('admin.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { SETTING_KEYS, type Settings as S } from '@barev/core';
|
||||
import { LOCALES } from '@barev/i18n';
|
||||
import { Button, Card, Field } from '@barev/ui';
|
||||
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Настройки владельца: пороги, сроки, цены подписки; способы оплаты; администраторы */
|
||||
export function Settings() {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const settings = useQuery(trpc.admin.settings.get.queryOptions());
|
||||
const [draft, setDraft] = useState<S | null>(null);
|
||||
useEffect(() => {
|
||||
if (settings.data) setDraft(settings.data);
|
||||
}, [settings.data]);
|
||||
const save = useMutation(
|
||||
trpc.admin.settings.update.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="bc-h2">{t('admin.nav.settings')}</h1>
|
||||
{draft && (
|
||||
<Card className="bc-stack">
|
||||
<div className="form-grid">
|
||||
{SETTING_KEYS.map((k) => (
|
||||
<Field key={k} label={t(`admin.settings.${k.replace('.', '_')}`)}>
|
||||
<input
|
||||
className="bc-input"
|
||||
type={typeof draft[k] === 'number' ? 'number' : 'text'}
|
||||
value={String(draft[k])}
|
||||
onChange={(e) => setDraft({ ...draft, [k]: typeof draft[k] === 'number' ? Number(e.target.value) : e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={() => save.mutate(draft)} disabled={save.isPending}>
|
||||
{t('admin.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<PaymentMethods />
|
||||
<Admins />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentMethods() {
|
||||
const { t, locale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const methods = useQuery(trpc.admin.dictionaries.paymentMethods.queryOptions());
|
||||
const [d, setD] = useState<{ id?: string; code: string; name: Record<string, string>; active: boolean; sortOrder: number } | null>(null);
|
||||
const save = useMutation(
|
||||
trpc.admin.dictionaries.savePaymentMethod.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
setD(null);
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Card className="bc-stack">
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h3">{t('admin.settings.paymentMethods')}</h2>
|
||||
<Button size="sm" variant="light" onClick={() => setD({ code: '', name: { ru: '', en: '', zh: '', hy: '', es: '' }, active: true, sortOrder: (methods.data?.length ?? 0) + 1 })}>
|
||||
{t('admin.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{methods.data?.map((m) => (
|
||||
<div key={m.id} className="bc-between">
|
||||
<span style={{ opacity: m.active ? 1 : 0.5 }}>
|
||||
<b>{(m.name as Record<string, string>)[locale] || (m.name as Record<string, string>).ru}</b> <span className="mono bc-muted">{m.code}</span>
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => setD({ id: m.id, code: m.code, name: m.name as Record<string, string>, active: m.active, sortOrder: m.sortOrder })}>
|
||||
{t('admin.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{d && (
|
||||
<div className="form-grid">
|
||||
<Field label={t('admin.settings.code')}>
|
||||
<input className="bc-input mono" value={d.code} onChange={(e) => setD({ ...d, code: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '') })} />
|
||||
</Field>
|
||||
{LOCALES.map((l) => (
|
||||
<Field key={l} label={`${t('admin.settings.name')} · ${l.toUpperCase()}`}>
|
||||
<input className="bc-input" value={d.name[l] ?? ''} onChange={(e) => setD({ ...d, name: { ...d.name, [l]: e.target.value } })} />
|
||||
</Field>
|
||||
))}
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
|
||||
</label>
|
||||
<div className="full bc-row">
|
||||
<Button disabled={!d.code || !d.name.ru} onClick={() => save.mutate({ ...d, name: { ru: d.name.ru ?? '', en: d.name.en ?? '', zh: d.name.zh ?? '', hy: d.name.hy ?? '', es: d.name.es ?? '' } })}>
|
||||
{t('admin.save')}
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setD(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Admins() {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const admins = useQuery(trpc.admin.admins.list.queryOptions());
|
||||
const [d, setD] = useState({ email: '', name: '', role: 'OPERATOR' as 'OWNER' | 'OPERATOR', password: '' });
|
||||
const create = useMutation(
|
||||
trpc.admin.admins.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
setD({ email: '', name: '', role: 'OPERATOR', password: '' });
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const setActive = useMutation(trpc.admin.admins.setActive.mutationOptions({ onSuccess: () => qc.invalidateQueries(), onError: (e) => toast(errorText(t, e), 'error') }));
|
||||
return (
|
||||
<Card className="bc-stack">
|
||||
<h2 className="bc-h3">{t('admin.settings.admins')}</h2>
|
||||
{admins.data?.map((a) => (
|
||||
<div key={a.id} className="bc-between" style={{ opacity: a.active ? 1 : 0.5 }}>
|
||||
<span>
|
||||
<b>{a.name}</b> · {a.email} · {t(`admin.roles.${a.role}`)} {a.totpEnabled ? '· 2FA ✓' : ''}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => setActive.mutate({ id: a.id, active: !a.active })}>
|
||||
{t(a.active ? 'admin.settings.disable' : 'admin.settings.enable')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="form-grid">
|
||||
<Field label={t('admin.login.email')}>
|
||||
<input className="bc-input" type="text" autoCapitalize="none" value={d.email} onChange={(e) => setD({ ...d, email: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('admin.settings.name')}>
|
||||
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('admin.settings.role')}>
|
||||
<select className="bc-input" value={d.role} onChange={(e) => setD({ ...d, role: e.target.value as 'OWNER' | 'OPERATOR' })}>
|
||||
<option value="OPERATOR">{t('admin.roles.OPERATOR')}</option>
|
||||
<option value="OWNER">{t('admin.roles.OWNER')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('admin.settings.tempPassword')}>
|
||||
<input className="bc-input" type="text" value={d.password} onChange={(e) => setD({ ...d, password: e.target.value })} minLength={10} />
|
||||
</Field>
|
||||
</div>
|
||||
<div>
|
||||
<Button size="sm" disabled={!d.email || !d.name || d.password.length < 10 || create.isPending} onClick={() => create.mutate(d)}>
|
||||
{t('admin.settings.addAdmin')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge, Button, Card } from '@barev/ui';
|
||||
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
|
||||
/** Уведомления оператору в Telegram через рабочий бот: заявки, заказы, сообщения — с ответом прямо из Telegram */
|
||||
export function Telegram() {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const state = useQuery({ ...trpc.admin.auth.telegram.queryOptions(), refetchInterval: 5000 });
|
||||
const link = useMutation(
|
||||
trpc.admin.auth.telegramLink.mutationOptions({
|
||||
onSuccess: (r) => window.open(r.url, '_blank', 'noopener'),
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const settings = useMutation(
|
||||
trpc.admin.auth.telegramSettings.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const s = state.data;
|
||||
return (
|
||||
<>
|
||||
<h1 className="bc-h2">{t('admin.nav.telegram')}</h1>
|
||||
<Card className="bc-stack" style={{ maxWidth: 640 }}>
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h3">{t('admin.telegram.title', { bot: s?.bot ?? '' })}</h2>
|
||||
{s && <Badge style={{ background: s.linked ? 'var(--bc-home)' : 'var(--bc-line)' }}>{t(s.linked ? 'admin.telegram.linked' : 'admin.telegram.notLinked')}</Badge>}
|
||||
</div>
|
||||
<p style={{ margin: 0 }}>{t('admin.telegram.what')}</p>
|
||||
<ul className="bc-small" style={{ margin: 0, paddingLeft: 18, lineHeight: 1.7 }}>
|
||||
<li>{t('admin.telegram.e1')}</li>
|
||||
<li>{t('admin.telegram.e2')}</li>
|
||||
<li>{t('admin.telegram.e3')}</li>
|
||||
<li>{t('admin.telegram.e4')}</li>
|
||||
</ul>
|
||||
{s?.linked ? (
|
||||
<>
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={s.notify} onChange={(e) => settings.mutate({ notify: e.target.checked })} /> {t('admin.telegram.notify')}
|
||||
</label>
|
||||
<div className="bc-row">
|
||||
<Button variant="light" onClick={() => link.mutate()}>
|
||||
{t('admin.telegram.relink')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => settings.mutate({ unlink: true })}>
|
||||
{t('admin.telegram.unlink')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
||||
{t('admin.telegram.how')}
|
||||
</p>
|
||||
<div>
|
||||
<Button onClick={() => link.mutate()} disabled={link.isPending}>
|
||||
{t('admin.telegram.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { LOCALES } from '@barev/i18n';
|
||||
import { Badge, Button, Card, Field } from '@barev/ui';
|
||||
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
|
||||
type Kind = 'STEP' | 'DOC';
|
||||
type L = Record<string, string>;
|
||||
type Draft = { id?: string; kind: Kind; name: L; hint: L; active: boolean; inTurnkey: boolean };
|
||||
const empty = (): L => Object.fromEntries(LOCALES.map((l) => [l, '']));
|
||||
const full = (v: L) => ({ ru: v.ru ?? '', en: v.en ?? '', zh: v.zh ?? '', hy: v.hy ?? '', es: v.es ?? '' });
|
||||
|
||||
/**
|
||||
* Общий список шагов переезда и документов. Новые клиенты получают его копию в свой план;
|
||||
* у отдельного клиента шаги и документы меняются в разделе «Переезд».
|
||||
*/
|
||||
export function Templates() {
|
||||
const { t, locale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const list = useQuery(trpc.admin.leads.templates.queryOptions());
|
||||
const [d, setD] = useState<Draft | null>(null);
|
||||
const done = {
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
setD(null);
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: unknown) => toast(errorText(t, e), 'error'),
|
||||
};
|
||||
const save = useMutation(trpc.admin.leads.saveTemplate.mutationOptions(done));
|
||||
const move = useMutation(trpc.admin.leads.moveTemplate.mutationOptions({ onSuccess: () => void qc.invalidateQueries() }));
|
||||
|
||||
const section = (kind: Kind) => {
|
||||
const rows = (list.data ?? []).filter((x) => x.kind === kind);
|
||||
return (
|
||||
<Card className="bc-stack">
|
||||
<div className="bc-between">
|
||||
<div>
|
||||
<h2 className="bc-h3">{t(`admin.templates.${kind}`)}</h2>
|
||||
<div className="bc-small bc-muted">{t(`admin.templates.${kind}_hint`)}</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setD({ kind, name: empty(), hint: empty(), active: true, inTurnkey: kind === 'STEP' })}>
|
||||
{t('admin.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{d?.kind === kind && <Editor d={d} setD={setD} onSave={() => save.mutate({ ...d, name: full(d.name), hint: kind === 'DOC' ? full(d.hint) : null })} />}
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{rows.map((x, i) => {
|
||||
const name = x.name as L;
|
||||
const hint = (x.hint as L | null) ?? null;
|
||||
return (
|
||||
<tr key={x.id} style={{ opacity: x.active ? 1 : 0.5 }}>
|
||||
<td style={{ width: 70 }}>
|
||||
<button className="lead-x" disabled={i === 0} onClick={() => move.mutate({ id: x.id, dir: -1 })} aria-label="↑">
|
||||
↑
|
||||
</button>
|
||||
<button className="lead-x" disabled={i === rows.length - 1} onClick={() => move.mutate({ id: x.id, dir: 1 })} aria-label="↓">
|
||||
↓
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<b>{name[locale] || name.ru}</b>
|
||||
{(hint?.[locale] || hint?.ru) && <div className="bc-small bc-muted">{hint?.[locale] || hint?.ru}</div>}
|
||||
<div className="bc-small bc-muted">
|
||||
{LOCALES.filter((l) => l !== 'ru' && !name[l]).length > 0 ? t('admin.templates.missing', { langs: LOCALES.filter((l) => l !== 'ru' && !name[l]).join(', ').toUpperCase() }) : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{kind === 'STEP' && x.inTurnkey && <Badge style={{ background: 'var(--bc-pink)' }}>{t('admin.templates.turnkey')}</Badge>}
|
||||
{!x.active && <Badge style={{ background: 'var(--bc-line)' }}>{t('admin.templates.hidden')}</Badge>}
|
||||
</td>
|
||||
<td style={{ width: 120 }}>
|
||||
<Button size="sm" variant="ghost" onClick={() => setD({ id: x.id, kind, name: { ...empty(), ...name }, hint: { ...empty(), ...(hint ?? {}) }, active: x.active, inTurnkey: x.inTurnkey })}>
|
||||
{t('admin.edit')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="bc-h2">{t('admin.nav.templates')}</h1>
|
||||
<p className="bc-muted" style={{ margin: 0 }}>
|
||||
{t('admin.templates.sub')}
|
||||
</p>
|
||||
{section('STEP')}
|
||||
{section('DOC')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Editor({ d, setD, onSave }: { d: Draft; setD: (d: Draft | null) => void; onSave: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="form-grid" style={{ background: 'var(--bc-bg)', padding: 12, borderRadius: 16 }}>
|
||||
{LOCALES.map((l) => (
|
||||
<Field key={l} label={`${t('admin.templates.name')} · ${l.toUpperCase()}`}>
|
||||
<input className="bc-input" value={d.name[l] ?? ''} onChange={(e) => setD({ ...d, name: { ...d.name, [l]: e.target.value } })} />
|
||||
</Field>
|
||||
))}
|
||||
{d.kind === 'DOC' &&
|
||||
LOCALES.map((l) => (
|
||||
<Field key={`h-${l}`} label={`${t('admin.templates.hintField')} · ${l.toUpperCase()}`}>
|
||||
<input className="bc-input" value={d.hint[l] ?? ''} onChange={(e) => setD({ ...d, hint: { ...d.hint, [l]: e.target.value } })} />
|
||||
</Field>
|
||||
))}
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.templates.active')}
|
||||
</label>
|
||||
{d.kind === 'STEP' && (
|
||||
<label className="bc-row">
|
||||
<input type="checkbox" checked={d.inTurnkey} onChange={(e) => setD({ ...d, inTurnkey: e.target.checked })} /> {t('admin.templates.inTurnkey')}
|
||||
</label>
|
||||
)}
|
||||
<div className="full bc-row">
|
||||
<Button disabled={!d.name.ru?.trim()} onClick={onSave}>
|
||||
{t('admin.save')}
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setD(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { formatAmd } from '@barev/i18n';
|
||||
import { Badge, Button, Chip } from '@barev/ui';
|
||||
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
|
||||
const STATUSES = ['NEW', 'LINKED', 'ORDERED', 'CANCELLED'] as const;
|
||||
type Status = (typeof STATUSES)[number];
|
||||
const STATUS_TONE: Record<Status, string> = {
|
||||
NEW: 'var(--bc-perks)',
|
||||
LINKED: 'var(--bc-pink)',
|
||||
ORDERED: 'var(--bc-home)',
|
||||
CANCELLED: 'var(--bc-line)',
|
||||
};
|
||||
|
||||
/** Заявки с сайта (barevclub.com/clean): появляются сразу после отправки формы, список обновляется сам */
|
||||
export function WebRequests() {
|
||||
const { t, dt, locale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [status, setStatus] = useState<Status | undefined>();
|
||||
const list = useQuery({ ...trpc.admin.webRequests.list.queryOptions({ status }), refetchInterval: 15_000 });
|
||||
const update = useMutation(
|
||||
trpc.admin.webRequests.update.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('admin.saved'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h1 className="bc-h2">{t('admin.nav.webRequests')}</h1>
|
||||
<div className="bc-chips">
|
||||
<Chip pressed={!status} onClick={() => setStatus(undefined)}>
|
||||
{t('admin.webRequests.all')}
|
||||
</Chip>
|
||||
{STATUSES.map((s) => (
|
||||
<Chip key={s} pressed={status === s} onClick={() => setStatus(s)}>
|
||||
{t(`admin.webRequests.status_${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{list.data?.length === 0 ? (
|
||||
<p className="bc-muted">{t('admin.webRequests.empty')}</p>
|
||||
) : (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.webRequests.number')}</th>
|
||||
<th>{t('admin.webRequests.when')}</th>
|
||||
<th>{t('admin.webRequests.what')}</th>
|
||||
<th>{t('admin.webRequests.address')}</th>
|
||||
<th>{t('admin.webRequests.contact')}</th>
|
||||
<th>{t('admin.webRequests.price')}</th>
|
||||
<th>{t('admin.webRequests.status')}</th>
|
||||
<th>{t('admin.webRequests.note')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data?.map((r) => {
|
||||
const p = r.params as { cleaningType?: string; rooms?: number; bathrooms?: number; extras?: Record<string, number> };
|
||||
const extras = Object.entries(p.extras ?? {});
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<b>{r.number}</b>
|
||||
<div className="bc-small bc-muted">{t('admin.webRequests.created', { date: dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) })}</div>
|
||||
</td>
|
||||
<td className="bc-small">{dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</td>
|
||||
<td>
|
||||
<b>{t(`params.cleaning.cleaningType.${p.cleaningType ?? 'regular'}`)}</b>
|
||||
<div className="bc-small">
|
||||
{t('params.cleaning.rooms.count', { count: p.rooms ?? 1 })}, {t('params.cleaning.bathrooms.count', { count: p.bathrooms ?? 1 })}
|
||||
</div>
|
||||
{extras.length > 0 && (
|
||||
<div className="bc-small bc-muted">{extras.map(([code, qty]) => `${t(`extras.${code}.name`)}${qty > 1 ? ` × ${qty}` : ''}`).join(', ')}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="bc-small">
|
||||
{t(`districts.${r.district.code}`)}
|
||||
<div>{r.addressText}</div>
|
||||
{r.note && <div className="bc-muted">{r.note}</div>}
|
||||
</td>
|
||||
<td>
|
||||
{r.name && <b>{r.name}</b>}
|
||||
<div>
|
||||
<a href={`tel:${r.phone}`}>{r.phone}</a>
|
||||
</div>
|
||||
{r.client && (
|
||||
<div className="bc-small bc-muted">
|
||||
{r.client.user.username ? `@${r.client.user.username}` : `${r.client.user.firstName ?? ''} ${r.client.user.lastName ?? ''}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="bc-small bc-muted">{r.locale.toUpperCase()}</div>
|
||||
</td>
|
||||
<td className="mono">{formatAmd(locale, r.totalPrice)}</td>
|
||||
<td>
|
||||
<Badge style={{ background: STATUS_TONE[r.status] }}>{t(`admin.webRequests.status_${r.status}`)}</Badge>
|
||||
{r.order && (
|
||||
<div className="bc-small" style={{ marginTop: 6 }}>
|
||||
{t('admin.webRequests.order', { number: r.order.number })}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{r.status === 'CANCELLED' ? (
|
||||
<Button size="sm" variant="light" onClick={() => update.mutate({ id: r.id, status: 'REOPEN' })}>
|
||||
{t('admin.webRequests.reopen')}
|
||||
</Button>
|
||||
) : (
|
||||
r.status !== 'ORDERED' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={async () => (await confirmDialog(t('admin.webRequests.cancelConfirm', { number: r.number }))) && update.mutate({ id: r.id, status: 'CANCELLED' })}
|
||||
>
|
||||
{t('admin.webRequests.cancel')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ minWidth: 200 }}>
|
||||
<NoteEditor initial={r.adminNote ?? ''} onSave={(adminNote) => update.mutate({ id: r.id, adminNote })} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteEditor({ initial, onSave }: { initial: string; onSave: (v: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<div className="bc-stack" style={{ gap: 6 }}>
|
||||
<textarea className="bc-input" style={{ minHeight: 60 }} value={value} maxLength={1000} placeholder={t('admin.webRequests.notePlaceholder')} onChange={(e) => setValue(e.target.value)} />
|
||||
{value !== initial && (
|
||||
<Button size="sm" variant="outline" onClick={() => onSave(value)}>
|
||||
{t('admin.webRequests.saveNote')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "types": ["node", "vite/client"] },
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: true,
|
||||
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
|
||||
},
|
||||
build: { target: 'es2022', sourcemap: true },
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@barev/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/router.ts",
|
||||
"./telegram": "./src/auth/telegram.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch --clear-screen=false src/server.ts",
|
||||
"start": "tsx src/server.ts",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev:initdata": "tsx scripts/dev-initdata.ts",
|
||||
"dev:demo": "tsx scripts/dev-demo.ts",
|
||||
"dev:bo-demo": "tsx scripts/dev-backoffice-demo.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1152.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1152.0",
|
||||
"@barev/core": "workspace:*",
|
||||
"@barev/db": "workspace:*",
|
||||
"@barev/services": "workspace:*",
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@sentry/node": "^10.0.0",
|
||||
"@trpc/server": "^11.19.0",
|
||||
"dotenv": "^17.0.0",
|
||||
"fastify": "^5.12.0",
|
||||
"superjson": "^2.2.6",
|
||||
"zod": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.20.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Локальная разработка: демо-клиенты для новой админки (дашборд, список клиентов, подписки, деньги).
|
||||
* pnpm --filter @barev/api dev:bo-demo
|
||||
* Клиенты помечены тегом «демо» и telegramId 100001000+. Повторный запуск пересоздаёт их.
|
||||
* Цены условные — реальных цен подписки пока нет.
|
||||
*/
|
||||
import { getDb } from '@barev/db';
|
||||
import { env } from '../src/env.ts';
|
||||
|
||||
if (env.isProd) throw new Error('Только для разработки');
|
||||
|
||||
const db = getDb();
|
||||
const DAY = 24 * 3600_000;
|
||||
const now = Date.now();
|
||||
const TG_FROM = 100001000n;
|
||||
|
||||
// детерминированный «случай» — одинаковые данные при каждом запуске
|
||||
let seed = 42;
|
||||
const rnd = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
|
||||
const pick = <T,>(a: readonly T[]) => a[Math.floor(rnd() * a.length)]!;
|
||||
const between = (a: number, b: number) => Math.round(a + rnd() * (b - a));
|
||||
|
||||
// ── чистим прошлое демо ──
|
||||
const old = await db.user.findMany({ where: { telegramId: { gte: TG_FROM, lt: TG_FROM + 1000n } }, include: { client: true } });
|
||||
const oldClientIds = old.map((u) => u.client?.id).filter((x): x is string => !!x);
|
||||
await db.rating.deleteMany({ where: { clientId: { in: oldClientIds } } });
|
||||
await db.order.deleteMany({ where: { clientId: { in: oldClientIds } } });
|
||||
await db.subscription.deleteMany({ where: { clientId: { in: oldClientIds } } });
|
||||
await db.cardUsage.deleteMany({ where: { clientId: { in: oldClientIds } } });
|
||||
await db.auditLog.deleteMany({ where: { entityId: { in: oldClientIds } } });
|
||||
await db.client.deleteMany({ where: { id: { in: oldClientIds } } });
|
||||
await db.user.deleteMany({ where: { id: { in: old.map((u) => u.id) } } });
|
||||
await db.lead.deleteMany({ where: { contact: { startsWith: '+374 99 00' } } });
|
||||
|
||||
const [services, agencies, providers, partners, owner] = await Promise.all([
|
||||
db.service.findMany({ where: { active: true } }),
|
||||
db.agency.findMany({ where: { active: true } }),
|
||||
db.provider.findMany({ where: { active: true } }),
|
||||
db.partner.findMany({ where: { active: true } }),
|
||||
db.adminUser.findFirst({ where: { roles: { has: 'SUPERADMIN' } } }),
|
||||
]);
|
||||
|
||||
const PEOPLE: [string, string | null, string, 'ru' | 'en' | 'hy' | 'zh' | 'es'][] = [
|
||||
['Мария', 'Иванова', 'masha_iv', 'ru'],
|
||||
['Дмитрий', 'Козлов', 'dkozlov', 'ru'],
|
||||
['Sarah', 'Miller', 'sarah_m', 'en'],
|
||||
['Арсен', 'Григорян', 'arsen_g', 'hy'],
|
||||
['Екатерина', 'Смирнова', 'katya_sm', 'ru'],
|
||||
['Alex', 'Novak', 'alexnovak', 'en'],
|
||||
['Павел', 'Орлов', 'p_orlov', 'ru'],
|
||||
['Наталья', 'Белова', 'nbelova', 'ru'],
|
||||
['Tom', 'Becker', 'tbecker', 'en'],
|
||||
['Ольга', 'Лебедева', 'olga_leb', 'ru'],
|
||||
['Игорь', 'Соколов', 'igor_s', 'ru'],
|
||||
['Lucía', 'Fernández', 'lucia_f', 'es'],
|
||||
['Анна', 'Морозова', 'annamoroz', 'ru'],
|
||||
['王', '伟', 'wangwei', 'zh'],
|
||||
['Сергей', 'Волков', 'svolkov', 'ru'],
|
||||
['Елена', 'Кузнецова', 'elenakuz', 'ru'],
|
||||
['Карен', 'Арутюнян', 'karen_a', 'hy'],
|
||||
['Max', 'Fischer', 'maxfi', 'en'],
|
||||
['Юлия', 'Попова', 'yulia_pop', 'ru'],
|
||||
['Андрей', 'Зайцев', 'azaitsev', 'ru'],
|
||||
['Ирина', 'Павлова', 'irina_p', 'ru'],
|
||||
['Никита', 'Семёнов', 'nik_sem', 'ru'],
|
||||
['Emma', 'Wilson', 'emmaw', 'en'],
|
||||
['Светлана', 'Голубева', 'sveta_g', 'ru'],
|
||||
['Михаил', 'Виноградов', 'mvinogradov', 'ru'],
|
||||
['Лилит', 'Карапетян', 'lilit_k', 'hy'],
|
||||
['Артём', 'Богданов', 'artem_b', 'ru'],
|
||||
['Ксения', 'Фёдорова', 'ksu_f', 'ru'],
|
||||
['Daniel', 'Costa', 'dcosta', 'es'],
|
||||
['Виктор', 'Михайлов', 'v_mikh', 'ru'],
|
||||
['Алина', 'Комарова', 'alina_k', 'ru'],
|
||||
['Роман', 'Новиков', 'r_novikov', 'ru'],
|
||||
['Полина', 'Егорова', null, 'ru'],
|
||||
['Григорий', 'Ткаченко', 'grisha_t', 'ru'],
|
||||
['Hannah', 'Schmidt', 'hannah_s', 'en'],
|
||||
['Вера', 'Андреева', 'vera_a', 'ru'],
|
||||
['Олег', 'Николаев', 'olegnik', 'ru'],
|
||||
['Дарья', 'Ковалёва', 'dasha_k', 'ru'],
|
||||
];
|
||||
const TAGS = ['VIP', 'Кентрон', 'с детьми', 'IT', 'просит звонок', 'жалоба'];
|
||||
const UTMS = ['instagram', 'telegram_ads', 'google'];
|
||||
const PRICE = { BAREV_PLUS: 4900, ELITE: 19900 } as const;
|
||||
|
||||
let created = 0;
|
||||
for (const [i, [firstName, lastName, username, locale]] of PEOPLE.entries()) {
|
||||
const ageDays = between(0, 58);
|
||||
const createdAt = new Date(now - ageDays * DAY - between(0, 20) * 3600_000);
|
||||
const user = await db.user.create({
|
||||
data: { telegramId: TG_FROM + BigInt(i), firstName, lastName, username: username ?? undefined, locale, phone: rnd() < 0.5 ? `+374 9${between(1, 9)} ${between(100, 999)} ${between(100, 999)}` : undefined, createdAt },
|
||||
});
|
||||
const srcRoll = rnd();
|
||||
const agency = srcRoll < 0.35 && agencies.length ? pick(agencies) : null;
|
||||
const utm = !agency && srcRoll < 0.6 ? { utm_source: pick(UTMS) } : undefined;
|
||||
const tags = rnd() < 0.35 ? [pick(TAGS), ...(rnd() < 0.3 ? [pick(TAGS)] : [])] : [];
|
||||
const client = await db.client.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
cardCode: `D${String(1000 + i)}${Math.floor(rnd() * 90 + 10)}`,
|
||||
source: agency ? (rnd() < 0.7 ? 'deeplink' : 'manual_code') : 'organic',
|
||||
agencyId: agency?.id,
|
||||
agencyAttachedAt: agency ? createdAt : undefined,
|
||||
utm,
|
||||
consentAt: createdAt,
|
||||
consentVersion: '1',
|
||||
segment: rnd() < 0.4 ? 'MOVING' : 'LIVING',
|
||||
tags: [...new Set(['демо', ...tags])],
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
|
||||
// подписки
|
||||
const subRoll = rnd();
|
||||
if (agency && rnd() < 0.8) {
|
||||
const s = new Date(createdAt.getTime() + 3600_000);
|
||||
await db.subscription.create({ data: { clientId: client.id, plan: 'BAREV_PLUS', source: 'AGENCY_GIFT', agencyId: agency.id, startsAt: s, endsAt: new Date(s.getTime() + 30 * DAY), createdAt: s } });
|
||||
// часть подарков превращается в покупку
|
||||
if (rnd() < 0.45 && ageDays > 20) {
|
||||
const ps = new Date(s.getTime() + 30 * DAY);
|
||||
await db.subscription.create({
|
||||
data: { clientId: client.id, plan: 'BAREV_PLUS', source: 'PAID', amount: PRICE.BAREV_PLUS * 3, payMethod: pick(['cash', 'transfer', 'card']), grantedByAdminId: owner?.id, startsAt: ps, endsAt: new Date(ps.getTime() + 90 * DAY), createdAt: new Date(ps.getTime() - 2 * DAY) },
|
||||
});
|
||||
}
|
||||
} else if (subRoll < 0.45) {
|
||||
const plan = rnd() < 0.25 ? 'ELITE' : 'BAREV_PLUS';
|
||||
const months = pick([1, 1, 3, 6, 12]);
|
||||
const bought = new Date(createdAt.getTime() + between(0, Math.max(1, ageDays - 1)) * DAY);
|
||||
// часть подписок истекает на этой неделе
|
||||
const endsAt = rnd() < 0.25 ? new Date(now + between(1, 6) * DAY) : new Date(bought.getTime() + months * 30 * DAY);
|
||||
await db.subscription.create({
|
||||
data: { clientId: client.id, plan, source: 'PAID', amount: PRICE[plan] * months, payMethod: pick(['cash', 'transfer', 'card']), grantedByAdminId: owner?.id, startsAt: bought, endsAt, createdAt: bought },
|
||||
});
|
||||
} else if (subRoll < 0.6) {
|
||||
const s = new Date(createdAt.getTime() + DAY);
|
||||
await db.subscription.create({
|
||||
data: { clientId: client.id, plan: 'BAREV_PLUS', source: 'MANUAL', grantedByAdminId: owner?.id, note: 'Подарок за отзыв', startsAt: s, endsAt: new Date(s.getTime() + 30 * DAY), createdAt: s },
|
||||
});
|
||||
}
|
||||
|
||||
// оплаченные заказы
|
||||
const nOrders = rnd() < 0.55 ? between(1, 4) : 0;
|
||||
for (let k = 0; k < nOrders && services.length; k++) {
|
||||
const svc = pick(services);
|
||||
const at = new Date(now - between(0, Math.max(0, ageDays)) * DAY - between(1, 10) * 3600_000);
|
||||
const price = pick([12000, 15000, 18000, 25000, 30000, 45000, 60000]);
|
||||
const provider = providers.length ? pick(providers) : null;
|
||||
const order = await db.order.create({
|
||||
data: {
|
||||
clientId: client.id,
|
||||
serviceId: svc.id,
|
||||
params: {},
|
||||
scheduledAt: at,
|
||||
durationMinutes: 120,
|
||||
locale,
|
||||
basePrice: price,
|
||||
totalPrice: price,
|
||||
status: 'DONE',
|
||||
providerId: provider?.id,
|
||||
paymentStatus: 'PAID',
|
||||
paidAt: new Date(at.getTime() + 3 * 3600_000),
|
||||
completedAt: at,
|
||||
createdAt: new Date(at.getTime() - DAY),
|
||||
},
|
||||
});
|
||||
if (provider && rnd() < 0.6) {
|
||||
await db.rating.create({ data: { orderId: order.id, clientId: client.id, providerId: provider.id, score: pick([5, 5, 5, 4, 4, 3, 2]), createdAt: new Date(at.getTime() + 5 * 3600_000) } });
|
||||
}
|
||||
}
|
||||
|
||||
// сканы клубной карты у заведений
|
||||
if (partners.length && rnd() < 0.5) {
|
||||
for (let k = between(1, 5); k > 0; k--) {
|
||||
await db.cardUsage.create({ data: { clientId: client.id, partnerId: pick(partners).id, level: 'BAREV_PLUS', createdAt: new Date(now - between(0, 29) * DAY) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// заявки с сайта для воронки
|
||||
for (let k = 0; k < 14; k++) {
|
||||
await db.lead.create({
|
||||
data: {
|
||||
key: `demo-${k}`,
|
||||
name: pick(PEOPLE)[0],
|
||||
contact: `+374 99 00 ${String(k).padStart(2, '0')} 00`,
|
||||
contactType: 'PHONE',
|
||||
locale: 'ru',
|
||||
consentAt: new Date(),
|
||||
consentVersion: '1',
|
||||
status: k < 3 ? 'NEW' : 'DONE',
|
||||
createdAt: new Date(now - between(0, 29) * DAY),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// пересчёт рейтингов исполнителей
|
||||
for (const p of providers) {
|
||||
const agg = await db.rating.aggregate({ where: { providerId: p.id }, _sum: { score: true }, _count: true });
|
||||
await db.provider.update({ where: { id: p.id }, data: { ratingSum: agg._sum.score ?? 0, ratingCount: agg._count } });
|
||||
}
|
||||
|
||||
console.log(`Демо для новой админки: ${created} клиентов. Открывайте http://localhost:5176`);
|
||||
await db.$disconnect();
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Локальная разработка: демо-данные для двух сценариев клиентского приложения.
|
||||
* pnpm --filter @barev/api dev:demo
|
||||
* Анна — переезжает (план «под ключ» с шагами, документы, переписка с консьержем),
|
||||
* John — живёт в Армении с Barev+, 李明 — с Elite, Ольга — живёт без подписки; партнёры клуба.
|
||||
* Повторный запуск пересоздаёт демо-состояние этих клиентов.
|
||||
*/
|
||||
import { getDb } from '@barev/db';
|
||||
import { acceptPartnerInvite, createAppLead, createOwnerInvite, createStaffInvite, grantSubscription, savePartnerOffer, startClient, giveConsent, type Ctx } from '@barev/services';
|
||||
import { env } from '../src/env.ts';
|
||||
|
||||
if (env.isProd) throw new Error('Только для разработки');
|
||||
|
||||
const db = getDb();
|
||||
const ctx: Ctx = { db, notify: { emit: () => {} }, now: () => new Date() };
|
||||
const DAY = 24 * 3600_000;
|
||||
|
||||
async function client(id: number, firstName: string, lang: string, username?: string) {
|
||||
const res = await startClient(ctx, { id, first_name: firstName, language_code: lang, username });
|
||||
const c = await db.client.findUniqueOrThrow({ where: { id: res.clientId } });
|
||||
if (!c.consentAt) await giveConsent(ctx, c.id);
|
||||
// чистое демо-состояние
|
||||
await db.lead.deleteMany({ where: { clientId: c.id } });
|
||||
await db.clientRequest.deleteMany({ where: { clientId: c.id } });
|
||||
await db.conciergeMessage.deleteMany({ where: { clientId: c.id } });
|
||||
await db.subscription.deleteMany({ where: { clientId: c.id, source: 'MANUAL' } });
|
||||
return c.id;
|
||||
}
|
||||
|
||||
const anna = await client(100000001, 'Анна', 'ru', 'anna_moves');
|
||||
await db.client.update({ where: { id: anna }, data: { segment: 'MOVING' } });
|
||||
const moveDate = new Date(Date.now() + 40 * DAY).toISOString().slice(0, 10);
|
||||
const lead = await createAppLead(ctx, anna, { moveWhen: 'date', moveDate: new Date(`${moveDate}T00:00:00Z`), package: 'TURNKEY', comment: 'Двое взрослых и кот' });
|
||||
const notes: Record<string, { status: 'DONE' | 'IN_PROGRESS'; note?: string }> = {
|
||||
airport: { status: 'DONE', note: 'Водитель Арам встретит с табличкой, +374 91 000 000' },
|
||||
stay: { status: 'DONE', note: 'Отель Republica, 5 ночей, завтрак включён' },
|
||||
flat: { status: 'IN_PROGRESS', note: 'Подобрали 3 варианта в Кентроне, просмотры в среду с 15:00' },
|
||||
};
|
||||
for (const s of lead.steps) if (notes[s.code]) await db.leadStep.update({ where: { id: s.id }, data: notes[s.code]! });
|
||||
await db.lead.update({ where: { id: lead.id }, data: { status: 'IN_WORK' } });
|
||||
await db.leadDoc.updateMany({ where: { leadId: lead.id, code: { in: ['passport', 'passportCopies', 'photos'] } }, data: { checked: true } });
|
||||
const admin = await db.adminUser.findFirst();
|
||||
const msgs: [string, 'CLIENT' | 'OPERATOR', number][] = [
|
||||
['Здравствуйте! Можно ли взять кота в отель?', 'CLIENT', 50],
|
||||
['Добрый день! Да, в Republica можно с животными, предупредили отель 🐈', 'OPERATOR', 45],
|
||||
['Спасибо! А что с апостилем на дипломе — делать до переезда?', 'CLIENT', 5],
|
||||
];
|
||||
for (const [text, author, minAgo] of msgs) {
|
||||
await db.conciergeMessage.create({
|
||||
data: { clientId: anna, author, text, adminUserId: author === 'OPERATOR' ? admin?.id : undefined, readAt: minAgo > 10 ? new Date() : null, createdAt: new Date(Date.now() - minAgo * 60_000) },
|
||||
});
|
||||
}
|
||||
|
||||
const john = await client(100000002, 'John', 'en', 'john_yerevan');
|
||||
await db.client.update({ where: { id: john }, data: { segment: 'LIVING' } });
|
||||
await grantSubscription(ctx, { clientId: john, plan: 'BAREV_PLUS', days: 30 });
|
||||
await db.clientRequest.create({ data: { clientId: john, kind: 'SERVICE', serviceCode: 'repair', comment: 'Kitchen tap is leaking, after 6 pm please' } });
|
||||
await db.conciergeMessage.create({ data: { clientId: john, author: 'CLIENT', text: 'Hi! Can you recommend an English-speaking dentist?' } });
|
||||
|
||||
const li = await client(100000003, '李明', 'zh', 'li_ming');
|
||||
await db.client.update({ where: { id: li }, data: { segment: 'LIVING' } });
|
||||
await grantSubscription(ctx, { clientId: li, plan: 'ELITE', days: 30 });
|
||||
|
||||
const olga = await client(100000004, 'Ольга', 'ru', 'olga_local');
|
||||
await db.client.update({ where: { id: olga }, data: { segment: 'LIVING' } });
|
||||
await db.clientRequest.create({ data: { clientId: olga, kind: 'SUBSCRIPTION', plan: 'ELITE' } });
|
||||
|
||||
// новый клиент — увидит выбор сценария при первом входе
|
||||
await db.client.updateMany({ where: { user: { telegramId: 100000099n } }, data: { segment: null } });
|
||||
|
||||
if ((await db.partner.count()) === 0) {
|
||||
const offer = (ru: string, en: string, zh: string, hy: string, es: string) => ({ ru, en, zh, hy, es });
|
||||
await db.partner.createMany({
|
||||
data: [
|
||||
{ category: 'cafe', name: 'Lumen Coffee', address: 'Ереван, ул. Абовяна 18', offer: offer('−15% на кофе и завтраки', '−15% on coffee and breakfast', '咖啡和早餐 −15%', '−15% սուրճի և նախաճաշի համար', '−15% en café y desayunos') },
|
||||
{ category: 'restaurant', name: 'Dolmama', address: 'Ереван, ул. Пушкина 10', offer: offer('Десерт в подарок к ужину', 'Free dessert with dinner', '晚餐赠送甜点', 'Աղանդեր նվեր ընթրիքին', 'Postre de regalo con la cena') },
|
||||
{ category: 'restaurant', name: 'Lavash', address: 'Ереван, ул. Туманяна 21', offer: offer('−10% на всё меню', '−10% on the whole menu', '全部菜单 −10%', '−10% ամբողջ մենյուի համար', '−10% en toda la carta') },
|
||||
{ category: 'shop', name: 'Gum Market', address: 'Ереван, ул. Московяна 35', offer: offer('Подарочный набор специй', 'Free spice gift set', '赠送香料礼盒', 'Համեմունքների նվեր հավաքածու', 'Set de especias de regalo') },
|
||||
{ category: 'beauty', name: 'Barber Yerevan', address: 'Ереван, пр. Маштоца 7', offer: offer('−20% на первую стрижку', '−20% on the first haircut', '首次理发 −20%', '−20% առաջին սանրվածքի համար', '−20% en el primer corte') },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// предложения партнёров по уровням и лимитам; Арам — собственник Lumen Coffee, Гаяне — сотрудник
|
||||
const lumen =
|
||||
(await db.partner.findFirst({ where: { name: 'Lumen Coffee' } })) ??
|
||||
(await db.partner.create({ data: { category: 'cafe', name: 'Lumen Coffee', address: 'Ереван, ул. Абовяна 18', offer: { ru: 'Кофейня у Каскада', en: 'Café near the Cascade', zh: '', hy: '', es: '' } } }));
|
||||
if (lumen && (await db.partnerOffer.count({ where: { partnerId: lumen.id } })) === 0) {
|
||||
const L = (ru: string, en: string) => ({ ru, en, zh: '', hy: '', es: '' });
|
||||
await savePartnerOffer(ctx, { partnerId: lumen.id, title: L('−10% на кофе и завтраки', '−10% on coffee and breakfast'), minLevel: null, limit: 'NONE', limitCount: 1, active: true });
|
||||
await savePartnerOffer(ctx, { partnerId: lumen.id, title: L('Десерт на первый визит', 'Dessert on the first visit'), minLevel: null, limit: 'ONCE', limitCount: 1, active: true });
|
||||
await savePartnerOffer(ctx, { partnerId: lumen.id, title: L('Кофе в подарок', 'Free coffee'), minLevel: 'BAREV_PLUS', limit: 'WEEK', limitCount: 1, active: true });
|
||||
await savePartnerOffer(ctx, { partnerId: lumen.id, title: L('Завтрак для двоих', 'Breakfast for two'), minLevel: 'ELITE', limit: 'MONTH', limitCount: 2, active: true });
|
||||
}
|
||||
if (lumen && (await db.partnerMember.count({ where: { partnerId: lumen.id } })) === 0) {
|
||||
const { url } = await createOwnerInvite(ctx, lumen.id);
|
||||
await acceptPartnerInvite(ctx, { id: 300000010, first_name: 'Арам', language_code: 'ru' }, url.split('start=')[1]!);
|
||||
const staff = await createStaffInvite(ctx, 300000010n, lumen.id);
|
||||
await acceptPartnerInvite(ctx, { id: 300000011, first_name: 'Гаяне', language_code: 'ru' }, staff.url.split('start=')[1]!);
|
||||
}
|
||||
|
||||
console.log('Демо готово: Анна — переезд, John — Barev+, 李明 — Elite, Ольга — без подписки, «Новый клиент» — выбор сценария.');
|
||||
await db.$disconnect();
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Локальная разработка без Telegram: печатает ссылки на Mini App с подписанным initData.
|
||||
*
|
||||
* pnpm dev:initdata — ссылки для всех тестовых пользователей из seed
|
||||
* pnpm dev:initdata -- client 100000001 — конкретный пользователь
|
||||
*
|
||||
* Подпись делается тем же токеном, которым API проверяет initData (настоящий или DEV_*).
|
||||
*/
|
||||
import { env } from '../src/env.ts';
|
||||
import { signInitData } from '../src/auth/telegram.ts';
|
||||
|
||||
if (env.isProd) throw new Error('Только для разработки');
|
||||
|
||||
const users: { app: 'client' | 'pro'; id: number; name: string; lang: string }[] = [
|
||||
{ app: 'client', id: 100000001, name: 'Анна', lang: 'ru' },
|
||||
{ app: 'client', id: 100000002, name: 'John', lang: 'en' },
|
||||
{ app: 'client', id: 100000003, name: '李明', lang: 'zh' },
|
||||
{ app: 'client', id: 100000004, name: 'Ольга', lang: 'ru' },
|
||||
{ app: 'client', id: 100000099, name: 'Новый клиент', lang: 'ru' },
|
||||
{ app: 'pro', id: 200000001, name: 'Мария (клинер)', lang: 'ru' },
|
||||
{ app: 'pro', id: 200000003, name: 'Elena (клинер)', lang: 'es' },
|
||||
{ app: 'pro', id: 200000011, name: 'Лусине (сотрудник компании)', lang: 'hy' },
|
||||
{ app: 'pro', id: 300000001, name: 'Карен (диспетчер компании)', lang: 'ru' },
|
||||
{ app: 'pro', id: 300000010, name: 'Арам (собственник Lumen Coffee)', lang: 'ru' },
|
||||
{ app: 'pro', id: 300000011, name: 'Гаяне (сотрудник Lumen Coffee)', lang: 'ru' },
|
||||
];
|
||||
|
||||
const [appArg, idArg] = process.argv.slice(2).filter((a) => a !== '--');
|
||||
const selected = appArg && idArg ? [{ app: appArg as 'client' | 'pro', id: Number(idArg), name: `user ${idArg}`, lang: 'ru' }] : users;
|
||||
|
||||
for (const u of selected) {
|
||||
const token = u.app === 'client' ? env.clientBotToken : env.proBotToken;
|
||||
// initData живёт TG_INIT_DATA_MAX_AGE секунд — для разработки этого достаточно
|
||||
const initData = signInitData(token, { id: u.id, first_name: u.name.split(' ')[0], language_code: u.lang });
|
||||
const base = u.app === 'client' ? env.CLIENT_MINIAPP_URL : env.PRO_MINIAPP_URL;
|
||||
console.log(`${u.app.padEnd(6)} ${u.name}\n ${base}/#tgWebAppData=${encodeURIComponent(initData)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
import type { AdminUser, Db } from '@barev/db';
|
||||
import { env } from '../env.ts';
|
||||
|
||||
export const ADMIN_COOKIE = 'barev_admin';
|
||||
const SESSION_HOURS = 12;
|
||||
|
||||
/** В БД хранится только HMAC токена — утечка таблицы не даёт войти */
|
||||
const tokenHash = (token: string) => createHmac('sha256', env.ADMIN_SESSION_SECRET).update(token).digest('hex');
|
||||
|
||||
export async function createSession(db: Db, adminUserId: string) {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await db.adminSession.create({
|
||||
data: { adminUserId, tokenHash: tokenHash(token), expiresAt: new Date(Date.now() + SESSION_HOURS * 3600_000) },
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function findSession(db: Db, token: string | undefined) {
|
||||
if (!token) return null;
|
||||
const session = await db.adminSession.findUnique({ where: { tokenHash: tokenHash(token) }, include: { adminUser: true } });
|
||||
if (!session || session.expiresAt < new Date() || !session.adminUser.active) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function markVerified(db: Db, token: string) {
|
||||
await db.adminSession.update({ where: { tokenHash: tokenHash(token) }, data: { verified: true } });
|
||||
}
|
||||
|
||||
export async function destroySession(db: Db, token: string | undefined) {
|
||||
if (token) await db.adminSession.deleteMany({ where: { tokenHash: tokenHash(token) } });
|
||||
}
|
||||
|
||||
export const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict' as const,
|
||||
secure: env.isProd,
|
||||
path: '/',
|
||||
maxAge: SESSION_HOURS * 3600,
|
||||
};
|
||||
|
||||
/** Простой лимит попыток входа: 10 за 15 минут на email+ip */
|
||||
const attempts = new Map<string, { n: number; until: number }>();
|
||||
export function loginThrottled(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const a = attempts.get(key);
|
||||
if (!a || a.until < now) {
|
||||
attempts.set(key, { n: 1, until: now + 15 * 60_000 });
|
||||
return false;
|
||||
}
|
||||
a.n++;
|
||||
return a.n > 10;
|
||||
}
|
||||
|
||||
export function publicAdmin(u: AdminUser) {
|
||||
return { id: u.id, email: u.email, name: u.name, role: u.role, totpEnabled: u.totpEnabled };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Проверка подписи Telegram Mini App initData (раздел 6, критерий приёмки).
|
||||
* https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
*
|
||||
* secret = HMAC_SHA256(key = "WebAppData", data = bot_token)
|
||||
* hash = hex(HMAC_SHA256(key = secret, data = data_check_string))
|
||||
*/
|
||||
export interface TelegramInitUser {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
language_code?: string;
|
||||
}
|
||||
|
||||
export type InitDataResult =
|
||||
| { ok: true; user: TelegramInitUser; authDate: Date; startParam?: string }
|
||||
| { ok: false; reason: 'MISSING' | 'BAD_HASH' | 'EXPIRED' | 'NO_USER' | 'NO_TOKEN' };
|
||||
|
||||
function dataCheckString(params: URLSearchParams): string {
|
||||
return [...params.entries()]
|
||||
.filter(([k]) => k !== 'hash')
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function sign(botToken: string, dcs: string): string {
|
||||
const secret = createHmac('sha256', 'WebAppData').update(botToken).digest();
|
||||
return createHmac('sha256', secret).update(dcs).digest('hex');
|
||||
}
|
||||
|
||||
export function validateInitData(raw: string | undefined, botToken: string, maxAgeSeconds: number, now = new Date()): InitDataResult {
|
||||
if (!botToken) return { ok: false, reason: 'NO_TOKEN' };
|
||||
if (!raw) return { ok: false, reason: 'MISSING' };
|
||||
const params = new URLSearchParams(raw);
|
||||
const hash = params.get('hash');
|
||||
if (!hash || !/^[0-9a-f]{64}$/.test(hash)) return { ok: false, reason: 'BAD_HASH' };
|
||||
const expected = sign(botToken, dataCheckString(params));
|
||||
if (!timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expected, 'hex'))) return { ok: false, reason: 'BAD_HASH' };
|
||||
const authDate = new Date(Number(params.get('auth_date')) * 1000);
|
||||
if (!Number.isFinite(authDate.getTime()) || now.getTime() - authDate.getTime() > maxAgeSeconds * 1000) {
|
||||
return { ok: false, reason: 'EXPIRED' };
|
||||
}
|
||||
try {
|
||||
const user = JSON.parse(params.get('user') ?? '') as TelegramInitUser;
|
||||
if (!user?.id) return { ok: false, reason: 'NO_USER' };
|
||||
return { ok: true, user, authDate, startParam: params.get('start_param') ?? undefined };
|
||||
} catch {
|
||||
return { ok: false, reason: 'NO_USER' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Подписать initData — для локальной разработки и тестов */
|
||||
export function signInitData(botToken: string, user: TelegramInitUser, opts: { authDate?: Date; startParam?: string } = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('auth_date', String(Math.floor((opts.authDate ?? new Date()).getTime() / 1000)));
|
||||
params.set('query_id', 'dev');
|
||||
params.set('user', JSON.stringify(user));
|
||||
if (opts.startParam) params.set('start_param', opts.startParam);
|
||||
params.set('hash', sign(botToken, dataCheckString(params)));
|
||||
return params.toString();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** TOTP (RFC 6238, SHA-1, 6 цифр, 30 с) — совместим с Google Authenticator, 1Password и т.п. */
|
||||
|
||||
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
export function base32Encode(buf: Buffer): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let out = '';
|
||||
for (const byte of buf) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += B32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function base32Decode(s: string): Buffer {
|
||||
const clean = s.replace(/=+$/, '').toUpperCase();
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (const ch of clean) {
|
||||
const idx = B32.indexOf(ch);
|
||||
if (idx < 0) throw new Error('bad base32');
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 255);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
export const generateTotpSecret = () => base32Encode(randomBytes(20));
|
||||
|
||||
export function totpCode(secret: string, time = Date.now(), step = 30): string {
|
||||
const counter = Buffer.alloc(8);
|
||||
counter.writeBigUInt64BE(BigInt(Math.floor(time / 1000 / step)));
|
||||
const h = createHmac('sha1', base32Decode(secret)).update(counter).digest();
|
||||
const offset = h[h.length - 1]! & 0xf;
|
||||
const bin = (h.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
|
||||
return String(bin).padStart(6, '0');
|
||||
}
|
||||
|
||||
/** Допускаем ±1 шаг на рассинхрон часов */
|
||||
export function verifyTotp(secret: string, code: string, time = Date.now()): boolean {
|
||||
if (!/^\d{6}$/.test(code)) return false;
|
||||
return [-1, 0, 1].some((w) => {
|
||||
const expected = totpCode(secret, time + w * 30_000);
|
||||
return timingSafeEqual(Buffer.from(expected), Buffer.from(code));
|
||||
});
|
||||
}
|
||||
|
||||
export function otpauthUri(secret: string, account: string, issuer = 'Barev Club Admin') {
|
||||
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import path from 'node:path';
|
||||
import { config } from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
|
||||
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
API_PORT: z.coerce.number().default(4000),
|
||||
DATABASE_URL: z.string(),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
CLIENT_BOT_TOKEN: z.string().default(''),
|
||||
PRO_BOT_TOKEN: z.string().default(''),
|
||||
DEV_CLIENT_BOT_TOKEN: z.string().default(''),
|
||||
DEV_PRO_BOT_TOKEN: z.string().default(''),
|
||||
CLIENT_BOT_USERNAME: z.string().default('barev_club_bot'),
|
||||
PRO_BOT_USERNAME: z.string().default('barev_pro_bot'),
|
||||
TG_INIT_DATA_MAX_AGE: z.coerce.number().default(86400),
|
||||
LANDING_URL: z.string().default('http://localhost:3000'),
|
||||
CLIENT_MINIAPP_URL: z.string().default('http://localhost:5174'),
|
||||
PRO_MINIAPP_URL: z.string().default('http://localhost:5175'),
|
||||
ADMIN_URL: z.string().default('http://localhost:5173'),
|
||||
ADMIN_SESSION_SECRET: z.string().min(16),
|
||||
S3_ENDPOINT: z.string().default('http://localhost:9000'),
|
||||
S3_REGION: z.string().default('us-east-1'),
|
||||
S3_BUCKET: z.string().default('barev-private'),
|
||||
S3_ACCESS_KEY: z.string().default(''),
|
||||
S3_SECRET_KEY: z.string().default(''),
|
||||
SENTRY_DSN: z.string().default(''),
|
||||
});
|
||||
|
||||
const parsed = schema.parse(process.env);
|
||||
|
||||
const isProd = parsed.NODE_ENV === 'production';
|
||||
if (isProd && (!parsed.CLIENT_BOT_TOKEN || !parsed.PRO_BOT_TOKEN)) {
|
||||
throw new Error('CLIENT_BOT_TOKEN и PRO_BOT_TOKEN обязательны в production');
|
||||
}
|
||||
|
||||
export const env = {
|
||||
...parsed,
|
||||
isProd,
|
||||
/**
|
||||
* Токены для проверки initData. Без настоящих токенов в разработке используются DEV_*,
|
||||
* чтобы Mini App можно было открыть в браузере (pnpm dev:initdata).
|
||||
*/
|
||||
clientBotToken: parsed.CLIENT_BOT_TOKEN || (isProd ? '' : parsed.DEV_CLIENT_BOT_TOKEN),
|
||||
proBotToken: parsed.PRO_BOT_TOKEN || (isProd ? '' : parsed.DEV_PRO_BOT_TOKEN),
|
||||
corsOrigins: [parsed.LANDING_URL, parsed.CLIENT_MINIAPP_URL, parsed.PRO_MINIAPP_URL, parsed.ADMIN_URL],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CreateBucketCommand, GetObjectCommand, HeadBucketCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { getDb } from '@barev/db';
|
||||
import type { Ctx } from '@barev/services';
|
||||
import { createQueueNotifier } from '@barev/services/queue';
|
||||
import { env } from './env.ts';
|
||||
|
||||
export const db = getDb();
|
||||
|
||||
// ───── события → воркер ─────
|
||||
|
||||
export const notifier = createQueueNotifier(env.REDIS_URL);
|
||||
|
||||
export const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
|
||||
|
||||
// ───── приватное объектное хранилище ─────
|
||||
|
||||
export const s3 = new S3Client({
|
||||
endpoint: env.S3_ENDPOINT,
|
||||
region: env.S3_REGION,
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY },
|
||||
});
|
||||
|
||||
export async function ensureBucket() {
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: env.S3_BUCKET }));
|
||||
} catch {
|
||||
await s3.send(new CreateBucketCommand({ Bucket: env.S3_BUCKET }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Файлы шифруются на стороне хранилища (SSE); бакет приватный, доступ — только по временной ссылке */
|
||||
export async function putObject(prefix: string, body: Buffer, mime: string) {
|
||||
const ext = mime === 'image/png' ? 'png' : mime === 'image/webp' ? 'webp' : mime === 'application/pdf' ? 'pdf' : 'jpg';
|
||||
const key = `${prefix}/${randomUUID()}.${ext}`;
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env.S3_BUCKET,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: mime,
|
||||
...(env.isProd ? { ServerSideEncryption: 'AES256' as const } : {}),
|
||||
}),
|
||||
);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Временная ссылка на 5 минут */
|
||||
export function signedUrl(key: string) {
|
||||
return getSignedUrl(s3, new GetObjectCommand({ Bucket: env.S3_BUCKET, Key: key }), { expiresIn: 300 });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { adminRouter } from './routers/admin.ts';
|
||||
import { backofficeRouter } from './routers/backoffice/index.ts';
|
||||
import { clientRouter } from './routers/client.ts';
|
||||
import { proRouter } from './routers/pro.ts';
|
||||
import { publicRouter } from './routers/public.ts';
|
||||
import { router } from './trpc.ts';
|
||||
|
||||
export const appRouter = router({
|
||||
public: publicRouter,
|
||||
client: clientRouter,
|
||||
pro: proRouter,
|
||||
admin: adminRouter,
|
||||
bo: backofficeRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -0,0 +1,572 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import {
|
||||
getFlow,
|
||||
LOCALES,
|
||||
normalizeAgencyCode,
|
||||
ORDER_STATUSES,
|
||||
paramsSchemaSchema,
|
||||
payoutRulesSchema,
|
||||
pricingSchema,
|
||||
PROVIDER_ROLES,
|
||||
SETTING_KEYS,
|
||||
settingsSchema,
|
||||
slotConfigSchema,
|
||||
startOfYerevanDay,
|
||||
type ServiceFlow,
|
||||
} from '@barev/core';
|
||||
import {
|
||||
adminOrder,
|
||||
assignmentCandidates,
|
||||
getSettings,
|
||||
kanban,
|
||||
listLeads,
|
||||
conversation,
|
||||
grantSubscription,
|
||||
listClientRequests,
|
||||
listConversations,
|
||||
replyTo,
|
||||
addLeadDoc,
|
||||
addLeadStep,
|
||||
listRelocationItems,
|
||||
moveRelocationItem,
|
||||
peerInfo,
|
||||
relocationNames,
|
||||
removeLeadDoc,
|
||||
removeLeadStep,
|
||||
saveRelocationItem,
|
||||
updateLeadDoc,
|
||||
createOwnerInvite,
|
||||
savePartnerOffer,
|
||||
unreadConciergeCount,
|
||||
updateClientRequest,
|
||||
updateLeadStep,
|
||||
listWebRequests,
|
||||
markPaid,
|
||||
offerOrder,
|
||||
summary,
|
||||
transitionOrder,
|
||||
unassignOrder,
|
||||
updateLeadByAdmin,
|
||||
updateWebRequest,
|
||||
} from '@barev/services';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { ADMIN_COOKIE, cookieOptions, createSession, destroySession, loginThrottled, markVerified, publicAdmin } from '../auth/admin.ts';
|
||||
import { generateTotpSecret, otpauthUri, verifyTotp } from '../auth/totp.ts';
|
||||
import { env } from '../env.ts';
|
||||
import { signedUrl } from '../infra.ts';
|
||||
import { adminPreAuthProcedure, adminProcedure, ownerProcedure, publicProcedure, router } from '../trpc.ts';
|
||||
|
||||
const invite = (prefix: string) => `${prefix}-${randomBytes(5).toString('hex').toUpperCase()}`;
|
||||
const weekSchedule = z.array(z.object({ weekday: z.number().int().min(0).max(6), startMinute: z.number().int().min(0).max(1440), endMinute: z.number().int().min(0).max(1440) }));
|
||||
const localized = z.object({ ru: z.string().min(1), en: z.string(), zh: z.string(), hy: z.string(), es: z.string() });
|
||||
|
||||
const authRouter = router({
|
||||
/** Шаг 1: email + пароль. Сессия создаётся «непроверенной» до прохождения 2FA. */
|
||||
login: publicProcedure.input(z.object({ email: z.string().trim().min(3).max(120), password: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
||||
if (loginThrottled(`${input.email}|${ctx.req.ip}`)) throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'LOGIN_THROTTLED' });
|
||||
const admin = await ctx.db.adminUser.findUnique({ where: { email: input.email.toLowerCase() } });
|
||||
if (!admin || !admin.active || !admin.passwordHash || !(await verify(admin.passwordHash, input.password))) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'BAD_CREDENTIALS' });
|
||||
}
|
||||
const token = await createSession(ctx.db, admin.id);
|
||||
ctx.res.setCookie(ADMIN_COOKIE, token, cookieOptions);
|
||||
return { next: admin.totpEnabled ? ('totp' as const) : ('totp_setup' as const) };
|
||||
}),
|
||||
|
||||
/** 2FA обязательна: при первом входе — выдаём секрет для приложения-аутентификатора */
|
||||
totpSetup: adminPreAuthProcedure.mutation(async ({ ctx }) => {
|
||||
if (ctx.admin.totpEnabled) throw new TRPCError({ code: 'BAD_REQUEST', message: 'TOTP_ALREADY_ENABLED' });
|
||||
const secret = generateTotpSecret();
|
||||
await ctx.db.adminUser.update({ where: { id: ctx.admin.id }, data: { totpSecret: secret } });
|
||||
return { secret, uri: otpauthUri(secret, ctx.admin.email ?? ctx.admin.name) };
|
||||
}),
|
||||
|
||||
/** Шаг 2: код из приложения. При первой настройке — включает 2FA. */
|
||||
totpVerify: adminPreAuthProcedure.input(z.object({ code: z.string().regex(/^\d{6}$/) })).mutation(async ({ ctx, input }) => {
|
||||
const admin = await ctx.db.adminUser.findUniqueOrThrow({ where: { id: ctx.admin.id } });
|
||||
if (!admin.totpSecret || !verifyTotp(admin.totpSecret, input.code)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'BAD_TOTP' });
|
||||
if (!admin.totpEnabled) await ctx.db.adminUser.update({ where: { id: admin.id }, data: { totpEnabled: true } });
|
||||
await markVerified(ctx.db, ctx.token);
|
||||
return publicAdmin(admin);
|
||||
}),
|
||||
|
||||
me: adminPreAuthProcedure.query(({ ctx }) => ({ ...publicAdmin(ctx.admin), verified: ctx.session.verified })),
|
||||
/** Уведомления в Telegram через рабочий бот: статус привязки */
|
||||
telegram: adminProcedure.query(async ({ ctx }) => {
|
||||
const a = await ctx.db.adminUser.findUniqueOrThrow({ where: { id: ctx.admin.id } });
|
||||
return { linked: !!a.telegramId, notify: a.tgNotify, bot: env.PRO_BOT_USERNAME };
|
||||
}),
|
||||
/** Одноразовая ссылка t.me/<рабочий бот>?start=adm-<код>: открыл — Telegram привязан к этому администратору */
|
||||
telegramLink: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const token = randomBytes(9).toString('base64url').replace(/[-_]/g, 'x');
|
||||
await ctx.db.adminUser.update({ where: { id: ctx.admin.id }, data: { tgLinkToken: token } });
|
||||
return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` };
|
||||
}),
|
||||
telegramSettings: adminProcedure.input(z.object({ notify: z.boolean().optional(), unlink: z.boolean().optional() })).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.adminUser.update({
|
||||
where: { id: ctx.admin.id },
|
||||
data: { tgNotify: input.notify, ...(input.unlink ? { telegramId: null, tgLinkToken: null } : {}) },
|
||||
});
|
||||
}),
|
||||
|
||||
logout: publicProcedure.mutation(async ({ ctx }) => {
|
||||
await destroySession(ctx.db, ctx.req.cookies[ADMIN_COOKIE]);
|
||||
ctx.res.clearCookie(ADMIN_COOKIE, { path: '/' });
|
||||
}),
|
||||
});
|
||||
|
||||
const ordersRouter = router({
|
||||
/** ADM-1 */
|
||||
kanban: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
category: z.string().optional(),
|
||||
serviceCode: z.string().optional(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
search: z.string().max(100).optional(),
|
||||
includeCancelled: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.query(({ ctx, input }) => {
|
||||
let dateFrom: Date | undefined;
|
||||
let dateTo: Date | undefined;
|
||||
if (input.date) {
|
||||
dateFrom = startOfYerevanDay(new Date(`${input.date}T12:00:00+04:00`));
|
||||
dateTo = new Date(dateFrom.getTime() + 24 * 3600_000);
|
||||
}
|
||||
return kanban(ctx.svc, { ...input, dateFrom, dateTo });
|
||||
}),
|
||||
|
||||
/** ADM-2 */
|
||||
summary: adminProcedure.query(({ ctx }) => summary(ctx.svc)),
|
||||
|
||||
/** ADM-4 */
|
||||
get: adminProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const o = await adminOrder(ctx.db, input.id);
|
||||
const flow = getFlow(o.service.flow as ServiceFlow);
|
||||
const allowed = [...new Set(flow.transitions.filter((t) => t.from === o.status && t.by.includes('ADMIN')).map((t) => t.to))];
|
||||
return {
|
||||
...o,
|
||||
client: { ...o.client, user: { ...o.client.user, telegramId: o.client.user.telegramId.toString() } },
|
||||
adminTransitions: allowed,
|
||||
flowSteps: flow.steps,
|
||||
canCancel: o.status !== 'CANCELLED' && !flow.done.includes(o.status),
|
||||
};
|
||||
}),
|
||||
|
||||
/** ADM-3 */
|
||||
candidates: adminProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => assignmentCandidates(ctx.svc, input.id)),
|
||||
|
||||
assign: adminProcedure
|
||||
.input(z.object({ orderId: z.string(), target: z.object({ kind: z.enum(['provider', 'company']), id: z.string() }) }))
|
||||
.mutation(({ ctx, input }) => offerOrder(ctx.svc, { orderId: input.orderId, adminId: ctx.admin.id, target: input.target })),
|
||||
|
||||
unassign: adminProcedure.input(z.object({ orderId: z.string() })).mutation(({ ctx, input }) => unassignOrder(ctx.svc, { orderId: input.orderId, adminId: ctx.admin.id })),
|
||||
|
||||
/** Ручная смена статуса — с теми же проверками state machine */
|
||||
transition: adminProcedure
|
||||
.input(z.object({ orderId: z.string(), to: z.enum(ORDER_STATUSES), comment: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await transitionOrder(ctx.svc, { orderId: input.orderId, to: input.to, actor: 'ADMIN', actorId: ctx.admin.id, comment: input.comment });
|
||||
}),
|
||||
|
||||
/** Отмена оператором; удержание задаётся вручную (по умолчанию 0) */
|
||||
cancel: adminProcedure
|
||||
.input(z.object({ orderId: z.string(), reason: z.string().max(500).optional(), fee: z.number().int().min(0).default(0) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await transitionOrder(ctx.svc, {
|
||||
orderId: input.orderId,
|
||||
to: 'CANCELLED',
|
||||
actor: 'ADMIN',
|
||||
actorId: ctx.admin.id,
|
||||
comment: input.reason,
|
||||
data: { cancelFee: input.fee, cancelReason: input.reason },
|
||||
});
|
||||
await ctx.db.orderOffer.updateMany({ where: { orderId: input.orderId, status: 'OFFERED' }, data: { status: 'REVOKED' } });
|
||||
}),
|
||||
|
||||
/** PAY-1 */
|
||||
markPaid: adminProcedure
|
||||
.input(z.object({ orderId: z.string(), paymentMethodId: z.string().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await markPaid(ctx.svc, { orderId: input.orderId, by: { type: 'ADMIN', id: ctx.admin.id }, paymentMethodId: input.paymentMethodId });
|
||||
}),
|
||||
|
||||
fileUrl: adminProcedure.input(z.object({ fileId: z.string() })).query(async ({ ctx, input }) => {
|
||||
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId } });
|
||||
if (!file || file.deletedAt) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'ADMIN', actorId: ctx.admin.id } });
|
||||
return { url: await signedUrl(file.storageKey) };
|
||||
}),
|
||||
});
|
||||
|
||||
const servicesRouter = router({
|
||||
list: adminProcedure.query(({ ctx }) =>
|
||||
ctx.db.category.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { services: { orderBy: { sortOrder: 'asc' }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } } },
|
||||
}),
|
||||
),
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
active: z.boolean().optional(),
|
||||
lateCancelFeePct: z.number().int().min(0).max(100).optional(),
|
||||
pricing: pricingSchema.optional(),
|
||||
paramsSchema: paramsSchemaSchema.optional(),
|
||||
slotConfig: slotConfigSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
return ctx.db.service.update({ where: { id }, data });
|
||||
}),
|
||||
updateCategory: ownerProcedure
|
||||
.input(z.object({ id: z.string(), barevPlusDiscountPct: z.number().int().min(0).max(100).optional(), eliteDiscountPct: z.number().int().min(0).max(100).optional() }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
return ctx.db.category.update({ where: { id }, data });
|
||||
}),
|
||||
});
|
||||
|
||||
const providerInput = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
phone: z.string().max(30).optional().nullable(),
|
||||
role: z.enum(PROVIDER_ROLES),
|
||||
companyId: z.string().optional().nullable(),
|
||||
languages: z.array(z.enum(LOCALES)).min(1),
|
||||
districtIds: z.array(z.string()),
|
||||
schedule: weekSchedule,
|
||||
rateType: z.enum(['FIXED', 'PERCENT']),
|
||||
rateValue: z.number().int().min(0),
|
||||
/** пресет ставок; null — индивидуальная ставка */
|
||||
payoutPresetId: z.string().optional().nullable(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/** Пресеты ставок (джун / мидл / прайват): фикс или % по каждой строке цены */
|
||||
const presetsRouter = router({
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
const presets = await ctx.db.payoutPreset.findMany({ orderBy: [{ role: 'asc' }, { sortOrder: 'asc' }], include: { _count: { select: { providers: true } } } });
|
||||
return presets.map((p) => ({ ...p, rules: payoutRulesSchema.parse(p.rules), providers: p._count.providers }));
|
||||
}),
|
||||
save: ownerProcedure
|
||||
.input(z.object({ id: z.string().optional(), name: z.string().min(1).max(60), role: z.enum(PROVIDER_ROLES), rules: payoutRulesSchema, active: z.boolean(), sortOrder: z.number().int().default(0) }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
return id ? ctx.db.payoutPreset.update({ where: { id }, data }) : ctx.db.payoutPreset.create({ data });
|
||||
}),
|
||||
});
|
||||
|
||||
const providersRouter = router({
|
||||
list: adminProcedure.query(({ ctx }) =>
|
||||
ctx.db.provider.findMany({
|
||||
orderBy: [{ active: 'desc' }, { name: 'asc' }],
|
||||
include: { company: { select: { id: true, name: true } }, payoutPreset: { select: { id: true, name: true } }, districts: { include: { district: true } }, schedule: true },
|
||||
}),
|
||||
),
|
||||
create: adminProcedure.input(providerInput).mutation(({ ctx, input }) => {
|
||||
const { districtIds, schedule, ...data } = input;
|
||||
return ctx.db.provider.create({
|
||||
data: {
|
||||
...data,
|
||||
inviteCode: invite('PRO'),
|
||||
districts: { create: districtIds.map((districtId) => ({ districtId })) },
|
||||
schedule: { create: schedule },
|
||||
},
|
||||
});
|
||||
}),
|
||||
update: adminProcedure.input(providerInput.extend({ id: z.string() })).mutation(({ ctx, input }) => {
|
||||
const { id, districtIds, schedule, ...data } = input;
|
||||
return ctx.db.provider.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
districts: { deleteMany: {}, create: districtIds.map((districtId) => ({ districtId })) },
|
||||
schedule: { deleteMany: {}, create: schedule },
|
||||
},
|
||||
});
|
||||
}),
|
||||
/** Новый инвайт — старая привязка к Telegram снимается */
|
||||
resetInvite: adminProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) =>
|
||||
ctx.db.provider.update({ where: { id: input.id }, data: { inviteCode: invite('PRO'), userId: null } }),
|
||||
),
|
||||
});
|
||||
|
||||
const companyInput = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
roles: z.array(z.enum(PROVIDER_ROLES)).min(1),
|
||||
rateType: z.enum(['FIXED', 'PERCENT']),
|
||||
rateValue: z.number().int().min(0),
|
||||
contactPhone: z.string().max(30).optional().nullable(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const companiesRouter = router({
|
||||
list: adminProcedure.query(({ ctx }) =>
|
||||
ctx.db.providerCompany.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { employees: { select: { id: true, name: true, active: true } }, dispatchers: { include: { user: { select: { firstName: true, username: true } } } } },
|
||||
}),
|
||||
),
|
||||
create: adminProcedure.input(companyInput).mutation(({ ctx, input }) => ctx.db.providerCompany.create({ data: { ...input, inviteCode: invite('CO') } })),
|
||||
update: adminProcedure.input(companyInput.extend({ id: z.string() })).mutation(({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
return ctx.db.providerCompany.update({ where: { id }, data });
|
||||
}),
|
||||
resetInvite: adminProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) =>
|
||||
ctx.db.providerCompany.update({ where: { id: input.id }, data: { inviteCode: invite('CO') } }),
|
||||
),
|
||||
removeDispatcher: adminProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => ctx.db.companyDispatcher.delete({ where: { id: input.id } })),
|
||||
});
|
||||
|
||||
const agencyInput = z.object({
|
||||
code: z.string().min(2).max(32),
|
||||
name: z.string().min(1).max(100),
|
||||
commissionPct: z.number().int().min(0).max(100),
|
||||
contact: z.string().max(200).optional().nullable(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const agenciesRouter = router({
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
const agencies = await ctx.db.agency.findMany({ orderBy: { name: 'asc' }, include: { _count: { select: { clients: true } } } });
|
||||
return agencies.map((a) => ({ ...a, leads: a._count.clients }));
|
||||
}),
|
||||
create: ownerProcedure.input(agencyInput).mutation(({ ctx, input }) => {
|
||||
const code = normalizeAgencyCode(input.code);
|
||||
if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' });
|
||||
return ctx.db.agency.create({ data: { ...input, code } });
|
||||
}),
|
||||
update: ownerProcedure.input(agencyInput.extend({ id: z.string() })).mutation(({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
const code = normalizeAgencyCode(data.code);
|
||||
if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' });
|
||||
return ctx.db.agency.update({ where: { id }, data: { ...data, code } });
|
||||
}),
|
||||
});
|
||||
|
||||
const clientsRouter = router({
|
||||
/** ADM-6 (базовый): список, источник, статус Barev+ */
|
||||
list: adminProcedure.input(z.object({ search: z.string().max(100).optional() })).query(async ({ ctx, input }) => {
|
||||
const q = input.search?.trim();
|
||||
const now = ctx.svc.now();
|
||||
const clients = await ctx.db.client.findMany({
|
||||
where: q ? { user: { OR: [{ firstName: { contains: q, mode: 'insensitive' } }, { username: { contains: q, mode: 'insensitive' } }, { phone: { contains: q } }] } } : {},
|
||||
include: {
|
||||
user: { select: { firstName: true, lastName: true, username: true, locale: true } },
|
||||
agency: { select: { name: true, code: true } },
|
||||
subscriptions: { where: { status: 'ACTIVE', startsAt: { lte: now }, endsAt: { gt: now } }, orderBy: { plan: 'desc' }, take: 1 },
|
||||
_count: { select: { orders: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return clients.map((c) => ({
|
||||
id: c.id,
|
||||
user: c.user,
|
||||
agency: c.agency,
|
||||
source: c.source,
|
||||
utm: c.utm,
|
||||
consented: !!c.consentAt,
|
||||
barevPlusUntil: c.subscriptions[0]?.endsAt ?? null,
|
||||
plan: c.subscriptions[0]?.plan ?? null,
|
||||
segment: c.segment,
|
||||
orders: c._count.orders,
|
||||
createdAt: c.createdAt,
|
||||
}));
|
||||
}),
|
||||
/** Выдать Barev+ или Elite вручную (онлайн-оплаты пока нет) */
|
||||
grantBarevPlus: ownerProcedure
|
||||
.input(z.object({ clientId: z.string(), days: z.number().int().min(1).max(366), plan: z.enum(['BAREV_PLUS', 'ELITE']).default('BAREV_PLUS') }))
|
||||
.mutation(({ ctx, input }) => grantSubscription(ctx.svc, input)),
|
||||
});
|
||||
|
||||
const dictionariesRouter = router({
|
||||
/** ссылки для инвайтов и агентств */
|
||||
config: adminProcedure.query(() => ({ clientBot: env.CLIENT_BOT_USERNAME, proBot: env.PRO_BOT_USERNAME, landingUrl: env.LANDING_URL })),
|
||||
districts: adminProcedure.query(({ ctx }) => ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } })),
|
||||
paymentMethods: adminProcedure.query(({ ctx }) => ctx.db.paymentMethod.findMany({ orderBy: { sortOrder: 'asc' } })),
|
||||
savePaymentMethod: ownerProcedure
|
||||
.input(z.object({ id: z.string().optional(), code: z.string().regex(/^[a-z0-9_]+$/), name: localized, active: z.boolean(), sortOrder: z.number().int() }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
return id ? ctx.db.paymentMethod.update({ where: { id }, data }) : ctx.db.paymentMethod.create({ data });
|
||||
}),
|
||||
});
|
||||
|
||||
const settingsRouter = router({
|
||||
get: adminProcedure.query(({ ctx }) => getSettings(ctx.db)),
|
||||
update: ownerProcedure.input(settingsSchema.partial()).mutation(async ({ ctx, input }) => {
|
||||
for (const key of SETTING_KEYS) {
|
||||
if (input[key] === undefined) continue;
|
||||
await ctx.db.setting.upsert({ where: { key }, create: { key, value: input[key] }, update: { value: input[key] } });
|
||||
}
|
||||
return getSettings(ctx.db);
|
||||
}),
|
||||
});
|
||||
|
||||
const adminsRouter = router({
|
||||
list: ownerProcedure.query(async ({ ctx }) => (await ctx.db.adminUser.findMany({ orderBy: { createdAt: 'asc' } })).map((a) => ({ ...publicAdmin(a), active: a.active }))),
|
||||
create: ownerProcedure
|
||||
.input(z.object({ email: z.string().trim().min(3).max(120), name: z.string().min(1), role: z.enum(['OWNER', 'OPERATOR']), password: z.string().min(10) }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
publicAdmin(
|
||||
await ctx.db.adminUser.create({ data: { email: input.email.toLowerCase(), name: input.name, role: input.role, roles: input.role === 'OWNER' ? ['SUPERADMIN'] : ['SUPPORT', 'CONCIERGE'], passwordHash: await hash(input.password) } }),
|
||||
),
|
||||
),
|
||||
setActive: ownerProcedure.input(z.object({ id: z.string(), active: z.boolean() })).mutation(async ({ ctx, input }) => {
|
||||
if (input.id === ctx.admin.id) throw new TRPCError({ code: 'BAD_REQUEST', message: 'CANNOT_DISABLE_SELF' });
|
||||
await ctx.db.adminUser.update({ where: { id: input.id }, data: { active: input.active } });
|
||||
if (!input.active) await ctx.db.adminSession.deleteMany({ where: { adminUserId: input.id } });
|
||||
}),
|
||||
});
|
||||
|
||||
/** Заявки с сайта (barevclub.com/clean) */
|
||||
const webRequestsRouter = router({
|
||||
list: adminProcedure
|
||||
.input(z.object({ status: z.enum(['NEW', 'LINKED', 'ORDERED', 'CANCELLED']).optional() }))
|
||||
.query(({ ctx, input }) => listWebRequests(ctx.svc, input)),
|
||||
/** счётчик для бейджа в меню: заявки, по которым ещё нет заказа */
|
||||
openCount: adminProcedure.query(({ ctx }) => ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } })),
|
||||
update: adminProcedure
|
||||
.input(z.object({ id: z.string(), status: z.enum(['CANCELLED', 'REOPEN']).optional(), adminNote: z.string().max(1000).optional() }))
|
||||
.mutation(({ ctx, input }) => updateWebRequest(ctx.svc, input)),
|
||||
});
|
||||
|
||||
/** Заявки из квиза на главной («Переезжаю в Армению») */
|
||||
const LEAD_STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
|
||||
const leadsRouter = router({
|
||||
list: adminProcedure.input(z.object({ status: z.enum(LEAD_STATUSES).optional() })).query(({ ctx, input }) => listLeads(ctx.svc, input)),
|
||||
openCount: adminProcedure.query(({ ctx }) => ctx.db.lead.count({ where: { status: 'NEW' } })),
|
||||
update: adminProcedure
|
||||
.input(z.object({ id: z.string(), status: z.enum(LEAD_STATUSES).optional(), adminNote: z.string().max(1000).optional() }))
|
||||
.mutation(({ ctx, input }) => updateLeadByAdmin(ctx.svc, input)),
|
||||
/** шаг плана переезда: статус, комментарий для клиента, своё название */
|
||||
updateStep: adminProcedure
|
||||
.input(z.object({ stepId: z.string(), status: z.enum(['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED']).optional(), note: z.string().max(500).optional(), title: z.string().max(120).optional() }))
|
||||
.mutation(({ ctx, input }) => updateLeadStep(ctx.svc, input)),
|
||||
addStep: adminProcedure
|
||||
.input(z.object({ leadId: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() }))
|
||||
.mutation(({ ctx, input }) => addLeadStep(ctx.svc, input)),
|
||||
removeStep: adminProcedure.input(z.object({ stepId: z.string() })).mutation(({ ctx, input }) => removeLeadStep(ctx.svc, input.stepId)),
|
||||
/** документы клиента */
|
||||
addDoc: adminProcedure
|
||||
.input(z.object({ leadId: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() }))
|
||||
.mutation(({ ctx, input }) => addLeadDoc(ctx.svc, input)),
|
||||
updateDoc: adminProcedure
|
||||
.input(z.object({ docId: z.string(), title: z.string().max(120).optional(), checked: z.boolean().optional() }))
|
||||
.mutation(({ ctx, input }) => updateLeadDoc(ctx.svc, input)),
|
||||
removeDoc: adminProcedure.input(z.object({ docId: z.string() })).mutation(({ ctx, input }) => removeLeadDoc(ctx.svc, input.docId)),
|
||||
/** общий список шагов и документов */
|
||||
names: adminProcedure.query(({ ctx }) => relocationNames(ctx.db)),
|
||||
templates: adminProcedure.query(({ ctx }) => listRelocationItems(ctx.svc)),
|
||||
saveTemplate: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
kind: z.enum(['STEP', 'DOC']),
|
||||
name: localized,
|
||||
hint: z.object({ ru: z.string(), en: z.string(), zh: z.string(), hy: z.string(), es: z.string() }).optional().nullable(),
|
||||
active: z.boolean(),
|
||||
inTurnkey: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ ctx, input }) => saveRelocationItem(ctx.svc, input)),
|
||||
moveTemplate: adminProcedure.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(({ ctx, input }) => moveRelocationItem(ctx.svc, input)),
|
||||
});
|
||||
|
||||
/** Заявки из приложения: подписка или «перезвоните» по услуге */
|
||||
const requestsRouter = router({
|
||||
list: adminProcedure.input(z.object({ status: z.enum(['NEW', 'DONE', 'CANCELLED']).optional() })).query(({ ctx, input }) => listClientRequests(ctx.svc, input)),
|
||||
openCount: adminProcedure.query(({ ctx }) => ctx.db.clientRequest.count({ where: { status: 'NEW' } })),
|
||||
update: adminProcedure
|
||||
.input(z.object({ id: z.string(), status: z.enum(['NEW', 'DONE', 'CANCELLED']).optional(), adminNote: z.string().max(1000).optional() }))
|
||||
.mutation(({ ctx, input }) => updateClientRequest(ctx.svc, input)),
|
||||
/** включить подписку по заявке: создаёт подписку и закрывает заявку */
|
||||
activate: adminProcedure
|
||||
.input(z.object({ id: z.string(), days: z.number().int().min(1).max(366) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const r = await ctx.db.clientRequest.findUniqueOrThrow({ where: { id: input.id } });
|
||||
if (r.kind !== 'SUBSCRIPTION' || !r.plan) throw new TRPCError({ code: 'BAD_REQUEST' });
|
||||
return grantSubscription(ctx.svc, { clientId: r.clientId, plan: r.plan, days: input.days, requestId: r.id });
|
||||
}),
|
||||
});
|
||||
|
||||
/** Переписка: клиенты пишут клиентскому боту, исполнители — рабочему; оператор отвечает отсюда */
|
||||
const peerInput = z.union([z.object({ clientId: z.string() }), z.object({ providerId: z.string() })]);
|
||||
const conciergeRouter = router({
|
||||
list: adminProcedure.query(({ ctx }) => listConversations(ctx.svc)),
|
||||
unreadCount: adminProcedure.query(({ ctx }) => unreadConciergeCount(ctx.svc)),
|
||||
get: adminProcedure.input(peerInput).query(async ({ ctx, input }) => ({ peer: await peerInfo(ctx.svc, input), messages: await conversation(ctx.svc, input) })),
|
||||
reply: adminProcedure
|
||||
.input(z.intersection(peerInput, z.object({ text: z.string().min(1).max(4000) })))
|
||||
.mutation(({ ctx, input }) => replyTo(ctx.svc, { ...input, adminUserId: ctx.admin.id })),
|
||||
});
|
||||
|
||||
/** Партнёры клуба: скидки и подарки по клубной карте */
|
||||
const partnersRouter = router({
|
||||
list: adminProcedure.query(({ ctx }) =>
|
||||
ctx.db.partner.findMany({
|
||||
orderBy: [{ active: 'desc' }, { name: 'asc' }],
|
||||
include: {
|
||||
offers: { orderBy: { sortOrder: 'asc' } },
|
||||
members: { include: { user: { select: { firstName: true, lastName: true, username: true } } } },
|
||||
_count: { select: { usages: { where: { offerId: null } } } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
/** одноразовая ссылка для собственника: открыл рабочий бот — стал собственником партнёра */
|
||||
ownerInvite: adminProcedure.input(z.object({ partnerId: z.string() })).mutation(({ ctx, input }) => createOwnerInvite(ctx.svc, input.partnerId)),
|
||||
saveOffer: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
partnerId: z.string(),
|
||||
title: localized,
|
||||
minLevel: z.enum(['BAREV_PLUS', 'ELITE']).nullable(),
|
||||
limit: z.enum(['NONE', 'ONCE', 'WEEK', 'MONTH']),
|
||||
limitCount: z.number().int().min(1).max(100),
|
||||
active: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ ctx, input }) => savePartnerOffer(ctx.svc, input)),
|
||||
removeMember: adminProcedure.input(z.object({ memberId: z.string() })).mutation(({ ctx, input }) => ctx.db.partnerMember.delete({ where: { id: input.memberId } })),
|
||||
save: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
category: z.enum(['cafe', 'restaurant', 'shop', 'beauty', 'other']),
|
||||
name: z.string().min(1).max(100),
|
||||
offer: localized,
|
||||
address: z.string().max(200).optional().nullable(),
|
||||
active: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
return id ? ctx.db.partner.update({ where: { id }, data }) : ctx.db.partner.create({ data });
|
||||
}),
|
||||
});
|
||||
|
||||
export const adminRouter = router({
|
||||
webRequests: webRequestsRouter,
|
||||
leads: leadsRouter,
|
||||
requests: requestsRouter,
|
||||
concierge: conciergeRouter,
|
||||
partners: partnersRouter,
|
||||
auth: authRouter,
|
||||
orders: ordersRouter,
|
||||
services: servicesRouter,
|
||||
providers: providersRouter,
|
||||
presets: presetsRouter,
|
||||
companies: companiesRouter,
|
||||
agencies: agenciesRouter,
|
||||
clients: clientsRouter,
|
||||
dictionaries: dictionariesRouter,
|
||||
settings: settingsRouter,
|
||||
admins: adminsRouter,
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { ADMIN_COOKIE, cookieOptions, createSession, destroySession, markVerified } from '../../auth/admin.ts';
|
||||
import { env } from '../../env.ts';
|
||||
import { adminPreAuthProcedure, publicProcedure, router, staffProcedure, superProcedure } from '../../trpc.ts';
|
||||
|
||||
const LOGIN_TTL_MS = 5 * 60_000;
|
||||
const code = () => randomBytes(9).toString('base64url').replace(/[-_]/g, 'x');
|
||||
|
||||
export const STAFF_ROLES = ['SUPERADMIN', 'SUPPORT', 'CONCIERGE', 'SALES'] as const;
|
||||
|
||||
function staffView(a: { id: string; name: string; roles: string[]; telegramId: bigint | null; active: boolean; createdAt: Date; tgLinkToken?: string | null }) {
|
||||
return { id: a.id, name: a.name, roles: a.roles, telegramLinked: !!a.telegramId, active: a.active, createdAt: a.createdAt };
|
||||
}
|
||||
|
||||
async function openSession(ctx: { db: Parameters<typeof createSession>[0]; res: { setCookie: (n: string, v: string, o: typeof cookieOptions) => unknown } }, adminUserId: string) {
|
||||
const token = await createSession(ctx.db, adminUserId);
|
||||
await markVerified(ctx.db, token);
|
||||
ctx.res.setCookie(ADMIN_COOKIE, token, cookieOptions);
|
||||
}
|
||||
|
||||
export const boAuthRouter = router({
|
||||
/** Шаг 1: страница получает ссылку в рабочий бот */
|
||||
start: publicProcedure.mutation(async ({ ctx }) => {
|
||||
const req = await ctx.db.adminLoginRequest.create({
|
||||
data: { code: code(), pollKey: randomBytes(16).toString('hex'), expiresAt: new Date(Date.now() + LOGIN_TTL_MS) },
|
||||
});
|
||||
return { code: req.code, pollKey: req.pollKey, url: `https://t.me/${env.PRO_BOT_USERNAME}?start=lg-${req.code}`, expiresAt: req.expiresAt, devLogin: !env.isProd };
|
||||
}),
|
||||
|
||||
/** Шаг 2: страница опрашивает, подтвердил ли бот вход */
|
||||
poll: publicProcedure.input(z.object({ code: z.string().max(40), pollKey: z.string().max(64) })).mutation(async ({ ctx, input }) => {
|
||||
const req = await ctx.db.adminLoginRequest.findUnique({ where: { code: input.code } });
|
||||
if (!req || req.pollKey !== input.pollKey) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
if (req.consumedAt) return { status: 'expired' as const };
|
||||
if (!req.approvedAt || !req.adminUserId) return { status: req.expiresAt < new Date() ? ('expired' as const) : ('pending' as const) };
|
||||
await ctx.db.adminLoginRequest.update({ where: { id: req.id }, data: { consumedAt: new Date() } });
|
||||
await openSession(ctx, req.adminUserId);
|
||||
return { status: 'ok' as const };
|
||||
}),
|
||||
|
||||
/** Только локально: войти любым сотрудником без Telegram */
|
||||
devUsers: publicProcedure.query(async ({ ctx }) => {
|
||||
if (env.isProd) return [];
|
||||
const list = await ctx.db.adminUser.findMany({ where: { active: true, NOT: { roles: { isEmpty: true } } }, orderBy: { createdAt: 'asc' } });
|
||||
return list.map((a) => ({ id: a.id, name: a.name, roles: a.roles }));
|
||||
}),
|
||||
devLogin: publicProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
if (env.isProd) throw new TRPCError({ code: 'FORBIDDEN' });
|
||||
await openSession(ctx, input.id);
|
||||
}),
|
||||
|
||||
me: adminPreAuthProcedure.query(({ ctx }) => ({
|
||||
id: ctx.admin.id,
|
||||
name: ctx.admin.name,
|
||||
roles: ctx.admin.roles,
|
||||
verified: ctx.session.verified,
|
||||
telegramLinked: !!ctx.admin.telegramId,
|
||||
})),
|
||||
|
||||
logout: publicProcedure.mutation(async ({ ctx }) => {
|
||||
await destroySession(ctx.db, ctx.req.cookies[ADMIN_COOKIE]);
|
||||
ctx.res.clearCookie(ADMIN_COOKIE, { path: '/' });
|
||||
}),
|
||||
|
||||
/** Привязать свой Telegram (нужно для входа и уведомлений) */
|
||||
telegramLink: staffProcedure.mutation(async ({ ctx }) => {
|
||||
const token = code();
|
||||
await ctx.db.adminUser.update({ where: { id: ctx.admin.id }, data: { tgLinkToken: token } });
|
||||
return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` };
|
||||
}),
|
||||
});
|
||||
|
||||
/** Команда: суперадмин добавляет людей и раздаёт роли */
|
||||
export const boTeamRouter = router({
|
||||
list: superProcedure.query(async ({ ctx }) => (await ctx.db.adminUser.findMany({ orderBy: { createdAt: 'asc' } })).filter((a) => a.roles.length > 0 || a.active).map(staffView)),
|
||||
|
||||
/** Новый сотрудник: имя и роли; в ответе ссылка, по которой он привяжет свой Telegram */
|
||||
create: superProcedure.input(z.object({ name: z.string().trim().min(1).max(80), roles: z.array(z.enum(STAFF_ROLES)).min(1) })).mutation(async ({ ctx, input }) => {
|
||||
const token = code();
|
||||
const a = await ctx.db.adminUser.create({ data: { name: input.name, roles: input.roles, tgLinkToken: token } });
|
||||
return { ...staffView(a), linkUrl: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` };
|
||||
}),
|
||||
|
||||
update: superProcedure
|
||||
.input(z.object({ id: z.string(), name: z.string().trim().min(1).max(80).optional(), roles: z.array(z.enum(STAFF_ROLES)).optional(), active: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.id === ctx.admin.id && (input.active === false || (input.roles && !input.roles.includes('SUPERADMIN')))) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CANNOT_DEMOTE_SELF' });
|
||||
}
|
||||
const a = await ctx.db.adminUser.update({ where: { id: input.id }, data: { name: input.name, roles: input.roles, active: input.active } });
|
||||
if (input.active === false) await ctx.db.adminSession.deleteMany({ where: { adminUserId: a.id } });
|
||||
return staffView(a);
|
||||
}),
|
||||
|
||||
/** Новая ссылка для привязки Telegram (старая перестаёт работать) */
|
||||
linkUrl: superProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const token = code();
|
||||
await ctx.db.adminUser.update({ where: { id: input.id }, data: { tgLinkToken: token } });
|
||||
return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { activeSubscription, DAY } from '@barev/core';
|
||||
import type { Db, Prisma } from '@barev/db';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { rolesProcedure, router, staffProcedure, superProcedure } from '../../trpc.ts';
|
||||
|
||||
const PLANS = ['BAREV_PLUS', 'ELITE'] as const;
|
||||
const PAY_METHODS = ['cash', 'transfer', 'card', 'link'] as const;
|
||||
|
||||
/** Фильтры списка клиентов — их же сохраняем как сегмент */
|
||||
export const clientFilters = z.object({
|
||||
search: z.string().trim().max(100).optional(),
|
||||
/** none — без активной подписки, any — с любой активной */
|
||||
plan: z.enum(['any', 'none', 'BAREV_PLUS', 'ELITE']).optional(),
|
||||
/** как получена текущая подписка */
|
||||
subSource: z.enum(['PAID', 'MANUAL', 'AGENCY_GIFT']).optional(),
|
||||
/** откуда пришёл: organic | deeplink | manual_code */
|
||||
source: z.string().max(20).optional(),
|
||||
agencyId: z.string().optional(),
|
||||
tag: z.string().max(40).optional(),
|
||||
/** подписка истекает в ближайшие N дней */
|
||||
expiringDays: z.number().int().min(1).max(90).optional(),
|
||||
segment: z.enum(['MOVING', 'LIVING']).optional(),
|
||||
/** не платили ни разу / платили */
|
||||
paying: z.enum(['yes', 'no']).optional(),
|
||||
});
|
||||
export type ClientFilters = z.infer<typeof clientFilters>;
|
||||
|
||||
const clientInclude = {
|
||||
user: true,
|
||||
agency: { select: { id: true, name: true, code: true } },
|
||||
subscriptions: { orderBy: { endsAt: 'desc' } },
|
||||
} satisfies Prisma.ClientInclude;
|
||||
type ClientRow = Prisma.ClientGetPayload<{ include: typeof clientInclude }>;
|
||||
|
||||
/** Деньги по клиентам: оплаченные заказы + подписки − возвраты */
|
||||
async function revenueByClient(db: Db, clientIds: string[]) {
|
||||
const [orders, subs] = await Promise.all([
|
||||
db.order.groupBy({ by: ['clientId'], where: { clientId: { in: clientIds }, paymentStatus: 'PAID' }, _sum: { totalPrice: true }, _count: true }),
|
||||
db.subscription.groupBy({ by: ['clientId'], where: { clientId: { in: clientIds } }, _sum: { amount: true, refundAmount: true } }),
|
||||
]);
|
||||
const out = new Map<string, { orders: number; subs: number; total: number; paidOrders: number }>();
|
||||
for (const id of clientIds) out.set(id, { orders: 0, subs: 0, total: 0, paidOrders: 0 });
|
||||
for (const o of orders) {
|
||||
const r = out.get(o.clientId)!;
|
||||
r.orders = o._sum.totalPrice ?? 0;
|
||||
r.paidOrders = o._count;
|
||||
}
|
||||
for (const s of subs) {
|
||||
const r = out.get(s.clientId)!;
|
||||
r.subs = (s._sum.amount ?? 0) - (s._sum.refundAmount ?? 0);
|
||||
}
|
||||
for (const r of out.values()) r.total = r.orders + r.subs;
|
||||
return out;
|
||||
}
|
||||
|
||||
function displayName(u: { firstName: string | null; lastName: string | null; username: string | null }) {
|
||||
return [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
|
||||
}
|
||||
|
||||
function utmSource(utm: unknown): string | null {
|
||||
if (utm && typeof utm === 'object' && 'utm_source' in utm) return String((utm as Record<string, unknown>).utm_source);
|
||||
if (utm && typeof utm === 'object' && 'source' in utm) return String((utm as Record<string, unknown>).source);
|
||||
return null;
|
||||
}
|
||||
|
||||
function currentSub(c: ClientRow, now: Date) {
|
||||
return activeSubscription(c.subscriptions, now);
|
||||
}
|
||||
|
||||
function rowView(c: ClientRow, now: Date, money: { total: number; paidOrders: number } | undefined, showMoney: boolean) {
|
||||
const sub = currentSub(c, now);
|
||||
return {
|
||||
id: c.id,
|
||||
name: displayName(c.user),
|
||||
username: c.user.username,
|
||||
phone: c.user.phone,
|
||||
locale: c.user.locale,
|
||||
createdAt: c.createdAt,
|
||||
segment: c.segment,
|
||||
tags: c.tags,
|
||||
source: c.source,
|
||||
utmSource: utmSource(c.utm),
|
||||
agency: c.agency,
|
||||
sub: sub ? { id: sub.id, plan: sub.plan, source: sub.source, endsAt: sub.endsAt } : null,
|
||||
revenue: showMoney ? (money?.total ?? 0) : null,
|
||||
paying: (money?.paidOrders ?? 0) > 0 || c.subscriptions.some((s) => s.source === 'PAID'),
|
||||
};
|
||||
}
|
||||
|
||||
/** where для того, что фильтруется в базе; остальное — после загрузки */
|
||||
function baseWhere(f: ClientFilters): Prisma.ClientWhereInput {
|
||||
const and: Prisma.ClientWhereInput[] = [];
|
||||
if (f.search) {
|
||||
const s = f.search.replace(/^@/, '');
|
||||
and.push({
|
||||
OR: [
|
||||
{ user: { firstName: { contains: s, mode: 'insensitive' } } },
|
||||
{ user: { lastName: { contains: s, mode: 'insensitive' } } },
|
||||
{ user: { username: { contains: s, mode: 'insensitive' } } },
|
||||
{ user: { phone: { contains: s } } },
|
||||
{ cardCode: { equals: s.toUpperCase() } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (f.source) and.push({ source: f.source });
|
||||
if (f.agencyId) and.push({ agencyId: f.agencyId });
|
||||
if (f.tag) and.push({ tags: { has: f.tag } });
|
||||
if (f.segment) and.push({ segment: f.segment });
|
||||
return and.length ? { AND: and } : {};
|
||||
}
|
||||
|
||||
async function filteredClients(db: Db, f: ClientFilters, now: Date) {
|
||||
const rows = await db.client.findMany({ where: baseWhere(f), include: clientInclude, orderBy: { createdAt: 'desc' }, take: 5000 });
|
||||
const money = await revenueByClient(
|
||||
db,
|
||||
rows.map((r) => r.id),
|
||||
);
|
||||
const list = rows.filter((c) => {
|
||||
const sub = currentSub(c, now);
|
||||
if (f.plan === 'any' && !sub) return false;
|
||||
if (f.plan === 'none' && sub) return false;
|
||||
if ((f.plan === 'BAREV_PLUS' || f.plan === 'ELITE') && sub?.plan !== f.plan) return false;
|
||||
if (f.subSource && sub?.source !== f.subSource) return false;
|
||||
if (f.expiringDays && !(sub && sub.endsAt.getTime() - now.getTime() <= f.expiringDays * DAY)) return false;
|
||||
const m = money.get(c.id)!;
|
||||
const paying = m.paidOrders > 0 || c.subscriptions.some((s) => s.source === 'PAID');
|
||||
if (f.paying === 'yes' && !paying) return false;
|
||||
if (f.paying === 'no' && paying) return false;
|
||||
return true;
|
||||
});
|
||||
return { list, money };
|
||||
}
|
||||
|
||||
const csvCell = (v: unknown) => {
|
||||
const s = v === null || v === undefined ? '' : v instanceof Date ? v.toISOString().slice(0, 10) : String(v);
|
||||
return /[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
|
||||
/** Новая подписка того же тарифа начинается после окончания текущей */
|
||||
async function nextStart(db: Db, clientId: string, plan: (typeof PLANS)[number], now: Date) {
|
||||
const current = await db.subscription.findFirst({
|
||||
where: { clientId, plan, status: 'ACTIVE', endsAt: { gt: now } },
|
||||
orderBy: { endsAt: 'desc' },
|
||||
});
|
||||
return current?.endsAt ?? now;
|
||||
}
|
||||
|
||||
async function subOf(db: Db, clientId: string, subscriptionId: string) {
|
||||
const s = await db.subscription.findUnique({ where: { id: subscriptionId } });
|
||||
if (!s || s.clientId !== clientId) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Подписки: подарить/продать/продлить/отменить/сменить тариф — суперадмин и поддержка; возврат — только суперадмин */
|
||||
const subsProcedure = rolesProcedure('SUPPORT');
|
||||
|
||||
export const boClientsRouter = router({
|
||||
list: staffProcedure
|
||||
.input(clientFilters.extend({ sort: z.enum(['new', 'revenue', 'ends']).default('new'), page: z.number().int().min(0).default(0) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = ctx.svc.now();
|
||||
const { list, money } = await filteredClients(ctx.db, input, now);
|
||||
const showMoney = ctx.isSuper;
|
||||
let views = list.map((c) => rowView(c, now, money.get(c.id), showMoney));
|
||||
if (input.sort === 'revenue' && showMoney) views = views.sort((a, b) => (b.revenue ?? 0) - (a.revenue ?? 0));
|
||||
if (input.sort === 'ends') views = views.sort((a, b) => (a.sub?.endsAt.getTime() ?? Infinity) - (b.sub?.endsAt.getTime() ?? Infinity));
|
||||
const PAGE = 50;
|
||||
return {
|
||||
total: views.length,
|
||||
totalRevenue: showMoney ? views.reduce((s, v) => s + (v.revenue ?? 0), 0) : null,
|
||||
items: views.slice(input.page * PAGE, (input.page + 1) * PAGE),
|
||||
pageSize: PAGE,
|
||||
};
|
||||
}),
|
||||
|
||||
/** Справочники для фильтров */
|
||||
facets: staffProcedure.query(async ({ ctx }) => {
|
||||
const [agencies, tagsRaw, sources] = await Promise.all([
|
||||
ctx.db.agency.findMany({ select: { id: true, name: true }, orderBy: { name: 'asc' } }),
|
||||
ctx.db.$queryRaw<{ tag: string; n: bigint }[]>`SELECT unnest(tags) AS tag, count(*) AS n FROM "Client" GROUP BY 1 ORDER BY 2 DESC, 1`,
|
||||
ctx.db.client.groupBy({ by: ['source'], _count: true }),
|
||||
]);
|
||||
return { agencies, tags: tagsRaw.map((t) => ({ tag: t.tag, count: Number(t.n) })), sources: sources.map((s) => s.source) };
|
||||
}),
|
||||
|
||||
/** CSV для Excel: разделитель «;», BOM — чтобы кириллица открылась без танцев */
|
||||
exportCsv: staffProcedure.input(clientFilters).mutation(async ({ ctx, input }) => {
|
||||
const now = ctx.svc.now();
|
||||
const { list, money } = await filteredClients(ctx.db, input, now);
|
||||
const head = ['Имя', 'Username', 'Телефон', 'Язык', 'Создан', 'Источник', 'Агентство', 'UTM', 'Тариф', 'Как получена', 'До', 'Теги'];
|
||||
if (ctx.isSuper) head.push('Принёс, AMD');
|
||||
const lines = list.map((c) => {
|
||||
const v = rowView(c, now, money.get(c.id), ctx.isSuper);
|
||||
const cells: unknown[] = [v.name, v.username, v.phone, v.locale, v.createdAt, v.source, v.agency?.name, v.utmSource, v.sub?.plan, v.sub?.source, v.sub?.endsAt, v.tags.join(', ')];
|
||||
if (ctx.isSuper) cells.push(v.revenue);
|
||||
return cells.map(csvCell).join(';');
|
||||
});
|
||||
return { filename: `barev-clients-${now.toISOString().slice(0, 10)}.csv`, content: '' + [head.join(';'), ...lines].join('\r\n') };
|
||||
}),
|
||||
|
||||
get: staffProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const now = ctx.svc.now();
|
||||
const c = await ctx.db.client.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
...clientInclude,
|
||||
orders: { orderBy: { createdAt: 'desc' }, take: 100, include: { service: { select: { code: true } }, provider: { select: { name: true } }, rating: { select: { score: true } } } },
|
||||
_count: { select: { cardUsages: true, leads: true, requests: true, concierge: true } },
|
||||
},
|
||||
});
|
||||
if (!c) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
const money = (await revenueByClient(ctx.db, [c.id])).get(c.id)!;
|
||||
const adminIds = [...new Set(c.subscriptions.map((s) => s.grantedByAdminId).filter((x): x is string => !!x))];
|
||||
const agencyIds = [...new Set(c.subscriptions.map((s) => s.agencyId).filter((x): x is string => !!x))];
|
||||
const [admins, agencies, history] = await Promise.all([
|
||||
ctx.db.adminUser.findMany({ where: { id: { in: adminIds } }, select: { id: true, name: true } }),
|
||||
ctx.db.agency.findMany({ where: { id: { in: agencyIds } }, select: { id: true, name: true } }),
|
||||
ctx.db.auditLog.findMany({ where: { entityId: c.id }, orderBy: { createdAt: 'desc' }, take: 50, include: { adminUser: { select: { name: true } } } }),
|
||||
]);
|
||||
const adminName = new Map(admins.map((a) => [a.id, a.name]));
|
||||
const agencyName = new Map(agencies.map((a) => [a.id, a.name]));
|
||||
const showMoney = ctx.isSuper;
|
||||
return {
|
||||
...rowView(c, now, money, showMoney),
|
||||
cardCode: c.cardCode,
|
||||
adminNote: c.adminNote,
|
||||
utm: c.utm,
|
||||
agencyAttachedAt: c.agencyAttachedAt,
|
||||
consentAt: c.consentAt,
|
||||
money: showMoney ? money : null,
|
||||
counts: c._count,
|
||||
subscriptions: c.subscriptions.map((s) => ({
|
||||
id: s.id,
|
||||
plan: s.plan,
|
||||
source: s.source,
|
||||
status: s.status,
|
||||
startsAt: s.startsAt,
|
||||
endsAt: s.endsAt,
|
||||
amount: showMoney ? s.amount : null,
|
||||
payMethod: s.payMethod,
|
||||
note: s.note,
|
||||
grantedBy: s.grantedByAdminId ? (adminName.get(s.grantedByAdminId) ?? '—') : null,
|
||||
agency: s.agencyId ? (agencyName.get(s.agencyId) ?? null) : s.source === 'AGENCY_GIFT' ? (c.agency?.name ?? null) : null,
|
||||
cancelledAt: s.cancelledAt,
|
||||
refundAmount: showMoney ? s.refundAmount : null,
|
||||
refundedAt: s.refundedAt,
|
||||
createdAt: s.createdAt,
|
||||
active: s.status === 'ACTIVE' && s.startsAt <= now && s.endsAt > now,
|
||||
})),
|
||||
orders: c.orders.map((o) => ({
|
||||
id: o.id,
|
||||
number: o.number,
|
||||
service: o.service.code,
|
||||
scheduledAt: o.scheduledAt,
|
||||
status: o.status,
|
||||
paymentStatus: o.paymentStatus,
|
||||
total: showMoney ? o.totalPrice : null,
|
||||
provider: o.provider?.name ?? null,
|
||||
rating: o.rating?.score ?? null,
|
||||
})),
|
||||
history: history.map((h) => ({ id: h.id, action: h.action, by: h.adminUser?.name ?? null, at: h.createdAt, data: h.data })),
|
||||
};
|
||||
}),
|
||||
|
||||
setTags: staffProcedure.input(z.object({ id: z.string(), tags: z.array(z.string().trim().min(1).max(40)).max(20) })).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.client.update({ where: { id: input.id }, data: { tags: [...new Set(input.tags)] } });
|
||||
}),
|
||||
|
||||
setNote: staffProcedure.input(z.object({ id: z.string(), note: z.string().max(4000) })).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.client.update({ where: { id: input.id }, data: { adminNote: input.note || null } });
|
||||
}),
|
||||
|
||||
// ── подписки (id — клиент, чтобы журнал собирался в карточке) ──
|
||||
|
||||
gift: subsProcedure
|
||||
.input(z.object({ id: z.string(), plan: z.enum(PLANS), days: z.number().int().min(1).max(3660), note: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const now = ctx.svc.now();
|
||||
const startsAt = await nextStart(ctx.db, input.id, input.plan, now);
|
||||
const sub = await ctx.db.subscription.create({
|
||||
data: { clientId: input.id, plan: input.plan, source: 'MANUAL', startsAt, endsAt: new Date(startsAt.getTime() + input.days * DAY), grantedByAdminId: ctx.admin.id, note: input.note },
|
||||
});
|
||||
await ctx.svc.notify.emit({ type: 'subscription.activated', subscriptionId: sub.id });
|
||||
return { subscriptionId: sub.id };
|
||||
}),
|
||||
|
||||
/** Продажа: ручная отметка оплаты. Ссылка на оплату — когда подключим платёжку. */
|
||||
sell: subsProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
plan: z.enum(PLANS),
|
||||
days: z.number().int().min(1).max(3660),
|
||||
amount: z.number().int().min(0).max(100_000_000),
|
||||
payMethod: z.enum(PAY_METHODS),
|
||||
note: z.string().max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.payMethod === 'link') throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'PAYMENTS_NOT_CONFIGURED' });
|
||||
const now = ctx.svc.now();
|
||||
const startsAt = await nextStart(ctx.db, input.id, input.plan, now);
|
||||
const sub = await ctx.db.subscription.create({
|
||||
data: {
|
||||
clientId: input.id,
|
||||
plan: input.plan,
|
||||
source: 'PAID',
|
||||
startsAt,
|
||||
endsAt: new Date(startsAt.getTime() + input.days * DAY),
|
||||
amount: input.amount,
|
||||
payMethod: input.payMethod,
|
||||
grantedByAdminId: ctx.admin.id,
|
||||
note: input.note,
|
||||
},
|
||||
});
|
||||
await ctx.svc.notify.emit({ type: 'subscription.activated', subscriptionId: sub.id });
|
||||
return { subscriptionId: sub.id };
|
||||
}),
|
||||
|
||||
extend: subsProcedure.input(z.object({ id: z.string(), subscriptionId: z.string(), days: z.number().int().min(-3660).max(3660) })).mutation(async ({ ctx, input }) => {
|
||||
const s = await subOf(ctx.db, input.id, input.subscriptionId);
|
||||
const endsAt = new Date(s.endsAt.getTime() + input.days * DAY);
|
||||
if (endsAt <= s.startsAt) throw new TRPCError({ code: 'BAD_REQUEST', message: 'ENDS_BEFORE_START' });
|
||||
await ctx.db.subscription.update({ where: { id: s.id }, data: { endsAt, status: endsAt > ctx.svc.now() && s.status === 'EXPIRED' ? 'ACTIVE' : s.status } });
|
||||
}),
|
||||
|
||||
cancel: subsProcedure.input(z.object({ id: z.string(), subscriptionId: z.string(), reason: z.string().max(500).optional() })).mutation(async ({ ctx, input }) => {
|
||||
const s = await subOf(ctx.db, input.id, input.subscriptionId);
|
||||
await ctx.db.subscription.update({
|
||||
where: { id: s.id },
|
||||
data: { status: 'CANCELLED', cancelledAt: ctx.svc.now(), note: input.reason ? [s.note, `Отмена: ${input.reason}`].filter(Boolean).join('\n') : s.note },
|
||||
});
|
||||
}),
|
||||
|
||||
changePlan: subsProcedure.input(z.object({ id: z.string(), subscriptionId: z.string(), plan: z.enum(PLANS) })).mutation(async ({ ctx, input }) => {
|
||||
const s = await subOf(ctx.db, input.id, input.subscriptionId);
|
||||
await ctx.db.subscription.update({ where: { id: s.id }, data: { plan: input.plan } });
|
||||
}),
|
||||
|
||||
refund: superProcedure
|
||||
.input(z.object({ id: z.string(), subscriptionId: z.string(), amount: z.number().int().min(1), cancel: z.boolean().default(true) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const s = await subOf(ctx.db, input.id, input.subscriptionId);
|
||||
const refunded = (s.refundAmount ?? 0) + input.amount;
|
||||
if (s.amount === null || refunded > s.amount) throw new TRPCError({ code: 'BAD_REQUEST', message: 'REFUND_TOO_BIG' });
|
||||
const now = ctx.svc.now();
|
||||
await ctx.db.subscription.update({
|
||||
where: { id: s.id },
|
||||
data: { refundAmount: refunded, refundedAt: now, ...(input.cancel ? { status: 'CANCELLED', cancelledAt: now } : {}) },
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
/** Сохранённые выборки — у каждого сотрудника свои */
|
||||
export const boSegmentsRouter = router({
|
||||
list: staffProcedure.query(({ ctx }) => ctx.db.savedSegment.findMany({ where: { adminUserId: ctx.admin.id }, orderBy: { createdAt: 'asc' } })),
|
||||
save: staffProcedure.input(z.object({ name: z.string().trim().min(1).max(60), filters: clientFilters })).mutation(({ ctx, input }) =>
|
||||
ctx.db.savedSegment.create({ data: { name: input.name, filters: input.filters, adminUserId: ctx.admin.id } }),
|
||||
),
|
||||
remove: staffProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.savedSegment.deleteMany({ where: { id: input.id, adminUserId: ctx.admin.id } });
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { DAY, startOfYerevanDay } from '@barev/core';
|
||||
import { unreadConciergeCount } from '@barev/services';
|
||||
import { router, staffProcedure } from '../../trpc.ts';
|
||||
|
||||
/** День по Еревану (UTC+4, без перехода на летнее время) */
|
||||
const dayKey = (d: Date) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 10);
|
||||
|
||||
function name(u: { firstName: string | null; lastName: string | null; username: string | null } | null | undefined) {
|
||||
if (!u) return '—';
|
||||
return [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
|
||||
}
|
||||
|
||||
export const boDashboardRouter = router({
|
||||
/** Что требует внимания — видят все роли */
|
||||
attention: staffProcedure.query(async ({ ctx }) => {
|
||||
const now = ctx.svc.now();
|
||||
const [unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring] = await Promise.all([
|
||||
ctx.db.order.count({ where: { providerId: null, companyId: null, status: { in: ['NEW', 'ENROLLED'] }, offers: { none: { status: 'OFFERED' } } } }),
|
||||
unreadConciergeCount(ctx.svc),
|
||||
ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } }),
|
||||
ctx.db.lead.count({ where: { status: 'NEW' } }),
|
||||
ctx.db.clientRequest.count({ where: { status: 'NEW' } }),
|
||||
ctx.db.rating.count({ where: { score: { lte: 3 }, createdAt: { gte: new Date(now.getTime() - 7 * DAY) } } }),
|
||||
ctx.db.subscription.count({ where: { status: 'ACTIVE', endsAt: { gt: now, lte: new Date(now.getTime() + 7 * DAY) } } }),
|
||||
]);
|
||||
return { unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring };
|
||||
}),
|
||||
|
||||
/** Полная сводка — деньги и команда видит только суперадмин */
|
||||
overview: staffProcedure.query(async ({ ctx }) => {
|
||||
const now = ctx.svc.now();
|
||||
const today = startOfYerevanDay(now);
|
||||
const from60 = new Date(today.getTime() - 59 * DAY);
|
||||
const from30 = new Date(today.getTime() - 29 * DAY);
|
||||
|
||||
const [paidOrders, soldSubs, refunds, activeSubs, expiringSubs, newClients, leads30, giftClients, paidSubClients, cardUsages, providers, recentSubs, recentOrders, recentAudit] =
|
||||
await Promise.all([
|
||||
ctx.db.order.findMany({ where: { paymentStatus: 'PAID', paidAt: { gte: from60 } }, select: { paidAt: true, totalPrice: true } }),
|
||||
ctx.db.subscription.findMany({ where: { amount: { not: null }, createdAt: { gte: from60 } }, select: { createdAt: true, amount: true } }),
|
||||
ctx.db.subscription.findMany({ where: { refundedAt: { gte: from60 } }, select: { refundedAt: true, refundAmount: true } }),
|
||||
ctx.db.subscription.findMany({ where: { status: 'ACTIVE', startsAt: { lte: now }, endsAt: { gt: now } }, select: { clientId: true, plan: true, source: true } }),
|
||||
ctx.db.subscription.findMany({
|
||||
where: { status: 'ACTIVE', endsAt: { gt: now, lte: new Date(now.getTime() + 7 * DAY) } },
|
||||
orderBy: { endsAt: 'asc' },
|
||||
take: 6,
|
||||
include: { client: { include: { user: true } } },
|
||||
}),
|
||||
ctx.db.client.findMany({ where: { createdAt: { gte: from60 } }, select: { createdAt: true, source: true, utm: true, agency: { select: { name: true } } } }),
|
||||
ctx.db.lead.count({ where: { createdAt: { gte: from30 } } }),
|
||||
ctx.db.subscription.findMany({ where: { source: { in: ['MANUAL', 'AGENCY_GIFT'] } }, select: { clientId: true }, distinct: ['clientId'] }),
|
||||
ctx.db.subscription.findMany({ where: { source: 'PAID' }, select: { clientId: true }, distinct: ['clientId'] }),
|
||||
ctx.db.cardUsage.groupBy({ by: ['partnerId'], where: { createdAt: { gte: from30 }, partnerId: { not: null } }, _count: true, orderBy: { _count: { partnerId: 'desc' } }, take: 5 }),
|
||||
ctx.db.provider.findMany({ where: { active: true, ratingCount: { gt: 0 } }, select: { id: true, name: true, ratingSum: true, ratingCount: true, role: true } }),
|
||||
ctx.db.subscription.findMany({ orderBy: { createdAt: 'desc' }, take: 8, include: { client: { include: { user: true } } } }),
|
||||
ctx.db.order.findMany({ where: { paymentStatus: 'PAID', paidAt: { not: null } }, orderBy: { paidAt: 'desc' }, take: 8, include: { client: { include: { user: true } }, service: { select: { code: true } } } }),
|
||||
ctx.db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 8, include: { adminUser: { select: { name: true } } } }),
|
||||
]);
|
||||
|
||||
// ── деньги по дням ──
|
||||
const days: string[] = [];
|
||||
for (let i = 59; i >= 0; i--) days.push(dayKey(new Date(today.getTime() - i * DAY + 12 * 3600_000)));
|
||||
const byDay = new Map(days.map((d) => [d, { orders: 0, subs: 0 }]));
|
||||
for (const o of paidOrders) byDay.get(dayKey(o.paidAt!))!.orders += o.totalPrice;
|
||||
for (const s of soldSubs) {
|
||||
const b = byDay.get(dayKey(s.createdAt));
|
||||
if (b) b.subs += s.amount ?? 0;
|
||||
}
|
||||
for (const r of refunds) {
|
||||
const b = byDay.get(dayKey(r.refundedAt!));
|
||||
if (b) b.subs -= r.refundAmount ?? 0;
|
||||
}
|
||||
const series = days.map((d) => ({ day: d, ...byDay.get(d)! }));
|
||||
const sum = (from: number, to: number) => series.slice(from, to).reduce((a, x) => ({ orders: a.orders + x.orders, subs: a.subs + x.subs }), { orders: 0, subs: 0 });
|
||||
const money = {
|
||||
today: sum(59, 60),
|
||||
yesterday: sum(58, 59),
|
||||
week: sum(53, 60),
|
||||
prevWeek: sum(46, 53),
|
||||
month: sum(30, 60),
|
||||
prevMonth: sum(0, 30),
|
||||
series: series.slice(30),
|
||||
};
|
||||
|
||||
// ── подписки ──
|
||||
const plus = new Set(activeSubs.filter((s) => s.plan === 'BAREV_PLUS').map((s) => s.clientId));
|
||||
const elite = new Set(activeSubs.filter((s) => s.plan === 'ELITE').map((s) => s.clientId));
|
||||
for (const id of elite) plus.delete(id);
|
||||
const paidSet = new Set(paidSubClients.map((s) => s.clientId));
|
||||
const giftConverted = giftClients.filter((g) => paidSet.has(g.clientId)).length;
|
||||
|
||||
// ── рост ──
|
||||
const clients30 = newClients.filter((c) => c.createdAt >= from30);
|
||||
const sources = new Map<string, number>();
|
||||
for (const c of clients30) {
|
||||
const utm = c.utm && typeof c.utm === 'object' ? ((c.utm as Record<string, unknown>).utm_source ?? (c.utm as Record<string, unknown>).source) : null;
|
||||
const key = c.agency ? `agency:${c.agency.name}` : utm ? `utm:${String(utm)}` : c.source;
|
||||
sources.set(key, (sources.get(key) ?? 0) + 1);
|
||||
}
|
||||
const payingClients30 = await ctx.db.client.count({
|
||||
where: {
|
||||
createdAt: { gte: from30 },
|
||||
OR: [{ subscriptions: { some: { source: 'PAID' } } }, { orders: { some: { paymentStatus: 'PAID' } } }],
|
||||
},
|
||||
});
|
||||
|
||||
// ── команда ──
|
||||
const partnerNames = new Map(
|
||||
(await ctx.db.partner.findMany({ where: { id: { in: cardUsages.map((c) => c.partnerId!) } }, select: { id: true, name: true } })).map((p) => [p.id, p.name]),
|
||||
);
|
||||
const rated = providers.map((p) => ({ id: p.id, name: p.name, role: p.role, avg: p.ratingSum / p.ratingCount, count: p.ratingCount })).sort((a, b) => b.avg - a.avg);
|
||||
|
||||
// ── лента ──
|
||||
const feed = [
|
||||
...recentSubs.map((s) => ({
|
||||
kind: 'subscription' as const,
|
||||
at: s.createdAt,
|
||||
clientId: s.clientId,
|
||||
who: name(s.client.user),
|
||||
plan: s.plan,
|
||||
source: s.source,
|
||||
amount: ctx.isSuper ? s.amount : null,
|
||||
})),
|
||||
...recentOrders.map((o) => ({ kind: 'payment' as const, at: o.paidAt!, clientId: o.clientId, who: name(o.client.user), service: o.service.code, number: o.number, amount: ctx.isSuper ? o.totalPrice : null })),
|
||||
...recentAudit.map((a) => ({ kind: 'action' as const, at: a.createdAt, by: a.adminUser?.name ?? null, action: a.action, entityId: a.entityId })),
|
||||
]
|
||||
.sort((a, b) => b.at.getTime() - a.at.getTime())
|
||||
.slice(0, 12);
|
||||
|
||||
return {
|
||||
money: ctx.isSuper ? money : null,
|
||||
subs: {
|
||||
plus: plus.size,
|
||||
elite: elite.size,
|
||||
gifts: activeSubs.filter((s) => s.source !== 'PAID').length,
|
||||
expiring: expiringSubs.map((s) => ({ clientId: s.clientId, name: name(s.client.user), plan: s.plan, endsAt: s.endsAt, source: s.source })),
|
||||
giftClients: giftClients.length,
|
||||
giftConverted,
|
||||
},
|
||||
growth: {
|
||||
today: newClients.filter((c) => c.createdAt >= today).length,
|
||||
week: newClients.filter((c) => c.createdAt >= new Date(today.getTime() - 6 * DAY)).length,
|
||||
month: clients30.length,
|
||||
prevMonth: newClients.length - clients30.length,
|
||||
sources: [...sources.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count),
|
||||
funnel: { leads: leads30, clients: clients30.length, paying: payingClients30 },
|
||||
},
|
||||
team: ctx.isSuper
|
||||
? {
|
||||
topProviders: rated.slice(0, 3),
|
||||
bottomProviders: rated.length > 3 ? rated.slice(-2).reverse() : [],
|
||||
topPartners: cardUsages.map((c) => ({ id: c.partnerId!, name: partnerNames.get(c.partnerId!) ?? '—', scans: c._count })),
|
||||
}
|
||||
: null,
|
||||
feed,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { router } from '../../trpc.ts';
|
||||
import { boAuthRouter, boTeamRouter } from './auth.ts';
|
||||
import { boClientsRouter, boSegmentsRouter } from './clients.ts';
|
||||
import { boDashboardRouter } from './dashboard.ts';
|
||||
|
||||
/** API новой админки (apps/backoffice) */
|
||||
export const backofficeRouter = router({
|
||||
auth: boAuthRouter,
|
||||
team: boTeamRouter,
|
||||
dashboard: boDashboardRouter,
|
||||
clients: boClientsRouter,
|
||||
segments: boSegmentsRouter,
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { defaultParams, discountFor, LOCALES } from '@barev/core';
|
||||
import {
|
||||
applyAgencyCode,
|
||||
availableSlots,
|
||||
cancelByClient,
|
||||
cancelPreview,
|
||||
clientOrder,
|
||||
createOrder,
|
||||
getMembership,
|
||||
getServiceByCode,
|
||||
getSettings,
|
||||
giveConsent,
|
||||
listCatalog,
|
||||
listClientOrders,
|
||||
quoteForClient,
|
||||
repeatDraft,
|
||||
rescheduleByClient,
|
||||
setLocale,
|
||||
clientRelocation,
|
||||
createAppLead,
|
||||
createClientRequest,
|
||||
LEAD_MOVE_WHEN,
|
||||
LEAD_PACKAGES,
|
||||
openClientRequests,
|
||||
relocationNames,
|
||||
setSegment,
|
||||
toggleRelocationDoc,
|
||||
cardQrToken,
|
||||
clientPartners,
|
||||
} from '@barev/services';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../env.ts';
|
||||
import { signedUrl } from '../infra.ts';
|
||||
import { clientProcedure, consentedClientProcedure, router } from '../trpc.ts';
|
||||
|
||||
const paramsInput = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))]));
|
||||
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
export const clientRouter = router({
|
||||
me: clientProcedure.query(async ({ ctx }) => {
|
||||
const [membership, agency, settings] = await Promise.all([
|
||||
getMembership(ctx.db, ctx.client.id, ctx.svc.now()),
|
||||
ctx.client.agencyId ? ctx.db.agency.findUnique({ where: { id: ctx.client.agencyId } }) : null,
|
||||
getSettings(ctx.db),
|
||||
]);
|
||||
return {
|
||||
firstName: ctx.client.user.firstName,
|
||||
locale: ctx.client.user.locale,
|
||||
consented: !!ctx.client.consentAt,
|
||||
membership,
|
||||
agencyName: agency?.name ?? null,
|
||||
giftUsed: !!ctx.client.giftUsedAt,
|
||||
cardCode: ctx.client.cardCode,
|
||||
barevPlusPrice: settings['barevPlus.monthlyPrice'],
|
||||
freeCancelHours: settings['cancel.freeHours'],
|
||||
/** сценарий приложения: MOVING — переезжает, LIVING — уже живёт; null — спросить */
|
||||
segment: ctx.client.segment,
|
||||
plans: {
|
||||
plusPrice: settings['barevPlus.monthlyPrice'],
|
||||
plusMaxPct: settings['barevPlus.maxDiscountPct'],
|
||||
elitePrice: settings['elite.monthlyPrice'],
|
||||
eliteMaxPct: settings['elite.maxDiscountPct'],
|
||||
consultation: settings['relocation.consultation'],
|
||||
},
|
||||
/** чат с консьержем — переписка в боте */
|
||||
conciergeUrl: `https://t.me/${env.CLIENT_BOT_USERNAME}`,
|
||||
};
|
||||
}),
|
||||
|
||||
setLocale: clientProcedure.input(z.object({ locale: z.enum(LOCALES) })).mutation(async ({ ctx, input }) => {
|
||||
await setLocale(ctx.db, ctx.telegramId, input.locale);
|
||||
}),
|
||||
|
||||
/** ONB-3 в Mini App — если клиент открыл приложение, минуя онбординг в боте */
|
||||
consent: clientProcedure.mutation(async ({ ctx }) => giveConsent(ctx.svc, ctx.client.id)),
|
||||
|
||||
applyAgencyCode: consentedClientProcedure
|
||||
.input(z.object({ code: z.string().min(2).max(32) }))
|
||||
.mutation(({ ctx, input }) => applyAgencyCode(ctx.svc, ctx.client.id, input.code)),
|
||||
|
||||
catalog: clientProcedure.query(async ({ ctx }) => {
|
||||
const [categories, membership] = await Promise.all([listCatalog(ctx.db), getMembership(ctx.db, ctx.client.id, ctx.svc.now())]);
|
||||
return { categories, level: membership.level };
|
||||
}),
|
||||
|
||||
setSegment: clientProcedure.input(z.object({ segment: z.enum(['MOVING', 'LIVING']) })).mutation(async ({ ctx, input }) => {
|
||||
await setSegment(ctx.svc, ctx.client.id, input.segment);
|
||||
}),
|
||||
|
||||
/** План переезда: шаги со статусами от оператора и чек-лист документов */
|
||||
relocation: router({
|
||||
/** names — названия шагов и документов из общего списка по ключу «STEP:код» / «DOC:код» */
|
||||
get: clientProcedure.query(async ({ ctx }) => {
|
||||
const [lead, names] = await Promise.all([clientRelocation(ctx.svc, ctx.client.id), relocationNames(ctx.db)]);
|
||||
return { lead, names };
|
||||
}),
|
||||
create: consentedClientProcedure
|
||||
.input(
|
||||
z.object({
|
||||
moveWhen: z.enum(LEAD_MOVE_WHEN),
|
||||
moveDate: z.iso.date().transform((v) => new Date(`${v}T00:00:00Z`)).optional(),
|
||||
package: z.enum(LEAD_PACKAGES),
|
||||
options: z
|
||||
.object({
|
||||
airport: z.enum(['none', 'standard', 'premium']).optional(),
|
||||
stay: z.enum(['none', 'flat', 'hotel']).optional(),
|
||||
stayBudgetFrom: z.number().int().min(0).max(10_000_000).optional(),
|
||||
stayBudgetTo: z.number().int().min(0).max(10_000_000).optional(),
|
||||
flat: z.boolean().optional(),
|
||||
socialCard: z.boolean().optional(),
|
||||
bankCard: z.boolean().optional(),
|
||||
registration: z.boolean().optional(),
|
||||
residence: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
comment: z.string().max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ ctx, input }) => createAppLead(ctx.svc, ctx.client.id, input)),
|
||||
toggleDoc: clientProcedure
|
||||
.input(z.object({ docId: z.string().max(40), checked: z.boolean() }))
|
||||
.mutation(({ ctx, input }) => toggleRelocationDoc(ctx.svc, ctx.client.id, input.docId, input.checked)),
|
||||
}),
|
||||
|
||||
/** Заявки оператору: подписка Barev+/Elite или «перезвоните» по услуге без онлайн-записи */
|
||||
requests: router({
|
||||
open: clientProcedure.query(({ ctx }) => openClientRequests(ctx.svc, ctx.client.id)),
|
||||
create: consentedClientProcedure
|
||||
.input(
|
||||
z.object({
|
||||
kind: z.enum(['SUBSCRIPTION', 'SERVICE']),
|
||||
plan: z.enum(['BAREV_PLUS', 'ELITE']).optional(),
|
||||
serviceCode: z.string().max(40).optional(),
|
||||
comment: z.string().max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ ctx, input }) => createClientRequest(ctx.svc, ctx.client.id, input)),
|
||||
}),
|
||||
|
||||
/** Партнёры клуба: скидки и подарки по клубной карте */
|
||||
partners: clientProcedure.query(({ ctx }) => clientPartners(ctx.svc, ctx.client.id)),
|
||||
/** Живой QR клубной карты: ссылка на рабочий бот, меняется каждую минуту */
|
||||
cardQr: consentedClientProcedure.query(({ ctx }) => {
|
||||
const { token, expiresAt } = cardQrToken(ctx.client.cardCode, ctx.svc.now());
|
||||
return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=${token}`, expiresAt, cardCode: ctx.client.cardCode };
|
||||
}),
|
||||
|
||||
/** ORD-2: параметры услуги для формы */
|
||||
service: clientProcedure.input(z.object({ code: z.string() })).query(async ({ ctx, input }) => {
|
||||
const s = await getServiceByCode(ctx.db, input.code);
|
||||
const membership = await getMembership(ctx.db, ctx.client.id, ctx.svc.now());
|
||||
return {
|
||||
code: s.code,
|
||||
category: s.category,
|
||||
flow: s.flow,
|
||||
active: s.active,
|
||||
isOnline: s.isOnline,
|
||||
paramsSchema: s.paramsSchema,
|
||||
defaults: defaultParams(s.paramsSchema),
|
||||
fromPrice: s.fromPrice,
|
||||
discountPct: discountFor(membership.level, s.categoryDiscountPct, s.categoryEliteDiscountPct),
|
||||
categoryDiscountPct: s.categoryDiscountPct,
|
||||
categoryEliteDiscountPct: s.categoryEliteDiscountPct,
|
||||
horizonDays: s.slotConfig.horizonDays,
|
||||
lateCancelFeePct: s.lateCancelFeePct,
|
||||
/** цены вариантов — чтобы показать «от N ֏» на кнопках выбора */
|
||||
variantPrices: s.pricing.kind === 'rooms' ? Object.fromEntries(Object.entries(s.pricing.tiers).map(([v, tier]) => [v, tier.base])) : {},
|
||||
/** цены доп. услуг — показываются рядом с выбором */
|
||||
extrasPrices: s.pricing.kind === 'rooms' ? s.pricing.extras : {},
|
||||
};
|
||||
}),
|
||||
|
||||
districts: clientProcedure.query(({ ctx }) => ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' }, select: { id: true, code: true } })),
|
||||
|
||||
paymentMethods: clientProcedure.query(({ ctx }) =>
|
||||
ctx.db.paymentMethod.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' }, select: { id: true, code: true, name: true } }),
|
||||
),
|
||||
|
||||
/** ORD-4 */
|
||||
quote: clientProcedure
|
||||
.input(z.object({ serviceCode: z.string(), params: paramsInput }))
|
||||
.query(async ({ ctx, input }) => (await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params)).quote),
|
||||
|
||||
/** ORD-3 */
|
||||
slots: clientProcedure
|
||||
.input(z.object({ serviceCode: z.string(), params: paramsInput, dateKey, excludeOrderId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const slots = await availableSlots(ctx.svc, input);
|
||||
return slots.map((s) => ({ start: s.start, end: s.end }));
|
||||
}),
|
||||
|
||||
/** ORD-5 */
|
||||
createOrder: consentedClientProcedure
|
||||
.input(
|
||||
z.object({
|
||||
serviceCode: z.string(),
|
||||
params: paramsInput,
|
||||
scheduledAt: z.date(),
|
||||
addressText: z.string().max(300).optional(),
|
||||
districtId: z.string().optional(),
|
||||
lat: z.number().min(-90).max(90).optional(),
|
||||
lng: z.number().min(-180).max(180).optional(),
|
||||
note: z.string().max(1000).optional(),
|
||||
paymentMethodId: z.string().optional(),
|
||||
repeatOfId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const order = await createOrder(ctx.svc, { ...input, clientId: ctx.client.id });
|
||||
return { id: order.id, number: order.number };
|
||||
}),
|
||||
|
||||
orders: router({
|
||||
list: clientProcedure.query(({ ctx }) => listClientOrders(ctx.db, ctx.client.id)),
|
||||
get: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => clientOrder(ctx.db, ctx.client.id, input.id)),
|
||||
cancelPreview: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => cancelPreview(ctx.svc, input.id, ctx.client.id)),
|
||||
cancel: clientProcedure
|
||||
.input(z.object({ id: z.string(), reason: z.string().max(500).optional() }))
|
||||
.mutation(({ ctx, input }) => cancelByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, reason: input.reason }).then((r) => ({ fee: r.fee }))),
|
||||
reschedule: clientProcedure
|
||||
.input(z.object({ id: z.string(), scheduledAt: z.date() }))
|
||||
.mutation(({ ctx, input }) => rescheduleByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, scheduledAt: input.scheduledAt })),
|
||||
/** ORD-7 */
|
||||
repeat: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => repeatDraft(ctx.db, input.id, ctx.client.id)),
|
||||
}),
|
||||
|
||||
/** Фотоотчёт по своему заказу — временная ссылка, доступ логируется */
|
||||
fileUrl: clientProcedure.input(z.object({ fileId: z.string() })).query(async ({ ctx, input }) => {
|
||||
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId }, include: { order: true } });
|
||||
if (!file || file.deletedAt || file.order.clientId !== ctx.client.id || file.kind !== 'PHOTO_REPORT') throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'CLIENT', actorId: ctx.client.id } });
|
||||
return { url: await signedUrl(file.storageKey) };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { averageRating, fromYerevan, parseDateKey } from '@barev/core';
|
||||
import {
|
||||
companyBoard,
|
||||
companyStaffForOrder,
|
||||
dispatcherAssign,
|
||||
markPaid,
|
||||
proHistory,
|
||||
proOrder,
|
||||
proToday,
|
||||
providerAction,
|
||||
respondToOffer,
|
||||
setChecklistItem,
|
||||
setShift,
|
||||
checkCard,
|
||||
createStaffInvite,
|
||||
partnerCabinet,
|
||||
redeemOffer,
|
||||
removePartnerMember,
|
||||
} from '@barev/services';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { signedUrl } from '../infra.ts';
|
||||
import { proUserProcedure, providerProcedure, router } from '../trpc.ts';
|
||||
|
||||
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
/** Кабинет партнёра клуба: собственник и сотрудники */
|
||||
const partnerRouter = router({
|
||||
cabinet: proUserProcedure.input(z.object({ partnerId: z.string().optional() })).query(({ ctx, input }) => partnerCabinet(ctx.svc, ctx.identity.user.telegramId, input.partnerId)),
|
||||
/** скан QR клиента (ссылка или код) */
|
||||
check: proUserProcedure
|
||||
.input(z.object({ token: z.string().max(300), partnerId: z.string().optional() }))
|
||||
.mutation(({ ctx, input }) => checkCard(ctx.svc, { telegramId: ctx.identity.user.telegramId, ...input })),
|
||||
redeem: proUserProcedure
|
||||
.input(z.object({ checkId: z.string(), offerId: z.string() }))
|
||||
.mutation(({ ctx, input }) => redeemOffer(ctx.svc, { telegramId: ctx.identity.user.telegramId, ...input })),
|
||||
invite: proUserProcedure.input(z.object({ partnerId: z.string().optional() })).mutation(({ ctx, input }) => createStaffInvite(ctx.svc, ctx.identity.user.telegramId, input.partnerId)),
|
||||
removeMember: proUserProcedure.input(z.object({ memberId: z.string() })).mutation(({ ctx, input }) => removePartnerMember(ctx.svc, ctx.identity.user.telegramId, input.memberId)),
|
||||
});
|
||||
|
||||
export const proRouter = router({
|
||||
partner: partnerRouter,
|
||||
/** Кто я: исполнитель и/или диспетчер компаний */
|
||||
me: proUserProcedure.query(({ ctx }) => {
|
||||
const p = ctx.identity.provider;
|
||||
return {
|
||||
locale: ctx.identity.user.locale,
|
||||
provider: p
|
||||
? { id: p.id, name: p.name, role: p.role, onShift: p.onShift, rating: averageRating(p.ratingSum, p.ratingCount), companyName: p.company?.name ?? null }
|
||||
: null,
|
||||
companies: ctx.identity.companies.map((c) => ({ id: c.id, name: c.name })),
|
||||
partners: ctx.identity.partners,
|
||||
};
|
||||
}),
|
||||
|
||||
today: providerProcedure.query(({ ctx }) => proToday(ctx.svc, ctx.provider.id)),
|
||||
order: providerProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => proOrder(ctx.svc, ctx.provider.id, input.id)),
|
||||
|
||||
setShift: providerProcedure.input(z.object({ onShift: z.boolean() })).mutation(async ({ ctx, input }) => {
|
||||
await setShift(ctx.db, ctx.provider.id, input.onShift);
|
||||
}),
|
||||
|
||||
/** PRO-5 */
|
||||
respondOffer: providerProcedure
|
||||
.input(z.object({ offerId: z.string(), accept: z.boolean() }))
|
||||
.mutation(({ ctx, input }) => respondToOffer(ctx.svc, { ...input, providerId: ctx.provider.id })),
|
||||
|
||||
/** PRO-3 */
|
||||
action: providerProcedure
|
||||
.input(z.object({ orderId: z.string(), action: z.string().max(40), comment: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await providerAction(ctx.svc, { ...input, providerId: ctx.provider.id });
|
||||
}),
|
||||
|
||||
/** PRO-4 */
|
||||
checklist: providerProcedure
|
||||
.input(z.object({ orderId: z.string(), code: z.string(), checked: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await setChecklistItem(ctx.svc, { ...input, providerId: ctx.provider.id });
|
||||
}),
|
||||
|
||||
/** PAY-1: исполнитель отмечает «оплачено» (например, наличными) */
|
||||
markPaid: providerProcedure.input(z.object({ orderId: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await markPaid(ctx.svc, { orderId: input.orderId, by: { type: 'PROVIDER', id: ctx.provider.id } });
|
||||
}),
|
||||
|
||||
/** PRO-6 */
|
||||
history: providerProcedure.input(z.object({ from: dateKey, to: dateKey })).query(({ ctx, input }) => {
|
||||
const f = parseDateKey(input.from);
|
||||
const t = parseDateKey(input.to);
|
||||
return proHistory(ctx.svc, ctx.provider.id, fromYerevan(f.year, f.month, f.day), fromYerevan(t.year, t.month, t.day + 1));
|
||||
}),
|
||||
|
||||
fileUrl: providerProcedure.input(z.object({ fileId: z.string() })).query(async ({ ctx, input }) => {
|
||||
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId }, include: { order: true } });
|
||||
if (!file || file.deletedAt || file.order.providerId !== ctx.provider.id) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'PROVIDER', actorId: ctx.provider.id } });
|
||||
return { url: await signedUrl(file.storageKey) };
|
||||
}),
|
||||
|
||||
/** PRO-7: диспетчер компании */
|
||||
dispatcher: router({
|
||||
board: proUserProcedure.input(z.object({ companyId: z.string() })).query(({ ctx, input }) => companyBoard(ctx.svc, input.companyId, ctx.identity.user.id)),
|
||||
staffForOrder: proUserProcedure.input(z.object({ companyId: z.string(), orderId: z.string() })).query(async ({ ctx, input }) => {
|
||||
if (!ctx.identity.companies.some((c) => c.id === input.companyId)) throw new TRPCError({ code: 'FORBIDDEN' });
|
||||
return companyStaffForOrder(ctx.svc, input.companyId, input.orderId);
|
||||
}),
|
||||
assign: proUserProcedure
|
||||
.input(z.object({ companyId: z.string(), orderId: z.string(), providerId: z.string() }))
|
||||
.mutation(({ ctx, input }) => dispatcherAssign(ctx.svc, { ...input, dispatcherUserId: ctx.identity.user.id })),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { botLink, encodeStartPayload, LOCALES, normalizeAgencyCode, UTM_KEYS } from '@barev/core';
|
||||
import { createLead, createWebRequest, getServiceByCode, getSettings, LEAD_MOVE_WHEN, LEAD_PACKAGES, listCatalog, updateLead } from '@barev/services';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { loginThrottled } from '../auth/admin.ts';
|
||||
import { env } from '../env.ts';
|
||||
import { publicProcedure, router } from '../trpc.ts';
|
||||
|
||||
export const publicRouter = router({
|
||||
/** LND-5: цены «от» и скидки — для лендинга */
|
||||
catalog: publicProcedure.query(async ({ ctx }) => {
|
||||
const [categories, settings] = await Promise.all([listCatalog(ctx.db), getSettings(ctx.db)]);
|
||||
return {
|
||||
categories,
|
||||
barevPlus: { monthlyPrice: settings['barevPlus.monthlyPrice'], giftDays: settings['barevPlus.giftDays'] },
|
||||
/** цены и скидки главной: отель, консультация, Elite, «скидки до N%» */
|
||||
offer: {
|
||||
plusMaxPct: settings['barevPlus.maxDiscountPct'],
|
||||
elitePrice: settings['elite.monthlyPrice'],
|
||||
eliteMaxPct: settings['elite.maxDiscountPct'],
|
||||
hotelWithPlus: settings['relocation.hotelWithPlus'],
|
||||
hotelBase: settings['relocation.hotelBase'],
|
||||
consultation: settings['relocation.consultation'],
|
||||
docsFrom: settings['docs.fromPrice'],
|
||||
socialCard: settings['docs.socialCardPrice'],
|
||||
residenceEscortFrom: settings['residence.escortFrom'],
|
||||
residenceStateFee: settings['residence.stateFee'],
|
||||
transferStandard: settings['transfer.standardPrice'],
|
||||
transferPremium: settings['transfer.premiumPrice'],
|
||||
},
|
||||
/** условия для агентств-партнёров — страница /land6 */
|
||||
agency: { commissionPct: settings['agency.defaultCommissionPct'], attributionMonths: settings['agency.attributionMonths'] },
|
||||
botUsername: env.CLIENT_BOT_USERNAME,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* LND-3: ссылка в бота. Если есть UTM — сохраняем их и отдаём короткий токен,
|
||||
* потому что payload Telegram ограничен 64 символами.
|
||||
*/
|
||||
startLink: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
locale: z.enum(LOCALES).optional(),
|
||||
agency: z.string().max(32).optional(),
|
||||
utm: z.partialRecord(z.enum(UTM_KEYS), z.string().max(120)).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const agency = input.agency ? (normalizeAgencyCode(input.agency) ?? undefined) : undefined;
|
||||
const hasUtm = input.utm && Object.keys(input.utm).length > 0;
|
||||
let payload: string;
|
||||
if (hasUtm) {
|
||||
const id = randomBytes(6).toString('base64url').replace(/[-_]/g, 'x');
|
||||
await ctx.db.startToken.create({ data: { id, locale: input.locale, agency, utm: input.utm } });
|
||||
payload = encodeStartPayload({ token: id });
|
||||
} else {
|
||||
payload = encodeStartPayload({ locale: input.locale, agency });
|
||||
}
|
||||
return { payload, url: botLink(env.CLIENT_BOT_USERNAME, payload) };
|
||||
}),
|
||||
|
||||
/** Страница /clean: правила цены и окно записи — цена считается в браузере той же функцией quote, что и на сервере */
|
||||
cleaning: publicProcedure.query(async ({ ctx }) => {
|
||||
const [s, districts] = await Promise.all([getServiceByCode(ctx.db, 'cleaning'), ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' }, select: { code: true } })]);
|
||||
return {
|
||||
active: s.active,
|
||||
pricing: s.pricing,
|
||||
paramsSchema: s.paramsSchema,
|
||||
slotConfig: s.slotConfig,
|
||||
categoryDiscountPct: s.categoryDiscountPct,
|
||||
districts: districts.map((d) => d.code),
|
||||
botUsername: env.CLIENT_BOT_USERNAME,
|
||||
};
|
||||
}),
|
||||
|
||||
/** Заявка с сайта → сразу в админке; в ответе ссылка в бота, которая привязывает заявку к Telegram */
|
||||
cleaningRequest: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))])),
|
||||
preferredAt: z.iso.datetime().transform((v) => new Date(v)),
|
||||
districtCode: z.string().max(40),
|
||||
addressText: z.string().max(300),
|
||||
note: z.string().max(1000).optional(),
|
||||
phone: z.string().max(30),
|
||||
name: z.string().max(80).optional(),
|
||||
locale: z.enum(LOCALES),
|
||||
agency: z.string().max(32).optional(),
|
||||
utm: z.partialRecord(z.enum(UTM_KEYS), z.string().max(120)).optional(),
|
||||
consent: z.literal(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// защита от спама формой: тот же счётчик, что у входа в админку (10 попыток за 15 минут с IP)
|
||||
if (loginThrottled(`web-request|${ctx.req.ip}`)) throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'REQUEST_THROTTLED' });
|
||||
const agency = input.agency ? (normalizeAgencyCode(input.agency) ?? undefined) : undefined;
|
||||
const request = await createWebRequest(ctx.svc, { ...input, serviceCode: 'cleaning', agency });
|
||||
const payload = encodeStartPayload({ token: request.token });
|
||||
return { number: request.number, url: botLink(env.CLIENT_BOT_USERNAME, payload) };
|
||||
}),
|
||||
|
||||
/** Квиз на главной, шаг 1: имя и контакт — сразу в базу и в админку. В ответе ключ для следующих шагов */
|
||||
leadStart: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().max(80),
|
||||
contact: z.string().max(60),
|
||||
locale: z.enum(LOCALES),
|
||||
agency: z.string().max(32).optional(),
|
||||
utm: z.partialRecord(z.enum(UTM_KEYS), z.string().max(120)).optional(),
|
||||
consent: z.literal(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (loginThrottled(`lead|${ctx.req.ip}`)) throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'REQUEST_THROTTLED' });
|
||||
const agency = input.agency ? (normalizeAgencyCode(input.agency) ?? undefined) : undefined;
|
||||
const lead = await createLead(ctx.svc, { ...input, agency });
|
||||
return { id: lead.id, key: lead.key, number: lead.number };
|
||||
}),
|
||||
|
||||
/** Квиз, следующие шаги: ответы дописываются в ту же заявку */
|
||||
leadUpdate: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().max(40),
|
||||
key: z.string().max(40),
|
||||
moved: z.boolean().optional(),
|
||||
moveWhen: z.enum(LEAD_MOVE_WHEN).optional(),
|
||||
moveDate: z.iso.date().transform((v) => new Date(`${v}T00:00:00Z`)).optional(),
|
||||
package: z.enum(LEAD_PACKAGES).optional(),
|
||||
options: z
|
||||
.object({
|
||||
airport: z.enum(['none', 'standard', 'premium']).optional(),
|
||||
stay: z.enum(['none', 'flat', 'hotel']).optional(),
|
||||
stayBudgetFrom: z.number().int().min(0).max(10_000_000).optional(),
|
||||
stayBudgetTo: z.number().int().min(0).max(10_000_000).optional(),
|
||||
flat: z.boolean().optional(),
|
||||
socialCard: z.boolean().optional(),
|
||||
bankCard: z.boolean().optional(),
|
||||
registration: z.boolean().optional(),
|
||||
residence: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
submit: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const lead = await updateLead(ctx.svc, input);
|
||||
return { number: lead.number, submitted: !!lead.submittedAt };
|
||||
}),
|
||||
|
||||
health: publicProcedure.query(() => ({ ok: true })),
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import cookie from '@fastify/cookie';
|
||||
import cors from '@fastify/cors';
|
||||
import multipart from '@fastify/multipart';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||
import { getFlow } from '@barev/core';
|
||||
import { DomainError, proIdentity, setChecklistItem } from '@barev/services';
|
||||
import Fastify from 'fastify';
|
||||
import { validateInitData } from './auth/telegram.ts';
|
||||
import { env } from './env.ts';
|
||||
import { ctx as svc, db, ensureBucket, putObject } from './infra.ts';
|
||||
import { appRouter, type AppRouter } from './router.ts';
|
||||
import { createContext } from './trpc.ts';
|
||||
|
||||
if (env.SENTRY_DSN) Sentry.init({ dsn: env.SENTRY_DSN, environment: env.NODE_ENV });
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: env.isProd ? 'info' : 'warn', redact: ['req.headers.authorization', 'req.headers.cookie'] },
|
||||
bodyLimit: 1024 * 1024,
|
||||
trustProxy: true,
|
||||
// tRPC склеивает несколько запросов в один путь (/trpc/a,b,c) — по умолчанию Fastify режет параметр после 100 символов (414)
|
||||
routerOptions: { maxParamLength: 5000 },
|
||||
});
|
||||
|
||||
await app.register(cors, { origin: env.corsOrigins, credentials: true });
|
||||
await app.register(cookie);
|
||||
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext,
|
||||
onError({ error, path }) {
|
||||
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
||||
app.log.error({ path, err: error.cause ?? error }, 'trpc error');
|
||||
if (env.SENTRY_DSN) Sentry.captureException(error.cause ?? error);
|
||||
}
|
||||
},
|
||||
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
||||
});
|
||||
|
||||
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic']);
|
||||
|
||||
/**
|
||||
* PRO-4: фото к пункту чек-листа (фотоотчёт клинера).
|
||||
* multipart: orderId, code, file. Авторизация — initData бота исполнителей.
|
||||
*/
|
||||
app.post('/files/pro/checklist-photo', async (req, reply) => {
|
||||
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
|
||||
const init = validateInitData(auth, env.proBotToken, env.TG_INIT_DATA_MAX_AGE);
|
||||
if (!init.ok) return reply.code(401).send({ error: `INIT_DATA_${init.reason}` });
|
||||
const identity = await proIdentity(db, BigInt(init.user.id));
|
||||
if (!identity?.provider) return reply.code(403).send({ error: 'NOT_PROVIDER' });
|
||||
|
||||
const part = await req.file();
|
||||
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
|
||||
const field = (name: string) => {
|
||||
const f = part.fields[name];
|
||||
return f && !Array.isArray(f) && f.type === 'field' ? String(f.value) : undefined;
|
||||
};
|
||||
const orderId = field('orderId');
|
||||
const code = field('code');
|
||||
if (!orderId || !code) return reply.code(400).send({ error: 'BAD_INPUT' });
|
||||
if (!IMAGE_TYPES.has(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
|
||||
|
||||
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
|
||||
if (!order || order.providerId !== identity.provider.id) return reply.code(404).send({ error: 'NOT_FOUND' });
|
||||
if (!getFlow(order.service.flow).checklistAt.includes(order.status)) return reply.code(409).send({ error: 'TRANSITION_NOT_ALLOWED' });
|
||||
|
||||
const body = await part.toBuffer();
|
||||
const key = await putObject(`orders/${order.id}`, body, part.mimetype);
|
||||
const file = await db.orderFile.create({
|
||||
data: { orderId: order.id, kind: 'PHOTO_REPORT', storageKey: key, mime: part.mimetype, size: body.length, uploadedBy: 'PROVIDER', uploadedById: identity.provider.id },
|
||||
});
|
||||
try {
|
||||
await setChecklistItem(svc, { orderId: order.id, providerId: identity.provider.id, code, checked: true, fileId: file.id });
|
||||
} catch (e) {
|
||||
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
|
||||
throw e;
|
||||
}
|
||||
return { fileId: file.id };
|
||||
});
|
||||
|
||||
/** Ошибки из Mini App и админки (sendBeacon) — только в лог, без хранения */
|
||||
app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
|
||||
const b = (typeof req.body === 'object' && req.body) || {};
|
||||
app.log.warn({ clientError: b, ua: req.headers['user-agent']?.slice(0, 160), host: req.headers.host }, 'client error');
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
|
||||
await ensureBucket().catch((e) => app.log.warn({ err: e }, 'S3 недоступно — загрузка фото работать не будет'));
|
||||
await app.listen({ port: env.API_PORT, host: '0.0.0.0' });
|
||||
console.log(`API: http://localhost:${env.API_PORT}`);
|
||||
@@ -0,0 +1,169 @@
|
||||
// подключает типы req.cookies / reply.setCookie
|
||||
import type {} from '@fastify/cookie';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import { DomainError, getClientByTelegramId, proIdentity, startClient } from '@barev/services';
|
||||
import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
import { ADMIN_COOKIE, findSession } from './auth/admin.ts';
|
||||
import { validateInitData } from './auth/telegram.ts';
|
||||
import { env } from './env.ts';
|
||||
import { ctx as servicesCtx, db } from './infra.ts';
|
||||
|
||||
export async function createContext({ req, res }: { req: FastifyRequest; res: FastifyReply }) {
|
||||
return { req, res, db, svc: servicesCtx };
|
||||
}
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
const DOMAIN_TO_TRPC: Partial<Record<DomainError['code'], TRPCError['code']>> = {
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
CONSENT_REQUIRED: 'PRECONDITION_FAILED',
|
||||
};
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
const cause = error.cause;
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
// код бизнес-ошибки — фронт показывает errors.<domainCode>
|
||||
domainCode: cause instanceof DomainError ? cause.code : null,
|
||||
domainDetails: cause instanceof DomainError ? (cause.details ?? null) : null,
|
||||
zod: cause instanceof ZodError ? cause.issues.map((i) => ({ path: i.path.join('.'), code: i.code })) : null,
|
||||
stack: env.isProd ? undefined : shape.data.stack,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** DomainError → TRPCError с сохранением кода */
|
||||
const domainErrors = t.middleware(async ({ next }) => {
|
||||
const result = await next();
|
||||
if (!result.ok && result.error.cause instanceof DomainError) {
|
||||
const cause = result.error.cause;
|
||||
throw new TRPCError({ code: DOMAIN_TO_TRPC[cause.code] ?? 'BAD_REQUEST', message: cause.code, cause });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure.use(domainErrors);
|
||||
|
||||
function initDataFrom(req: FastifyRequest): string | undefined {
|
||||
const h = req.headers.authorization;
|
||||
return h?.startsWith('tma ') ? h.slice(4) : undefined;
|
||||
}
|
||||
|
||||
function checkInitData(req: FastifyRequest, token: string) {
|
||||
const res = validateInitData(initDataFrom(req), token, env.TG_INIT_DATA_MAX_AGE);
|
||||
if (!res.ok) throw new TRPCError({ code: 'UNAUTHORIZED', message: `INIT_DATA_${res.reason}` });
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиентский Mini App: каждый запрос подписан initData клиентского бота.
|
||||
* Если клиент открыл Mini App раньше, чем написал /start, — заводим его без источника.
|
||||
*/
|
||||
export const clientProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
||||
const init = checkInitData(ctx.req, env.clientBotToken);
|
||||
const telegramId = BigInt(init.user.id);
|
||||
let client = await getClientByTelegramId(ctx.db, telegramId);
|
||||
if (!client) {
|
||||
await startClient(ctx.svc, init.user, init.startParam);
|
||||
client = await getClientByTelegramId(ctx.db, telegramId);
|
||||
}
|
||||
return next({ ctx: { tgUser: init.user, telegramId, client: client! } });
|
||||
});
|
||||
|
||||
/** То же, но заказ требует согласия на обработку ПДн (ONB-3) */
|
||||
export const consentedClientProcedure = clientProcedure.use(async ({ ctx, next }) => {
|
||||
if (!ctx.client.consentAt) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'CONSENT_REQUIRED', cause: new DomainError('CONSENT_REQUIRED') });
|
||||
return next();
|
||||
});
|
||||
|
||||
/** Mini App исполнителя: initData бота исполнителей; пользователь должен быть привязан инвайтом */
|
||||
export const proUserProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
||||
const init = checkInitData(ctx.req, env.proBotToken);
|
||||
const identity = await proIdentity(ctx.db, BigInt(init.user.id));
|
||||
if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' });
|
||||
}
|
||||
return next({ ctx: { tgUser: init.user, identity } });
|
||||
});
|
||||
|
||||
export const providerProcedure = proUserProcedure.use(async ({ ctx, next }) => {
|
||||
const provider = ctx.identity.provider;
|
||||
if (!provider) throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_PROVIDER' });
|
||||
return next({ ctx: { provider } });
|
||||
});
|
||||
|
||||
// ───── админка ─────
|
||||
|
||||
async function loadAdmin(ctx: Context) {
|
||||
const token = ctx.req.cookies[ADMIN_COOKIE];
|
||||
const session = await findSession(ctx.db, token);
|
||||
return session ? { session, admin: session.adminUser, token: token! } : null;
|
||||
}
|
||||
|
||||
/** Вошёл паролем, но 2FA ещё не пройдена */
|
||||
export const adminPreAuthProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
||||
const s = await loadAdmin(ctx);
|
||||
if (!s) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'ADMIN_LOGIN_REQUIRED' });
|
||||
return next({ ctx: s });
|
||||
});
|
||||
|
||||
/** Аудит-лог действий админов: каждая мутация */
|
||||
const audit = t.middleware(async ({ ctx, next, type, path, getRawInput }) => {
|
||||
const result = await next();
|
||||
const admin = (ctx as Context & { admin?: { id: string } }).admin;
|
||||
if (type === 'mutation' && result.ok && admin) {
|
||||
const input = (await getRawInput()) as Record<string, unknown> | undefined;
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
adminUserId: admin.id,
|
||||
action: path,
|
||||
entity: path.split('.')[1] ?? 'admin',
|
||||
entityId: typeof input?.id === 'string' ? input.id : typeof input?.orderId === 'string' ? input.orderId : null,
|
||||
data: input ? JSON.parse(JSON.stringify(input, (k, v) => (/password|code|secret/i.test(k) ? '***' : v))) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
export const adminProcedure = adminPreAuthProcedure
|
||||
.use(async ({ ctx, next }) => {
|
||||
if (!ctx.session.verified) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'ADMIN_2FA_REQUIRED' });
|
||||
return next();
|
||||
})
|
||||
.use(audit);
|
||||
|
||||
/** ADM-9: финансы и настройки — только владелец */
|
||||
export const ownerProcedure = adminProcedure.use(async ({ ctx, next }) => {
|
||||
if (ctx.admin.role !== 'OWNER') throw new TRPCError({ code: 'FORBIDDEN', message: 'OWNER_ONLY' });
|
||||
return next();
|
||||
});
|
||||
|
||||
// ───── новая админка (apps/backoffice) ─────
|
||||
|
||||
export type StaffRole = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
|
||||
|
||||
/** Сотрудник новой админки: вошёл и имеет хотя бы одну роль */
|
||||
export const staffProcedure = adminProcedure.use(async ({ ctx, next }) => {
|
||||
if (ctx.admin.roles.length === 0) throw new TRPCError({ code: 'FORBIDDEN', message: 'NO_ROLE' });
|
||||
const roles = ctx.admin.roles as StaffRole[];
|
||||
const isSuper = roles.includes('SUPERADMIN');
|
||||
return next({ ctx: { roles, isSuper } });
|
||||
});
|
||||
|
||||
/** Доступ для перечисленных ролей; суперадмину можно всё */
|
||||
export const rolesProcedure = (...allowed: StaffRole[]) =>
|
||||
staffProcedure.use(async ({ ctx, next }) => {
|
||||
if (!ctx.isSuper && !ctx.roles.some((r) => allowed.includes(r))) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
|
||||
return next();
|
||||
});
|
||||
|
||||
export const superProcedure = rolesProcedure();
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { signInitData, validateInitData } from '../src/auth/telegram.ts';
|
||||
import { base32Decode, base32Encode, generateTotpSecret, totpCode, verifyTotp } from '../src/auth/totp.ts';
|
||||
|
||||
const TOKEN = '123456:test-token';
|
||||
const user = { id: 42, first_name: 'Anna', language_code: 'ru' };
|
||||
|
||||
describe('Telegram initData', () => {
|
||||
it('принимает корректную подпись', () => {
|
||||
const res = validateInitData(signInitData(TOKEN, user, { startParam: 'l-ru' }), TOKEN, 3600);
|
||||
expect(res).toMatchObject({ ok: true, user: { id: 42 }, startParam: 'l-ru' });
|
||||
});
|
||||
|
||||
it('отклоняет отсутствующие, подделанные, чужим токеном и просроченные данные', () => {
|
||||
expect(validateInitData(undefined, TOKEN, 3600)).toEqual({ ok: false, reason: 'MISSING' });
|
||||
const signed = signInitData(TOKEN, user);
|
||||
const tampered = signed.replace('%22id%22%3A42', '%22id%22%3A43');
|
||||
expect(tampered).not.toBe(signed);
|
||||
expect(validateInitData(tampered, TOKEN, 3600)).toEqual({ ok: false, reason: 'BAD_HASH' });
|
||||
expect(validateInitData(signed, '999:other', 3600)).toEqual({ ok: false, reason: 'BAD_HASH' });
|
||||
expect(validateInitData(signed.replace(/hash=[0-9a-f]+/, 'hash=zz'), TOKEN, 3600)).toEqual({ ok: false, reason: 'BAD_HASH' });
|
||||
const old = signInitData(TOKEN, user, { authDate: new Date(Date.now() - 2 * 3600_000) });
|
||||
expect(validateInitData(old, TOKEN, 3600)).toEqual({ ok: false, reason: 'EXPIRED' });
|
||||
});
|
||||
|
||||
it('без токена бота не принимает ничего', () => {
|
||||
expect(validateInitData(signInitData(TOKEN, user), '', 3600)).toEqual({ ok: false, reason: 'NO_TOKEN' });
|
||||
});
|
||||
|
||||
it('совпадает с эталонным алгоритмом Telegram (порядок ключей не важен)', () => {
|
||||
const signed = new URLSearchParams(signInitData(TOKEN, user));
|
||||
const reordered = new URLSearchParams([...signed.entries()].reverse());
|
||||
expect(validateInitData(reordered.toString(), TOKEN, 3600).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TOTP', () => {
|
||||
it('RFC 6238: эталонный вектор SHA-1', () => {
|
||||
// секрет "12345678901234567890", T = 59 c → 94287082 (8 цифр) → последние 6: 287082
|
||||
const secret = base32Encode(Buffer.from('12345678901234567890'));
|
||||
expect(totpCode(secret, 59_000)).toBe('287082');
|
||||
expect(base32Decode(secret).toString()).toBe('12345678901234567890');
|
||||
});
|
||||
|
||||
it('проверяет код с допуском ±30 с', () => {
|
||||
const secret = generateTotpSecret();
|
||||
const now = Date.now();
|
||||
expect(verifyTotp(secret, totpCode(secret, now), now)).toBe(true);
|
||||
expect(verifyTotp(secret, totpCode(secret, now - 30_000), now)).toBe(true);
|
||||
expect(verifyTotp(secret, totpCode(secret, now - 120_000), now)).toBe(false);
|
||||
expect(verifyTotp(secret, 'abcdef', now)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src", "scripts", "test"] }
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#4a1028" />
|
||||
<title>Barev Club · Команда</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%23b8235a'/%3E%3Cpath d='M10 16.5l4 4 8-8.5' stroke='%23fff' stroke-width='3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,700;0,800;1,400;1,500&family=Onest:wght@400;500;600&family=Noto+Sans+Armenian:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@barev/backoffice",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5176 --strictPort",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5176",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@barev/i18n": "workspace:*",
|
||||
"@barev/ui": "workspace:*",
|
||||
"@barev/web": "workspace:*",
|
||||
"@tanstack/react-query": "^5.104.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@barev/api": "workspace:*",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Logo } from '@barev/ui';
|
||||
import { match, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { BO_LOCALE_LABELS, BO_LOCALES, errText, useT, type BoKey } from './i18n.tsx';
|
||||
import { Avatar, I, OLD_ADMIN_URL, type IconName } from './components/kit.tsx';
|
||||
import { Login } from './pages/Login.tsx';
|
||||
import { Dashboard } from './pages/Dashboard.tsx';
|
||||
import { Clients } from './pages/Clients.tsx';
|
||||
import { ClientCard } from './pages/ClientCard.tsx';
|
||||
import { Team } from './pages/Team.tsx';
|
||||
import { Soon } from './pages/Soon.tsx';
|
||||
|
||||
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
|
||||
export interface Me {
|
||||
id: string;
|
||||
name: string;
|
||||
roles: Role[];
|
||||
telegramLinked: boolean;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const trpc = useTRPC();
|
||||
const me = useQuery({ ...trpc.bo.auth.me.queryOptions(), retry: false });
|
||||
if (me.isPending) return null;
|
||||
if (!me.data || !me.data.verified) return <Login />;
|
||||
return <Shell me={{ ...me.data, roles: me.data.roles as Role[] }} />;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
key: BoKey;
|
||||
icon: IconName;
|
||||
/** этап, на котором раздел появится; пусто — уже работает */
|
||||
stage?: number;
|
||||
/** кому виден; суперадмину — всё */
|
||||
roles: Role[] | 'all';
|
||||
group?: 'work' | 'partners' | 'setup';
|
||||
count?: 'chats' | 'orders';
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ path: '/', key: 'nav.dashboard', icon: 'dashboard', roles: 'all' },
|
||||
{ path: '/clients', key: 'nav.clients', icon: 'clients', roles: ['SUPPORT', 'CONCIERGE'] },
|
||||
{ path: '/chats', key: 'nav.chats', icon: 'chats', stage: 2, roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
|
||||
{ path: '/orders', key: 'nav.orders', icon: 'orders', stage: 3, roles: ['SUPPORT'], count: 'orders' },
|
||||
{ path: '/providers', key: 'nav.providers', icon: 'providers', stage: 3, roles: ['SUPPORT'], group: 'work' },
|
||||
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', stage: 4, roles: [], group: 'partners' },
|
||||
{ path: '/venues', key: 'nav.venues', icon: 'venues', stage: 4, roles: [], group: 'partners' },
|
||||
{ path: '/crm', key: 'nav.crm', icon: 'crm', stage: 4, roles: ['SALES'], group: 'partners' },
|
||||
{ path: '/products', key: 'nav.products', icon: 'products', stage: 5, roles: [], group: 'setup' },
|
||||
{ path: '/journal', key: 'nav.journal', icon: 'journal', stage: 5, roles: [], group: 'setup' },
|
||||
{ path: '/team', key: 'nav.team', icon: 'team', roles: [], group: 'setup' },
|
||||
];
|
||||
const GROUP_KEY = { work: 'nav.groupWork', partners: 'nav.groupPartners', setup: 'nav.groupSetup' } as const;
|
||||
|
||||
function Shell({ me }: { me: Me }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const { path } = useLocation();
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
const items = NAV.filter((n) => isSuper || n.roles === 'all' || n.roles.some((r) => me.roles.includes(r)));
|
||||
const attention = useQuery({ ...trpc.bo.dashboard.attention.queryOptions(), refetchInterval: 30_000 });
|
||||
const counts = { chats: attention.data?.unreadChats, orders: attention.data?.unassigned };
|
||||
const [sheet, setSheet] = useState(false);
|
||||
useEffect(() => {
|
||||
setSheet(false);
|
||||
window.scrollTo(0, 0);
|
||||
}, [path]);
|
||||
|
||||
const current = items.find((n) => n.path === path || (n.path !== '/' && path.startsWith(`${n.path}/`)));
|
||||
let page: ReactNode;
|
||||
const clientId = match('/clients/:id', path)?.id;
|
||||
if (path === '/') page = <Dashboard me={me} />;
|
||||
else if (path === '/clients' && current) page = <Clients me={me} />;
|
||||
else if (clientId && items.some((n) => n.path === '/clients')) page = <ClientCard id={clientId} me={me} />;
|
||||
else if (path === '/team' && isSuper) page = <Team me={me} />;
|
||||
else if (current?.stage) page = <Soon title={t(current.key)} stage={current.stage} />;
|
||||
else page = <Dashboard me={me} />;
|
||||
|
||||
const isActive = (n: NavItem) => (n.path === '/' ? path === '/' : path === n.path || path.startsWith(`${n.path}/`));
|
||||
const tabItems = items.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="bo-layout">
|
||||
<aside className="bo-side">
|
||||
<div className="bo-side__logo">
|
||||
<Logo size={22} color="#f6e9dc" />
|
||||
</div>
|
||||
<NavList items={items} isActive={isActive} counts={counts} />
|
||||
<SideFoot me={me} />
|
||||
</aside>
|
||||
|
||||
<main className="bo-main">
|
||||
{!me.telegramLinked && <LinkTelegramBanner />}
|
||||
{page}
|
||||
</main>
|
||||
|
||||
<nav className="bo-tabbar" aria-label="Menu">
|
||||
{tabItems.map((n) => (
|
||||
<a key={n.path} href={`#${n.path}`} className="bo-tab" aria-current={isActive(n) ? 'page' : undefined}>
|
||||
<I name={n.icon} size={22} />
|
||||
{t(n.key)}
|
||||
{!!(n.count && counts[n.count]) && <span className="bo-nav__count">{counts[n.count!]}</span>}
|
||||
</a>
|
||||
))}
|
||||
<button type="button" className="bo-tab" onClick={() => setSheet(true)} aria-expanded={sheet}>
|
||||
<I name="more" size={22} />
|
||||
{t('nav.more')}
|
||||
</button>
|
||||
</nav>
|
||||
{sheet && (
|
||||
<>
|
||||
<div className="bo-sheet-backdrop" onClick={() => setSheet(false)} />
|
||||
<div className="bo-sheet" role="dialog" aria-label={t('nav.more')}>
|
||||
<NavList items={items.slice(4)} isActive={isActive} counts={counts} />
|
||||
<SideFoot me={me} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavList({ items, isActive, counts }: { items: NavItem[]; isActive: (n: NavItem) => boolean; counts: Record<'chats' | 'orders', number | undefined> }) {
|
||||
const { t } = useT();
|
||||
let lastGroup: string | undefined;
|
||||
return (
|
||||
<>
|
||||
{items.map((n) => {
|
||||
const header = n.group && n.group !== lastGroup ? <div className="bo-side__group">{t(GROUP_KEY[n.group])}</div> : null;
|
||||
lastGroup = n.group ?? lastGroup;
|
||||
const count = n.count ? counts[n.count] : undefined;
|
||||
return (
|
||||
<div key={n.path}>
|
||||
{header}
|
||||
<a href={`#${n.path}`} className="bo-nav" aria-current={isActive(n) ? 'page' : undefined}>
|
||||
<I name={n.icon} />
|
||||
{t(n.key)}
|
||||
{count ? <span className="bo-nav__count">{count}</span> : n.stage ? <span className="bo-nav__soon">{t('nav.soon', { n: n.stage })}</span> : null}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SideFoot({ me }: { me: Me }) {
|
||||
const { t, locale, setLocale } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const logout = useMutation(trpc.bo.auth.logout.mutationOptions({ onSuccess: () => qc.resetQueries() }));
|
||||
return (
|
||||
<div className="bo-side__foot">
|
||||
<div className="bo-langs" role="group" aria-label="Language">
|
||||
{BO_LOCALES.map((l) => (
|
||||
<button key={l} type="button" className="bo-lang" aria-pressed={l === locale} onClick={() => setLocale(l)}>
|
||||
{BO_LOCALE_LABELS[l]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="bo-me">
|
||||
<Avatar name={me.name} id={me.id} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>{me.name}</div>
|
||||
<div className="bo-me__roles">{me.roles.map((r) => t(`role.${r}`)).join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<a className="bo-side__link" href={OLD_ADMIN_URL} target="_blank" rel="noreferrer">
|
||||
{t('shell.oldAdmin')}
|
||||
</a>
|
||||
<button type="button" className="bo-side__link" onClick={() => logout.mutate()}>
|
||||
{t('shell.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkTelegramBanner() {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const link = useMutation(
|
||||
trpc.bo.auth.telegramLink.mutationOptions({
|
||||
onSuccess: (r) => window.open(r.url, '_blank', 'noopener'),
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<div className="bo-banner">
|
||||
<span>{t('shell.linkTg')}</span>
|
||||
<button type="button" className="bo-btn bo-btn--sm" onClick={() => link.mutate()} disabled={link.isPending}>
|
||||
<I name="telegram" size={16} />
|
||||
{t('shell.linkTgBtn')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import type { T } from '../i18n.tsx';
|
||||
|
||||
const PATHS = {
|
||||
dashboard: 'M4 13h6V4H4zM14 20h6v-9h-6zM4 20h6v-4H4zM14 4v4h6V4z',
|
||||
clients: 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM2 21a7 7 0 0 1 14 0M16 3.5a4 4 0 0 1 0 7.5M18 14a6 6 0 0 1 4 7',
|
||||
chats: 'M4 5h12v9H8l-4 3.5zM8 17.5V18h8l4 3v-9h-2',
|
||||
orders: 'M6 3h12v18l-3-2-3 2-3-2-3 2zM9 8h6M9 12h6',
|
||||
providers: 'M14.7 6.3a4 4 0 0 0-5.4 5.3L3 18l3 3 6.4-6.3a4 4 0 0 0 5.3-5.4l-2.5 2.5-2.8-.7-.7-2.8z',
|
||||
agencies: 'M3 21V8l9-5 9 5v13M9 21v-6h6v6M3 21h18',
|
||||
venues: 'M5 11v10h14V11M3 7l2-4h14l2 4v1a3 3 0 0 1-6 0 3 3 0 0 1-6 0 3 3 0 0 1-6 0zM10 21v-5h4v5',
|
||||
crm: 'M3 4h18l-7 8.5V19l-4 2v-8.5z',
|
||||
products: 'M12 3l8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9',
|
||||
journal: 'M6 3h11a2 2 0 0 1 2 2v16H8a2 2 0 0 1-2-2zM6 19a2 2 0 0 1 2-2h11M10 8h5M10 12h5',
|
||||
team: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM5 21a7 7 0 0 1 14 0',
|
||||
more: 'M5 12h.01M12 12h.01M19 12h.01',
|
||||
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4-4',
|
||||
back: 'M19 12H5M11 18l-6-6 6-6',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
gift: 'M4 8h16v4H4zM5 12h14v8H5zM12 8v12M12 8c-2-3-6-3-6 0M12 8c2-3 6-3 6 0',
|
||||
download: 'M12 4v11M7 10l5 5 5-5M5 20h14',
|
||||
send: 'M4 12l16-8-6 16-2.5-6.5z',
|
||||
check: 'M5 12l4 4 10-10',
|
||||
close: 'M6 6l12 12M18 6L6 18',
|
||||
filter: 'M4 6h16M7 12h10M10 18h4',
|
||||
telegram: 'M21 4L3 11l6 2 2 6 3-4 5 4z M9 13l8-6',
|
||||
arrowUp: 'M12 19V5M6 11l6-6 6 6',
|
||||
arrowDown: 'M12 5v14M6 13l6 6 6-6',
|
||||
external: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6',
|
||||
} as const;
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
export function I({ name, size = 20, stroke = 1.8 }: { name: IconName; size?: number; stroke?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={name === 'more' ? 3.2 : stroke} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d={PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function initials(name: string) {
|
||||
const clean = name.replace(/^@/, '').trim();
|
||||
const parts = clean.split(/\s+/).filter(Boolean);
|
||||
return ((parts[0]?.[0] ?? '') + (parts[1]?.[0] ?? '')).toUpperCase() || '·';
|
||||
}
|
||||
|
||||
/** Пастельный фон аватара по имени — чтобы в списке глаз цеплялся за людей */
|
||||
const AVATAR = [
|
||||
['var(--bc-pink)', 'var(--bc-plum)'],
|
||||
['var(--bc-blue)', 'var(--bc-blue-ink)'],
|
||||
['var(--bc-home)', 'var(--bc-home-ink)'],
|
||||
['var(--bc-learn)', 'var(--bc-learn-ink)'],
|
||||
['var(--bc-docs)', 'var(--bc-docs-ink)'],
|
||||
['var(--bc-perks)', 'var(--bc-perks-ink)'],
|
||||
] as const;
|
||||
export function Avatar({ name, id, size }: { name: string; id: string; size?: number }) {
|
||||
let h = 0;
|
||||
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
const [bg, fg] = AVATAR[h % AVATAR.length]!;
|
||||
return (
|
||||
<span className="bo-avatar" style={{ background: bg, color: fg, ...(size ? { width: size, height: size, fontSize: size * 0.4 } : {}) }} aria-hidden>
|
||||
{initials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanBadge({ plan, t }: { plan: 'BAREV_PLUS' | 'ELITE' | null; t: T }) {
|
||||
return <span className={`bo-plan bo-plan--${plan ?? 'none'}`}>{plan ? t(`plan.${plan}`) : t('plan.none')}</span>;
|
||||
}
|
||||
|
||||
/** Источник клиента простыми словами */
|
||||
export function sourceLabel(t: T, c: { source: string; agency?: { name: string } | null; utmSource?: string | null }) {
|
||||
if (c.agency) return t('source.agency', { name: c.agency.name });
|
||||
if (c.utmSource) return t('source.utm', { name: c.utmSource });
|
||||
const key = `source.${c.source}`;
|
||||
const tr = t(key);
|
||||
return tr === key ? c.source : tr;
|
||||
}
|
||||
|
||||
/** Ключ источника из дашборда: agency:Имя | utm:имя | organic */
|
||||
export function sourceKeyLabel(t: T, key: string) {
|
||||
if (key.startsWith('agency:')) return t('source.agency', { name: key.slice(7) });
|
||||
if (key.startsWith('utm:')) return t('source.utm', { name: key.slice(4) });
|
||||
return sourceLabel(t, { source: key });
|
||||
}
|
||||
|
||||
/** Модальное окно на <dialog>: фокус, Esc и затемнение — силами браузера */
|
||||
export function Dialog({ open, onClose, title, children, footer }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer: ReactNode }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const d = ref.current;
|
||||
if (!d) return;
|
||||
if (open && !d.open) d.showModal();
|
||||
if (!open && d.open) d.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className="bo-dialog"
|
||||
onClose={onClose}
|
||||
onClick={(e) => {
|
||||
if (e.target === ref.current) onClose();
|
||||
}}
|
||||
aria-labelledby="bo-dialog-title"
|
||||
>
|
||||
{open && (
|
||||
<div className="bo-dialog__body">
|
||||
<h2 id="bo-dialog-title" className="bo-h2">
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
<div className="bo-dialog__foot">{footer}</div>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: ReactNode }) {
|
||||
return (
|
||||
<label className="bo-field">
|
||||
<span className="bo-label">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="bo-hint">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton({ h }: { h: number }) {
|
||||
return <div className="bo-skeleton" style={{ height: h }} aria-hidden />;
|
||||
}
|
||||
|
||||
export function download(filename: string, content: string, type = 'text/csv;charset=utf-8') {
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export const OLD_ADMIN_URL = import.meta.env.VITE_OLD_ADMIN_URL ?? (location.hostname === 'localhost' ? 'http://localhost:5173' : '/');
|
||||
@@ -0,0 +1,976 @@
|
||||
import { createT as createSharedT, formatAmd, formatDateTime, type Locale as SharedLocale } from '@barev/i18n';
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
/** Языки новой админки */
|
||||
export const BO_LOCALES = ['ru', 'en', 'hy'] as const;
|
||||
export type BoLocale = (typeof BO_LOCALES)[number];
|
||||
export const BO_LOCALE_LABELS: Record<BoLocale, string> = { ru: 'Рус', en: 'Eng', hy: 'Հայ' };
|
||||
|
||||
const ru = {
|
||||
'ui.save': 'Сохранить',
|
||||
'ui.close': 'Закрыть',
|
||||
'ui.loading': 'Загружаем…',
|
||||
'ui.error': 'Не получилось: {msg}',
|
||||
'ui.retry': 'Повторить',
|
||||
'ui.copy': 'Скопировать',
|
||||
'ui.copied': 'Скопировано',
|
||||
'ui.days': '{n} дн.',
|
||||
'ui.yes': 'Да',
|
||||
'ui.no': 'Нет',
|
||||
'ui.none': '—',
|
||||
|
||||
'err.PAYMENTS_NOT_CONFIGURED': 'Платёжная система ещё не подключена — отметьте оплату вручную.',
|
||||
'err.REFUND_TOO_BIG': 'Сумма возврата больше, чем клиент заплатил.',
|
||||
'err.ENDS_BEFORE_START': 'Подписка не может закончиться раньше, чем началась.',
|
||||
'err.CANNOT_DEMOTE_SELF': 'Нельзя отключить себя или снять с себя роль суперадмина.',
|
||||
'err.ROLE_FORBIDDEN': 'У вашей роли нет доступа к этому действию.',
|
||||
'err.NO_ROLE': 'У вашего аккаунта нет ролей в новой админке. Попросите суперадмина их выдать.',
|
||||
|
||||
'login.coverWord': 'команда',
|
||||
'login.title': 'Вход для команды',
|
||||
'login.lead': 'Подтвердите вход в рабочем боте Barev Club. Пароль не нужен.',
|
||||
'login.open': 'Подтвердить в Telegram',
|
||||
'login.qr': 'С компьютера удобнее отсканировать код телефоном.',
|
||||
'login.waiting': 'Ждём подтверждения в Telegram…',
|
||||
'login.expired': 'Ссылка устарела.',
|
||||
'login.retry': 'Получить новую ссылку',
|
||||
'login.dev': 'Войти локально',
|
||||
'login.devHint': 'Только на этом компьютере — на сервере этого блока нет.',
|
||||
'login.noTelegram': 'Нет доступа? Попросите суперадмина добавить вас в команду и прислать ссылку для привязки Telegram.',
|
||||
|
||||
'nav.dashboard': 'Дашборд',
|
||||
'nav.clients': 'Клиенты',
|
||||
'nav.chats': 'Чаты',
|
||||
'nav.orders': 'Заказы',
|
||||
'nav.providers': 'Исполнители',
|
||||
'nav.agencies': 'Агентства',
|
||||
'nav.venues': 'Заведения',
|
||||
'nav.crm': 'CRM',
|
||||
'nav.products': 'Продукты',
|
||||
'nav.journal': 'Журнал',
|
||||
'nav.team': 'Команда',
|
||||
'nav.more': 'Ещё',
|
||||
'nav.soon': 'этап {n}',
|
||||
'nav.groupWork': 'Работа',
|
||||
'nav.groupPartners': 'Партнёры',
|
||||
'nav.groupSetup': 'Настройка',
|
||||
|
||||
'role.SUPERADMIN': 'Суперадмин',
|
||||
'role.SUPPORT': 'Поддержка',
|
||||
'role.CONCIERGE': 'Консьерж',
|
||||
'role.SALES': 'Продажи',
|
||||
'roleHint.SUPERADMIN': 'Всё, включая деньги и команду',
|
||||
'roleHint.SUPPORT': 'Чат поддержки, заказы, подписки',
|
||||
'roleHint.CONCIERGE': 'Чат консьержа для подписчиков',
|
||||
'roleHint.SALES': 'CRM: заведения и агентства',
|
||||
|
||||
'shell.logout': 'Выйти',
|
||||
'shell.oldAdmin': 'Старая админка',
|
||||
'shell.linkTg': 'Привяжите Telegram — через него вы входите и получаете уведомления.',
|
||||
'shell.linkTgBtn': 'Привязать Telegram',
|
||||
|
||||
'soon.title': '{section} переезжает сюда',
|
||||
'soon.text': 'Раздел появится на этапе {n}. Пока работайте в старой админке — данные общие.',
|
||||
'soon.open': 'Открыть старую админку',
|
||||
|
||||
'dash.morning': 'Доброе утро, {name}',
|
||||
'dash.day': 'Добрый день, {name}',
|
||||
'dash.evening': 'Добрый вечер, {name}',
|
||||
'dash.attention': 'Требует внимания',
|
||||
'dash.allClear': 'Срочного нет — всё разобрано.',
|
||||
'att.unassigned': 'Заказы без исполнителя',
|
||||
'att.unreadChats': 'Непрочитанные сообщения',
|
||||
'att.webRequests': 'Заявки с сайта',
|
||||
'att.leads': 'Заявки на переезд',
|
||||
'att.appRequests': 'Заявки из приложения',
|
||||
'att.lowRatings': 'Оценки 3 и ниже за неделю',
|
||||
'att.expiring': 'Подписки истекают за 7 дней',
|
||||
'dash.money': 'Деньги',
|
||||
'dash.today': 'Сегодня',
|
||||
'dash.week': '7 дней',
|
||||
'dash.month': '30 дней',
|
||||
'dash.vsYesterday': 'вчера {sum}',
|
||||
'dash.vsPrev': 'к прошлым {period}',
|
||||
'dash.orders': 'заказы',
|
||||
'dash.subs': 'подписки',
|
||||
'dash.chart': 'Выручка по дням за 30 дней',
|
||||
'dash.subsTitle': 'Подписки',
|
||||
'dash.gifts': 'Из них подарочных: {n}',
|
||||
'dash.expiring': 'Истекают в ближайшие 7 дней',
|
||||
'dash.noExpiring': 'В ближайшую неделю ничего не истекает.',
|
||||
'dash.conversion': 'Подарок → покупка',
|
||||
'dash.conversionHint': '{paid} из {total} клиентов с подарком потом купили подписку',
|
||||
'dash.growth': 'Рост',
|
||||
'dash.newClients': 'Новые клиенты',
|
||||
'dash.prevMonth': 'за прошлые 30 дней — {n}',
|
||||
'dash.sources': 'Откуда пришли за 30 дней',
|
||||
'dash.funnel': 'Воронка за 30 дней',
|
||||
'funnel.leads': 'Заявки с сайта',
|
||||
'funnel.clients': 'Клиенты в приложении',
|
||||
'funnel.paying': 'Заплатили',
|
||||
'dash.team': 'Команда и партнёры',
|
||||
'dash.topProviders': 'Лучшие исполнители',
|
||||
'dash.bottomProviders': 'Стоит поговорить',
|
||||
'dash.topPartners': 'Заведения: сканы карты за 30 дней',
|
||||
'dash.salesSoon': 'Результаты продажников появятся вместе с CRM.',
|
||||
'dash.empty': 'Пока пусто',
|
||||
'dash.feed': 'Лента',
|
||||
'feed.subscription': '{plan} · {source}',
|
||||
'feed.payment': 'Оплачен заказ №{number}',
|
||||
'dash.ratings': '{n} оценок',
|
||||
'dash.scans': '{n} сканов',
|
||||
|
||||
'source.organic': 'Сами нашли',
|
||||
'source.deeplink': 'Ссылка агентства',
|
||||
'source.manual_code': 'Код агентства',
|
||||
'source.agency': 'Агентство «{name}»',
|
||||
'source.utm': 'Реклама: {name}',
|
||||
|
||||
'plan.BAREV_PLUS': 'Barev+',
|
||||
'plan.ELITE': 'Elite',
|
||||
'plan.none': 'Без подписки',
|
||||
'subSource.PAID': 'Покупка',
|
||||
'subSource.MANUAL': 'Подарок команды',
|
||||
'subSource.AGENCY_GIFT': 'Подарок агентства',
|
||||
'subStatus.ACTIVE': 'Активна',
|
||||
'subStatus.EXPIRED': 'Истекла',
|
||||
'subStatus.CANCELLED': 'Отменена',
|
||||
'pay.cash': 'Наличные',
|
||||
'pay.transfer': 'Перевод',
|
||||
'pay.card': 'Карта через терминал',
|
||||
'pay.link': 'Ссылка на оплату',
|
||||
'segment.MOVING': 'Переезжает',
|
||||
'segment.LIVING': 'Живёт в Армении',
|
||||
|
||||
'clients.title': 'Клиенты',
|
||||
'clients.search': 'Имя, @username, телефон или номер карты',
|
||||
'clients.found': 'Найдено: {n}',
|
||||
'clients.revenueTotal': 'принесли {sum}',
|
||||
'col.client': 'Клиент',
|
||||
'col.sub': 'Подписка',
|
||||
'col.source': 'Откуда',
|
||||
'col.revenue': 'Принёс',
|
||||
'col.since': 'С нами с',
|
||||
'filter.plan': 'Подписка',
|
||||
'filter.all': 'Все',
|
||||
'filter.any': 'С подпиской',
|
||||
'filter.none': 'Без подписки',
|
||||
'filter.subSource': 'Как получена',
|
||||
'filter.source': 'Откуда',
|
||||
'filter.agency': 'Агентство',
|
||||
'filter.tag': 'Тег',
|
||||
'filter.segment': 'Сценарий',
|
||||
'filter.paying': 'Платили',
|
||||
'filter.payingYes': 'Платили',
|
||||
'filter.payingNo': 'Ни разу не платили',
|
||||
'filter.expiring': 'Истекает за 7 дней',
|
||||
'filter.reset': 'Сбросить фильтры',
|
||||
'filter.more': 'Фильтры',
|
||||
'filter.anyValue': 'Любое',
|
||||
'sort.label': 'Порядок',
|
||||
'sort.new': 'Сначала новые',
|
||||
'sort.revenue': 'Больше принесли',
|
||||
'sort.ends': 'Скоро истекает',
|
||||
'segments.title': 'Мои выборки',
|
||||
'segments.save': 'Сохранить выборку',
|
||||
'segments.name': 'Название выборки',
|
||||
'segments.saved': 'Выборка сохранена',
|
||||
'segments.remove': 'Удалить выборку «{name}»',
|
||||
'clients.export': 'Скачать для Excel',
|
||||
'clients.exported': 'Файл скачан',
|
||||
'clients.broadcast': 'Рассылка',
|
||||
'clients.broadcastSoon': 'Рассылки по выборке появятся на этапе 5.',
|
||||
'clients.empty': 'Под эти фильтры никто не подходит.',
|
||||
'clients.emptyAll': 'Клиентов пока нет — они появятся, когда напишут боту.',
|
||||
'clients.prev': 'Назад',
|
||||
'clients.next': 'Дальше',
|
||||
'clients.page': '{from}–{to} из {total}',
|
||||
|
||||
'card.back': 'Все клиенты',
|
||||
'card.since': 'С нами с {date}',
|
||||
'card.telegram': 'Открыть в Telegram',
|
||||
'card.cardNo': 'Карта {code}',
|
||||
'card.noSub': 'Нет активной подписки',
|
||||
'card.noSubHint': 'Подарите или продайте подписку — клиент сразу получит скидки.',
|
||||
'card.until': 'до {date}',
|
||||
'card.daysLeft': 'осталось {n} дн.',
|
||||
'card.history': 'Все подписки',
|
||||
'card.grantedBy': 'начислил(а) {name}',
|
||||
'card.giftFrom': 'подарок от «{name}»',
|
||||
'card.paidVia': '{method}',
|
||||
'card.refunded': 'возврат {sum}',
|
||||
'card.cancelledAt': 'отменена {date}',
|
||||
'card.upcoming': 'начнётся {date}',
|
||||
'card.money': 'Принёс',
|
||||
'card.fromOrders': 'заказы',
|
||||
'card.fromSubs': 'подписки',
|
||||
'card.orders': 'Заказы',
|
||||
'card.noOrders': 'Заказов пока нет.',
|
||||
'card.newOrder': 'Создать заказ',
|
||||
'card.newOrderSoon': 'Заказ за клиента появится на этапе 3, вместе с разделом заказов.',
|
||||
'card.profile': 'Профиль',
|
||||
'card.source': 'Откуда пришёл',
|
||||
'card.agency': 'Агентство',
|
||||
'card.language': 'Язык',
|
||||
'card.phone': 'Телефон',
|
||||
'card.segment': 'Сценарий',
|
||||
'card.consent': 'Согласие на обработку данных',
|
||||
'card.tags': 'Теги',
|
||||
'card.addTag': 'Новый тег',
|
||||
'card.note': 'Заметка команды',
|
||||
'card.notePh': 'Клиент не видит эту заметку',
|
||||
'card.saved': 'Сохранено',
|
||||
'card.activity': 'Журнал по клиенту',
|
||||
'card.noActivity': 'Команда пока ничего не меняла.',
|
||||
'card.stats': 'Активность',
|
||||
'stat.cardUsages': 'сканов карты',
|
||||
'stat.leads': 'заявок на переезд',
|
||||
'stat.requests': 'заявок из приложения',
|
||||
'stat.concierge': 'сообщений в чате',
|
||||
|
||||
'act.gift': 'Подарить',
|
||||
'act.sell': 'Продать',
|
||||
'act.extend': 'Изменить срок',
|
||||
'act.cancel': 'Отменить',
|
||||
'act.changePlan': 'Сменить тариф',
|
||||
'act.refund': 'Вернуть деньги',
|
||||
|
||||
'audit.bo.clients.gift': 'подарил(а) подписку',
|
||||
'audit.bo.clients.sell': 'продал(а) подписку',
|
||||
'audit.bo.clients.extend': 'изменил(а) срок подписки',
|
||||
'audit.bo.clients.cancel': 'отменил(а) подписку',
|
||||
'audit.bo.clients.changePlan': 'сменил(а) тариф',
|
||||
'audit.bo.clients.refund': 'оформил(а) возврат',
|
||||
'audit.bo.clients.setTags': 'изменил(а) теги',
|
||||
'audit.bo.clients.setNote': 'изменил(а) заметку',
|
||||
|
||||
'dlg.giftTitle': 'Подарить подписку',
|
||||
'dlg.giftHint': 'Подписка будет помечена как подарок от вас.',
|
||||
'dlg.sellTitle': 'Продать подписку',
|
||||
'dlg.plan': 'Тариф',
|
||||
'dlg.period': 'Срок',
|
||||
'dlg.period1': '1 месяц',
|
||||
'dlg.period3': '3 месяца',
|
||||
'dlg.period6': '6 месяцев',
|
||||
'dlg.period12': '1 год',
|
||||
'dlg.periodCustom': 'Своё',
|
||||
'dlg.days': 'Дней',
|
||||
'dlg.amount': 'Сумма, ֏',
|
||||
'dlg.method': 'Как оплатил',
|
||||
'dlg.note': 'Комментарий для команды',
|
||||
'dlg.linkSoon': 'Ссылку на оплату включим, когда подключим платёжную систему.',
|
||||
'dlg.giftConfirm': 'Подарить',
|
||||
'dlg.sellConfirm': 'Отметить оплату',
|
||||
'dlg.extendTitle': 'Изменить срок',
|
||||
'dlg.extendHint': 'Отрицательное число сокращает срок.',
|
||||
'dlg.newEnd': 'Новая дата окончания: {date}',
|
||||
'dlg.extendConfirm': 'Сохранить срок',
|
||||
'dlg.cancelTitle': 'Отменить подписку',
|
||||
'dlg.cancelHint': 'Клиент сразу потеряет скидки и привилегии подписки.',
|
||||
'dlg.reason': 'Причина',
|
||||
'dlg.cancelConfirm': 'Отменить подписку',
|
||||
'dlg.planTitle': 'Сменить тариф',
|
||||
'dlg.planConfirm': 'Сменить тариф',
|
||||
'dlg.refundTitle': 'Вернуть деньги',
|
||||
'dlg.refundPaid': 'Оплачено {sum}, уже возвращено {back}',
|
||||
'dlg.refundCancel': 'Сразу отменить подписку',
|
||||
'dlg.refundConfirm': 'Оформить возврат',
|
||||
'done.gift': 'Подписка подарена',
|
||||
'done.sell': 'Оплата отмечена',
|
||||
'done.extend': 'Срок изменён',
|
||||
'done.cancel': 'Подписка отменена',
|
||||
'done.changePlan': 'Тариф изменён',
|
||||
'done.refund': 'Возврат оформлен',
|
||||
|
||||
'team.title': 'Команда',
|
||||
'team.lead': 'Кто работает в админке и что ему доступно. У одного человека может быть несколько ролей.',
|
||||
'team.add': 'Добавить человека',
|
||||
'team.name': 'Имя',
|
||||
'team.roles': 'Роли',
|
||||
'team.create': 'Добавить',
|
||||
'team.linkTitle': 'Ссылка для {name}',
|
||||
'team.linkHint': 'Отправьте её человеку. Он откроет рабочий бот, Telegram привяжется — и можно входить.',
|
||||
'team.tgLinked': 'Telegram привязан',
|
||||
'team.tgNotLinked': 'Telegram не привязан',
|
||||
'team.newLink': 'Ссылка для привязки',
|
||||
'team.disable': 'Отключить доступ',
|
||||
'team.enable': 'Вернуть доступ',
|
||||
'team.disabled': 'Доступ отключён',
|
||||
'team.you': 'это вы',
|
||||
'team.saved': 'Роли сохранены',
|
||||
};
|
||||
|
||||
export type BoKey = keyof typeof ru;
|
||||
type Dict = Record<BoKey, string>;
|
||||
|
||||
const en: Dict = {
|
||||
'ui.save': 'Save',
|
||||
'ui.close': 'Close',
|
||||
'ui.loading': 'Loading…',
|
||||
'ui.error': 'That didn’t work: {msg}',
|
||||
'ui.retry': 'Try again',
|
||||
'ui.copy': 'Copy',
|
||||
'ui.copied': 'Copied',
|
||||
'ui.days': '{n} d',
|
||||
'ui.yes': 'Yes',
|
||||
'ui.no': 'No',
|
||||
'ui.none': '—',
|
||||
|
||||
'err.PAYMENTS_NOT_CONFIGURED': 'Online payments aren’t connected yet — mark the payment manually.',
|
||||
'err.REFUND_TOO_BIG': 'The refund is larger than what the client paid.',
|
||||
'err.ENDS_BEFORE_START': 'A subscription can’t end before it starts.',
|
||||
'err.CANNOT_DEMOTE_SELF': 'You can’t disable yourself or remove your own super admin role.',
|
||||
'err.ROLE_FORBIDDEN': 'Your role doesn’t have access to this action.',
|
||||
'err.NO_ROLE': 'Your account has no roles in the new admin. Ask a super admin to grant them.',
|
||||
|
||||
'login.coverWord': 'team',
|
||||
'login.title': 'Team sign-in',
|
||||
'login.lead': 'Confirm sign-in in the Barev Club work bot. No password needed.',
|
||||
'login.open': 'Confirm in Telegram',
|
||||
'login.qr': 'On a computer, scan the code with your phone.',
|
||||
'login.waiting': 'Waiting for confirmation in Telegram…',
|
||||
'login.expired': 'The link has expired.',
|
||||
'login.retry': 'Get a new link',
|
||||
'login.dev': 'Local sign-in',
|
||||
'login.devHint': 'Only on this computer — the server doesn’t show this block.',
|
||||
'login.noTelegram': 'No access? Ask a super admin to add you to the team and send a Telegram link.',
|
||||
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.clients': 'Clients',
|
||||
'nav.chats': 'Chats',
|
||||
'nav.orders': 'Orders',
|
||||
'nav.providers': 'Providers',
|
||||
'nav.agencies': 'Agencies',
|
||||
'nav.venues': 'Venues',
|
||||
'nav.crm': 'CRM',
|
||||
'nav.products': 'Products',
|
||||
'nav.journal': 'Activity log',
|
||||
'nav.team': 'Team',
|
||||
'nav.more': 'More',
|
||||
'nav.soon': 'stage {n}',
|
||||
'nav.groupWork': 'Work',
|
||||
'nav.groupPartners': 'Partners',
|
||||
'nav.groupSetup': 'Setup',
|
||||
|
||||
'role.SUPERADMIN': 'Super admin',
|
||||
'role.SUPPORT': 'Support',
|
||||
'role.CONCIERGE': 'Concierge',
|
||||
'role.SALES': 'Sales',
|
||||
'roleHint.SUPERADMIN': 'Everything, including money and the team',
|
||||
'roleHint.SUPPORT': 'Support chat, orders, subscriptions',
|
||||
'roleHint.CONCIERGE': 'Concierge chat for subscribers',
|
||||
'roleHint.SALES': 'CRM: venues and agencies',
|
||||
|
||||
'shell.logout': 'Sign out',
|
||||
'shell.oldAdmin': 'Old admin',
|
||||
'shell.linkTg': 'Link Telegram — you sign in and get notifications through it.',
|
||||
'shell.linkTgBtn': 'Link Telegram',
|
||||
|
||||
'soon.title': '{section} is moving here',
|
||||
'soon.text': 'This section arrives in stage {n}. Until then, use the old admin — the data is shared.',
|
||||
'soon.open': 'Open the old admin',
|
||||
|
||||
'dash.morning': 'Good morning, {name}',
|
||||
'dash.day': 'Good afternoon, {name}',
|
||||
'dash.evening': 'Good evening, {name}',
|
||||
'dash.attention': 'Needs attention',
|
||||
'dash.allClear': 'Nothing urgent — all caught up.',
|
||||
'att.unassigned': 'Orders without a provider',
|
||||
'att.unreadChats': 'Unread messages',
|
||||
'att.webRequests': 'Website requests',
|
||||
'att.leads': 'Relocation requests',
|
||||
'att.appRequests': 'In-app requests',
|
||||
'att.lowRatings': 'Ratings of 3 or lower this week',
|
||||
'att.expiring': 'Subscriptions ending within 7 days',
|
||||
'dash.money': 'Money',
|
||||
'dash.today': 'Today',
|
||||
'dash.week': '7 days',
|
||||
'dash.month': '30 days',
|
||||
'dash.vsYesterday': 'yesterday {sum}',
|
||||
'dash.vsPrev': 'vs previous {period}',
|
||||
'dash.orders': 'orders',
|
||||
'dash.subs': 'subscriptions',
|
||||
'dash.chart': 'Daily revenue, last 30 days',
|
||||
'dash.subsTitle': 'Subscriptions',
|
||||
'dash.gifts': 'Gifts among them: {n}',
|
||||
'dash.expiring': 'Ending in the next 7 days',
|
||||
'dash.noExpiring': 'Nothing ends in the coming week.',
|
||||
'dash.conversion': 'Gift → purchase',
|
||||
'dash.conversionHint': '{paid} of {total} gifted clients later bought a subscription',
|
||||
'dash.growth': 'Growth',
|
||||
'dash.newClients': 'New clients',
|
||||
'dash.prevMonth': 'previous 30 days — {n}',
|
||||
'dash.sources': 'Where they came from, 30 days',
|
||||
'dash.funnel': 'Funnel, 30 days',
|
||||
'funnel.leads': 'Website requests',
|
||||
'funnel.clients': 'Clients in the app',
|
||||
'funnel.paying': 'Paid',
|
||||
'dash.team': 'Team and partners',
|
||||
'dash.topProviders': 'Top providers',
|
||||
'dash.bottomProviders': 'Worth a talk',
|
||||
'dash.topPartners': 'Venues: card scans, 30 days',
|
||||
'dash.salesSoon': 'Sales results will appear with the CRM.',
|
||||
'dash.empty': 'Nothing yet',
|
||||
'dash.feed': 'Feed',
|
||||
'feed.subscription': '{plan} · {source}',
|
||||
'feed.payment': 'Order #{number} paid',
|
||||
'dash.ratings': '{n} ratings',
|
||||
'dash.scans': '{n} scans',
|
||||
|
||||
'source.organic': 'Found us',
|
||||
'source.deeplink': 'Agency link',
|
||||
'source.manual_code': 'Agency code',
|
||||
'source.agency': 'Agency “{name}”',
|
||||
'source.utm': 'Ads: {name}',
|
||||
|
||||
'plan.BAREV_PLUS': 'Barev+',
|
||||
'plan.ELITE': 'Elite',
|
||||
'plan.none': 'No subscription',
|
||||
'subSource.PAID': 'Purchase',
|
||||
'subSource.MANUAL': 'Team gift',
|
||||
'subSource.AGENCY_GIFT': 'Agency gift',
|
||||
'subStatus.ACTIVE': 'Active',
|
||||
'subStatus.EXPIRED': 'Expired',
|
||||
'subStatus.CANCELLED': 'Cancelled',
|
||||
'pay.cash': 'Cash',
|
||||
'pay.transfer': 'Bank transfer',
|
||||
'pay.card': 'Card via terminal',
|
||||
'pay.link': 'Payment link',
|
||||
'segment.MOVING': 'Relocating',
|
||||
'segment.LIVING': 'Lives in Armenia',
|
||||
|
||||
'clients.title': 'Clients',
|
||||
'clients.search': 'Name, @username, phone or card number',
|
||||
'clients.found': 'Found: {n}',
|
||||
'clients.revenueTotal': 'brought {sum}',
|
||||
'col.client': 'Client',
|
||||
'col.sub': 'Subscription',
|
||||
'col.source': 'Source',
|
||||
'col.revenue': 'Brought',
|
||||
'col.since': 'Joined',
|
||||
'filter.plan': 'Subscription',
|
||||
'filter.all': 'All',
|
||||
'filter.any': 'Subscribed',
|
||||
'filter.none': 'Not subscribed',
|
||||
'filter.subSource': 'How received',
|
||||
'filter.source': 'Source',
|
||||
'filter.agency': 'Agency',
|
||||
'filter.tag': 'Tag',
|
||||
'filter.segment': 'Scenario',
|
||||
'filter.paying': 'Paid',
|
||||
'filter.payingYes': 'Have paid',
|
||||
'filter.payingNo': 'Never paid',
|
||||
'filter.expiring': 'Ends within 7 days',
|
||||
'filter.reset': 'Reset filters',
|
||||
'filter.more': 'Filters',
|
||||
'filter.anyValue': 'Any',
|
||||
'sort.label': 'Order',
|
||||
'sort.new': 'Newest first',
|
||||
'sort.revenue': 'Brought the most',
|
||||
'sort.ends': 'Ending soon',
|
||||
'segments.title': 'My segments',
|
||||
'segments.save': 'Save segment',
|
||||
'segments.name': 'Segment name',
|
||||
'segments.saved': 'Segment saved',
|
||||
'segments.remove': 'Delete segment “{name}”',
|
||||
'clients.export': 'Download for Excel',
|
||||
'clients.exported': 'File downloaded',
|
||||
'clients.broadcast': 'Broadcast',
|
||||
'clients.broadcastSoon': 'Segment broadcasts arrive in stage 5.',
|
||||
'clients.empty': 'No one matches these filters.',
|
||||
'clients.emptyAll': 'No clients yet — they appear once they message the bot.',
|
||||
'clients.prev': 'Back',
|
||||
'clients.next': 'Next',
|
||||
'clients.page': '{from}–{to} of {total}',
|
||||
|
||||
'card.back': 'All clients',
|
||||
'card.since': 'With us since {date}',
|
||||
'card.telegram': 'Open in Telegram',
|
||||
'card.cardNo': 'Card {code}',
|
||||
'card.noSub': 'No active subscription',
|
||||
'card.noSubHint': 'Gift or sell a subscription — the client gets discounts right away.',
|
||||
'card.until': 'until {date}',
|
||||
'card.daysLeft': '{n} d left',
|
||||
'card.history': 'All subscriptions',
|
||||
'card.grantedBy': 'granted by {name}',
|
||||
'card.giftFrom': 'gift from “{name}”',
|
||||
'card.paidVia': '{method}',
|
||||
'card.refunded': 'refund {sum}',
|
||||
'card.cancelledAt': 'cancelled {date}',
|
||||
'card.upcoming': 'starts {date}',
|
||||
'card.money': 'Brought',
|
||||
'card.fromOrders': 'orders',
|
||||
'card.fromSubs': 'subscriptions',
|
||||
'card.orders': 'Orders',
|
||||
'card.noOrders': 'No orders yet.',
|
||||
'card.newOrder': 'Create order',
|
||||
'card.newOrderSoon': 'Ordering on a client’s behalf arrives in stage 3, together with the orders section.',
|
||||
'card.profile': 'Profile',
|
||||
'card.source': 'Source',
|
||||
'card.agency': 'Agency',
|
||||
'card.language': 'Language',
|
||||
'card.phone': 'Phone',
|
||||
'card.segment': 'Scenario',
|
||||
'card.consent': 'Data processing consent',
|
||||
'card.tags': 'Tags',
|
||||
'card.addTag': 'New tag',
|
||||
'card.note': 'Team note',
|
||||
'card.notePh': 'The client doesn’t see this note',
|
||||
'card.saved': 'Saved',
|
||||
'card.activity': 'Client log',
|
||||
'card.noActivity': 'The team hasn’t changed anything yet.',
|
||||
'card.stats': 'Activity',
|
||||
'stat.cardUsages': 'card scans',
|
||||
'stat.leads': 'relocation requests',
|
||||
'stat.requests': 'in-app requests',
|
||||
'stat.concierge': 'chat messages',
|
||||
|
||||
'act.gift': 'Gift',
|
||||
'act.sell': 'Sell',
|
||||
'act.extend': 'Change term',
|
||||
'act.cancel': 'Cancel',
|
||||
'act.changePlan': 'Change plan',
|
||||
'act.refund': 'Refund',
|
||||
|
||||
'audit.bo.clients.gift': 'gifted a subscription',
|
||||
'audit.bo.clients.sell': 'sold a subscription',
|
||||
'audit.bo.clients.extend': 'changed the subscription term',
|
||||
'audit.bo.clients.cancel': 'cancelled a subscription',
|
||||
'audit.bo.clients.changePlan': 'changed the plan',
|
||||
'audit.bo.clients.refund': 'issued a refund',
|
||||
'audit.bo.clients.setTags': 'changed tags',
|
||||
'audit.bo.clients.setNote': 'changed the note',
|
||||
|
||||
'dlg.giftTitle': 'Gift a subscription',
|
||||
'dlg.giftHint': 'It will be marked as a gift from you.',
|
||||
'dlg.sellTitle': 'Sell a subscription',
|
||||
'dlg.plan': 'Plan',
|
||||
'dlg.period': 'Term',
|
||||
'dlg.period1': '1 month',
|
||||
'dlg.period3': '3 months',
|
||||
'dlg.period6': '6 months',
|
||||
'dlg.period12': '1 year',
|
||||
'dlg.periodCustom': 'Custom',
|
||||
'dlg.days': 'Days',
|
||||
'dlg.amount': 'Amount, ֏',
|
||||
'dlg.method': 'Paid by',
|
||||
'dlg.note': 'Note for the team',
|
||||
'dlg.linkSoon': 'Payment links turn on once a payment provider is connected.',
|
||||
'dlg.giftConfirm': 'Gift',
|
||||
'dlg.sellConfirm': 'Mark as paid',
|
||||
'dlg.extendTitle': 'Change term',
|
||||
'dlg.extendHint': 'A negative number shortens the term.',
|
||||
'dlg.newEnd': 'New end date: {date}',
|
||||
'dlg.extendConfirm': 'Save term',
|
||||
'dlg.cancelTitle': 'Cancel subscription',
|
||||
'dlg.cancelHint': 'The client loses subscription discounts and perks immediately.',
|
||||
'dlg.reason': 'Reason',
|
||||
'dlg.cancelConfirm': 'Cancel subscription',
|
||||
'dlg.planTitle': 'Change plan',
|
||||
'dlg.planConfirm': 'Change plan',
|
||||
'dlg.refundTitle': 'Refund',
|
||||
'dlg.refundPaid': 'Paid {sum}, already refunded {back}',
|
||||
'dlg.refundCancel': 'Cancel the subscription too',
|
||||
'dlg.refundConfirm': 'Issue refund',
|
||||
'done.gift': 'Subscription gifted',
|
||||
'done.sell': 'Payment recorded',
|
||||
'done.extend': 'Term changed',
|
||||
'done.cancel': 'Subscription cancelled',
|
||||
'done.changePlan': 'Plan changed',
|
||||
'done.refund': 'Refund issued',
|
||||
|
||||
'team.title': 'Team',
|
||||
'team.lead': 'Who works in the admin and what they can access. One person can have several roles.',
|
||||
'team.add': 'Add a person',
|
||||
'team.name': 'Name',
|
||||
'team.roles': 'Roles',
|
||||
'team.create': 'Add',
|
||||
'team.linkTitle': 'Link for {name}',
|
||||
'team.linkHint': 'Send it to them. They open the work bot, Telegram gets linked, and they can sign in.',
|
||||
'team.tgLinked': 'Telegram linked',
|
||||
'team.tgNotLinked': 'Telegram not linked',
|
||||
'team.newLink': 'Linking link',
|
||||
'team.disable': 'Disable access',
|
||||
'team.enable': 'Restore access',
|
||||
'team.disabled': 'Access disabled',
|
||||
'team.you': 'you',
|
||||
'team.saved': 'Roles saved',
|
||||
};
|
||||
|
||||
const hy: Dict = {
|
||||
'ui.save': 'Պահպանել',
|
||||
'ui.close': 'Փակել',
|
||||
'ui.loading': 'Բեռնվում է…',
|
||||
'ui.error': 'Չստացվեց՝ {msg}',
|
||||
'ui.retry': 'Կրկնել',
|
||||
'ui.copy': 'Պատճենել',
|
||||
'ui.copied': 'Պատճենված է',
|
||||
'ui.days': '{n} օր',
|
||||
'ui.yes': 'Այո',
|
||||
'ui.no': 'Ոչ',
|
||||
'ui.none': '—',
|
||||
|
||||
'err.PAYMENTS_NOT_CONFIGURED': 'Առցանց վճարումները դեռ միացված չեն․ նշեք վճարումը ձեռքով։',
|
||||
'err.REFUND_TOO_BIG': 'Վերադարձի գումարը մեծ է հաճախորդի վճարածից։',
|
||||
'err.ENDS_BEFORE_START': 'Բաժանորդագրությունը չի կարող ավարտվել սկսվելուց առաջ։',
|
||||
'err.CANNOT_DEMOTE_SELF': 'Չեք կարող անջատել ինքներդ ձեզ կամ հանել ձեր սուպերադմինի դերը։',
|
||||
'err.ROLE_FORBIDDEN': 'Ձեր դերը մուտք չունի այս գործողությանը։',
|
||||
'err.NO_ROLE': 'Ձեր հաշիվը նոր ադմինում դերեր չունի։ Խնդրեք սուպերադմինին տրամադրել։',
|
||||
|
||||
'login.coverWord': 'թիմ',
|
||||
'login.title': 'Մուտք թիմի համար',
|
||||
'login.lead': 'Հաստատեք մուտքը Barev Club-ի աշխատանքային բոտում։ Գաղտնաբառ պետք չէ։',
|
||||
'login.open': 'Հաստատել Telegram-ում',
|
||||
'login.qr': 'Համակարգչից հարմար է կոդը սկանավորել հեռախոսով։',
|
||||
'login.waiting': 'Սպասում ենք հաստատմանը Telegram-ում…',
|
||||
'login.expired': 'Հղումը հնացել է։',
|
||||
'login.retry': 'Ստանալ նոր հղում',
|
||||
'login.dev': 'Տեղային մուտք',
|
||||
'login.devHint': 'Միայն այս համակարգչում․ սերվերում այս բլոկը չկա։',
|
||||
'login.noTelegram': 'Մուտք չունե՞ք։ Խնդրեք սուպերադմինին ավելացնել ձեզ թիմում և ուղարկել Telegram-ի կապման հղում։',
|
||||
|
||||
'nav.dashboard': 'Վահանակ',
|
||||
'nav.clients': 'Հաճախորդներ',
|
||||
'nav.chats': 'Չատեր',
|
||||
'nav.orders': 'Պատվերներ',
|
||||
'nav.providers': 'Կատարողներ',
|
||||
'nav.agencies': 'Գործակալություններ',
|
||||
'nav.venues': 'Հաստատություններ',
|
||||
'nav.crm': 'CRM',
|
||||
'nav.products': 'Ապրանքներ',
|
||||
'nav.journal': 'Մատյան',
|
||||
'nav.team': 'Թիմ',
|
||||
'nav.more': 'Ավելին',
|
||||
'nav.soon': 'փուլ {n}',
|
||||
'nav.groupWork': 'Աշխատանք',
|
||||
'nav.groupPartners': 'Գործընկերներ',
|
||||
'nav.groupSetup': 'Կարգավորում',
|
||||
|
||||
'role.SUPERADMIN': 'Սուպերադմին',
|
||||
'role.SUPPORT': 'Աջակցություն',
|
||||
'role.CONCIERGE': 'Կոնսիերժ',
|
||||
'role.SALES': 'Վաճառք',
|
||||
'roleHint.SUPERADMIN': 'Ամեն ինչ, ներառյալ գումարներն ու թիմը',
|
||||
'roleHint.SUPPORT': 'Աջակցության չատ, պատվերներ, բաժանորդագրություններ',
|
||||
'roleHint.CONCIERGE': 'Կոնսիերժի չատ բաժանորդների համար',
|
||||
'roleHint.SALES': 'CRM՝ հաստատություններ և գործակալություններ',
|
||||
|
||||
'shell.logout': 'Ելք',
|
||||
'shell.oldAdmin': 'Հին ադմին',
|
||||
'shell.linkTg': 'Կապեք Telegram-ը․ դրանով եք մուտք գործում և ստանում ծանուցումներ։',
|
||||
'shell.linkTgBtn': 'Կապել Telegram-ը',
|
||||
|
||||
'soon.title': '{section} բաժինը տեղափոխվում է այստեղ',
|
||||
'soon.text': 'Բաժինը կհայտնվի {n}-րդ փուլում։ Մինչ այդ աշխատեք հին ադմինում․ տվյալները ընդհանուր են։',
|
||||
'soon.open': 'Բացել հին ադմինը',
|
||||
|
||||
'dash.morning': 'Բարի լույս, {name}',
|
||||
'dash.day': 'Բարի օր, {name}',
|
||||
'dash.evening': 'Բարի երեկո, {name}',
|
||||
'dash.attention': 'Պահանջում է ուշադրություն',
|
||||
'dash.allClear': 'Շտապ ոչինչ չկա․ ամեն ինչ արված է։',
|
||||
'att.unassigned': 'Պատվերներ առանց կատարողի',
|
||||
'att.unreadChats': 'Չկարդացված հաղորդագրություններ',
|
||||
'att.webRequests': 'Հայտեր կայքից',
|
||||
'att.leads': 'Տեղափոխման հայտեր',
|
||||
'att.appRequests': 'Հայտեր հավելվածից',
|
||||
'att.lowRatings': '3 և ցածր գնահատականներ այս շաբաթ',
|
||||
'att.expiring': 'Ավարտվում են 7 օրում',
|
||||
'dash.money': 'Գումար',
|
||||
'dash.today': 'Այսօր',
|
||||
'dash.week': '7 օր',
|
||||
'dash.month': '30 օր',
|
||||
'dash.vsYesterday': 'երեկ՝ {sum}',
|
||||
'dash.vsPrev': 'նախորդ {period}-ի համեմատ',
|
||||
'dash.orders': 'պատվերներ',
|
||||
'dash.subs': 'բաժանորդագրություններ',
|
||||
'dash.chart': 'Եկամուտն ըստ օրերի, 30 օր',
|
||||
'dash.subsTitle': 'Բաժանորդագրություններ',
|
||||
'dash.gifts': 'Որից նվեր՝ {n}',
|
||||
'dash.expiring': 'Ավարտվում են առաջիկա 7 օրում',
|
||||
'dash.noExpiring': 'Առաջիկա շաբաթ ոչինչ չի ավարտվում։',
|
||||
'dash.conversion': 'Նվեր → գնում',
|
||||
'dash.conversionHint': 'Նվեր ստացած {total} հաճախորդից {paid}-ը հետո գնել են',
|
||||
'dash.growth': 'Աճ',
|
||||
'dash.newClients': 'Նոր հաճախորդներ',
|
||||
'dash.prevMonth': 'նախորդ 30 օրում՝ {n}',
|
||||
'dash.sources': 'Որտեղից են եկել, 30 օր',
|
||||
'dash.funnel': 'Ձագար, 30 օր',
|
||||
'funnel.leads': 'Հայտեր կայքից',
|
||||
'funnel.clients': 'Հաճախորդներ հավելվածում',
|
||||
'funnel.paying': 'Վճարել են',
|
||||
'dash.team': 'Թիմ և գործընկերներ',
|
||||
'dash.topProviders': 'Լավագույն կատարողներ',
|
||||
'dash.bottomProviders': 'Արժե խոսել',
|
||||
'dash.topPartners': 'Հաստատություններ՝ քարտի սկանավորումներ, 30 օր',
|
||||
'dash.salesSoon': 'Վաճառողների արդյունքները կհայտնվեն CRM-ի հետ։',
|
||||
'dash.empty': 'Դեռ դատարկ է',
|
||||
'dash.feed': 'Լրահոս',
|
||||
'feed.subscription': '{plan} · {source}',
|
||||
'feed.payment': '№{number} պատվերը վճարված է',
|
||||
'dash.ratings': '{n} գնահատական',
|
||||
'dash.scans': '{n} սկան',
|
||||
|
||||
'source.organic': 'Ինքն են գտել',
|
||||
'source.deeplink': 'Գործակալության հղում',
|
||||
'source.manual_code': 'Գործակալության կոդ',
|
||||
'source.agency': '«{name}» գործակալություն',
|
||||
'source.utm': 'Գովազդ՝ {name}',
|
||||
|
||||
'plan.BAREV_PLUS': 'Barev+',
|
||||
'plan.ELITE': 'Elite',
|
||||
'plan.none': 'Առանց բաժանորդագրության',
|
||||
'subSource.PAID': 'Գնում',
|
||||
'subSource.MANUAL': 'Թիմի նվեր',
|
||||
'subSource.AGENCY_GIFT': 'Գործակալության նվեր',
|
||||
'subStatus.ACTIVE': 'Ակտիվ',
|
||||
'subStatus.EXPIRED': 'Ավարտված',
|
||||
'subStatus.CANCELLED': 'Չեղարկված',
|
||||
'pay.cash': 'Կանխիկ',
|
||||
'pay.transfer': 'Փոխանցում',
|
||||
'pay.card': 'Քարտ տերմինալով',
|
||||
'pay.link': 'Վճարման հղում',
|
||||
'segment.MOVING': 'Տեղափոխվում է',
|
||||
'segment.LIVING': 'Ապրում է Հայաստանում',
|
||||
|
||||
'clients.title': 'Հաճախորդներ',
|
||||
'clients.search': 'Անուն, @username, հեռախոս կամ քարտի համար',
|
||||
'clients.found': 'Գտնվել է՝ {n}',
|
||||
'clients.revenueTotal': 'բերել են {sum}',
|
||||
'col.client': 'Հաճախորդ',
|
||||
'col.sub': 'Բաժանորդագրություն',
|
||||
'col.source': 'Աղբյուր',
|
||||
'col.revenue': 'Բերել է',
|
||||
'col.since': 'Մեզ հետ է',
|
||||
'filter.plan': 'Բաժանորդագրություն',
|
||||
'filter.all': 'Բոլորը',
|
||||
'filter.any': 'Բաժանորդագրությամբ',
|
||||
'filter.none': 'Առանց բաժանորդագրության',
|
||||
'filter.subSource': 'Ինչպես է ստացել',
|
||||
'filter.source': 'Աղբյուր',
|
||||
'filter.agency': 'Գործակալություն',
|
||||
'filter.tag': 'Պիտակ',
|
||||
'filter.segment': 'Սցենար',
|
||||
'filter.paying': 'Վճարել են',
|
||||
'filter.payingYes': 'Վճարել են',
|
||||
'filter.payingNo': 'Երբեք չեն վճարել',
|
||||
'filter.expiring': 'Ավարտվում է 7 օրում',
|
||||
'filter.reset': 'Զրոյացնել ֆիլտրերը',
|
||||
'filter.more': 'Ֆիլտրեր',
|
||||
'filter.anyValue': 'Ցանկացած',
|
||||
'sort.label': 'Հերթականություն',
|
||||
'sort.new': 'Նախ նորերը',
|
||||
'sort.revenue': 'Ամենաշատը բերածները',
|
||||
'sort.ends': 'Շուտով ավարտվող',
|
||||
'segments.title': 'Իմ ընտրանիները',
|
||||
'segments.save': 'Պահպանել ընտրանին',
|
||||
'segments.name': 'Ընտրանու անունը',
|
||||
'segments.saved': 'Ընտրանին պահպանված է',
|
||||
'segments.remove': 'Ջնջել «{name}» ընտրանին',
|
||||
'clients.export': 'Ներբեռնել Excel-ի համար',
|
||||
'clients.exported': 'Ֆայլը ներբեռնված է',
|
||||
'clients.broadcast': 'Առաքում',
|
||||
'clients.broadcastSoon': 'Ըստ ընտրանու առաքումները կհայտնվեն 5-րդ փուլում։',
|
||||
'clients.empty': 'Այս ֆիլտրերին ոչ ոք չի համապատասխանում։',
|
||||
'clients.emptyAll': 'Հաճախորդներ դեռ չկան․ կհայտնվեն, երբ գրեն բոտին։',
|
||||
'clients.prev': 'Հետ',
|
||||
'clients.next': 'Առաջ',
|
||||
'clients.page': '{from}–{to}՝ {total}-ից',
|
||||
|
||||
'card.back': 'Բոլոր հաճախորդները',
|
||||
'card.since': 'Մեզ հետ է {date}-ից',
|
||||
'card.telegram': 'Բացել Telegram-ում',
|
||||
'card.cardNo': 'Քարտ {code}',
|
||||
'card.noSub': 'Ակտիվ բաժանորդագրություն չկա',
|
||||
'card.noSubHint': 'Նվիրեք կամ վաճառեք բաժանորդագրություն․ հաճախորդը միանգամից կստանա զեղչեր։',
|
||||
'card.until': 'մինչև {date}',
|
||||
'card.daysLeft': 'մնաց {n} օր',
|
||||
'card.history': 'Բոլոր բաժանորդագրությունները',
|
||||
'card.grantedBy': 'տրամադրել է {name}',
|
||||
'card.giftFrom': 'նվեր «{name}»-ից',
|
||||
'card.paidVia': '{method}',
|
||||
'card.refunded': 'վերադարձ {sum}',
|
||||
'card.cancelledAt': 'չեղարկված {date}',
|
||||
'card.upcoming': 'կսկսվի {date}',
|
||||
'card.money': 'Բերել է',
|
||||
'card.fromOrders': 'պատվերներ',
|
||||
'card.fromSubs': 'բաժանորդագրություններ',
|
||||
'card.orders': 'Պատվերներ',
|
||||
'card.noOrders': 'Պատվերներ դեռ չկան։',
|
||||
'card.newOrder': 'Ստեղծել պատվեր',
|
||||
'card.newOrderSoon': 'Պատվեր հաճախորդի անունից՝ 3-րդ փուլում, պատվերների բաժնի հետ։',
|
||||
'card.profile': 'Պրոֆիլ',
|
||||
'card.source': 'Որտեղից է եկել',
|
||||
'card.agency': 'Գործակալություն',
|
||||
'card.language': 'Լեզու',
|
||||
'card.phone': 'Հեռախոս',
|
||||
'card.segment': 'Սցենար',
|
||||
'card.consent': 'Տվյալների մշակման համաձայնություն',
|
||||
'card.tags': 'Պիտակներ',
|
||||
'card.addTag': 'Նոր պիտակ',
|
||||
'card.note': 'Թիմի նշում',
|
||||
'card.notePh': 'Հաճախորդը չի տեսնում այս նշումը',
|
||||
'card.saved': 'Պահպանված է',
|
||||
'card.activity': 'Հաճախորդի մատյան',
|
||||
'card.noActivity': 'Թիմը դեռ ոչինչ չի փոխել։',
|
||||
'card.stats': 'Ակտիվություն',
|
||||
'stat.cardUsages': 'քարտի սկան',
|
||||
'stat.leads': 'տեղափոխման հայտ',
|
||||
'stat.requests': 'հայտ հավելվածից',
|
||||
'stat.concierge': 'հաղորդագրություն չատում',
|
||||
|
||||
'act.gift': 'Նվիրել',
|
||||
'act.sell': 'Վաճառել',
|
||||
'act.extend': 'Փոխել ժամկետը',
|
||||
'act.cancel': 'Չեղարկել',
|
||||
'act.changePlan': 'Փոխել սակագինը',
|
||||
'act.refund': 'Վերադարձնել գումարը',
|
||||
|
||||
'audit.bo.clients.gift': 'նվիրել է բաժանորդագրություն',
|
||||
'audit.bo.clients.sell': 'վաճառել է բաժանորդագրություն',
|
||||
'audit.bo.clients.extend': 'փոխել է ժամկետը',
|
||||
'audit.bo.clients.cancel': 'չեղարկել է բաժանորդագրությունը',
|
||||
'audit.bo.clients.changePlan': 'փոխել է սակագինը',
|
||||
'audit.bo.clients.refund': 'ձևակերպել է վերադարձ',
|
||||
'audit.bo.clients.setTags': 'փոխել է պիտակները',
|
||||
'audit.bo.clients.setNote': 'փոխել է նշումը',
|
||||
|
||||
'dlg.giftTitle': 'Նվիրել բաժանորդագրություն',
|
||||
'dlg.giftHint': 'Այն կնշվի որպես ձեր նվերը։',
|
||||
'dlg.sellTitle': 'Վաճառել բաժանորդագրություն',
|
||||
'dlg.plan': 'Սակագին',
|
||||
'dlg.period': 'Ժամկետ',
|
||||
'dlg.period1': '1 ամիս',
|
||||
'dlg.period3': '3 ամիս',
|
||||
'dlg.period6': '6 ամիս',
|
||||
'dlg.period12': '1 տարի',
|
||||
'dlg.periodCustom': 'Այլ',
|
||||
'dlg.days': 'Օր',
|
||||
'dlg.amount': 'Գումար, ֏',
|
||||
'dlg.method': 'Ինչպես է վճարել',
|
||||
'dlg.note': 'Մեկնաբանություն թիմի համար',
|
||||
'dlg.linkSoon': 'Վճարման հղումը կմիացնենք վճարային համակարգը միացնելուց հետո։',
|
||||
'dlg.giftConfirm': 'Նվիրել',
|
||||
'dlg.sellConfirm': 'Նշել վճարումը',
|
||||
'dlg.extendTitle': 'Փոխել ժամկետը',
|
||||
'dlg.extendHint': 'Բացասական թիվը կրճատում է ժամկետը։',
|
||||
'dlg.newEnd': 'Նոր ավարտի ամսաթիվ՝ {date}',
|
||||
'dlg.extendConfirm': 'Պահպանել ժամկետը',
|
||||
'dlg.cancelTitle': 'Չեղարկել բաժանորդագրությունը',
|
||||
'dlg.cancelHint': 'Հաճախորդը միանգամից կկորցնի զեղչերն ու արտոնությունները։',
|
||||
'dlg.reason': 'Պատճառ',
|
||||
'dlg.cancelConfirm': 'Չեղարկել բաժանորդագրությունը',
|
||||
'dlg.planTitle': 'Փոխել սակագինը',
|
||||
'dlg.planConfirm': 'Փոխել սակագինը',
|
||||
'dlg.refundTitle': 'Վերադարձնել գումարը',
|
||||
'dlg.refundPaid': 'Վճարված է {sum}, արդեն վերադարձված՝ {back}',
|
||||
'dlg.refundCancel': 'Միանգամից չեղարկել բաժանորդագրությունը',
|
||||
'dlg.refundConfirm': 'Ձևակերպել վերադարձ',
|
||||
'done.gift': 'Բաժանորդագրությունը նվիրված է',
|
||||
'done.sell': 'Վճարումը նշված է',
|
||||
'done.extend': 'Ժամկետը փոխված է',
|
||||
'done.cancel': 'Բաժանորդագրությունը չեղարկված է',
|
||||
'done.changePlan': 'Սակագինը փոխված է',
|
||||
'done.refund': 'Վերադարձը ձևակերպված է',
|
||||
|
||||
'team.title': 'Թիմ',
|
||||
'team.lead': 'Ով է աշխատում ադմինում և ինչ հասանելիություն ունի։ Մեկ մարդը կարող է ունենալ մի քանի դեր։',
|
||||
'team.add': 'Ավելացնել մարդու',
|
||||
'team.name': 'Անուն',
|
||||
'team.roles': 'Դերեր',
|
||||
'team.create': 'Ավելացնել',
|
||||
'team.linkTitle': 'Հղում {name}-ի համար',
|
||||
'team.linkHint': 'Ուղարկեք նրան։ Կբացի աշխատանքային բոտը, Telegram-ը կկապվի, և կարող է մուտք գործել։',
|
||||
'team.tgLinked': 'Telegram-ը կապված է',
|
||||
'team.tgNotLinked': 'Telegram-ը կապված չէ',
|
||||
'team.newLink': 'Կապման հղում',
|
||||
'team.disable': 'Անջատել մուտքը',
|
||||
'team.enable': 'Վերադարձնել մուտքը',
|
||||
'team.disabled': 'Մուտքն անջատված է',
|
||||
'team.you': 'դուք',
|
||||
'team.saved': 'Դերերը պահպանված են',
|
||||
};
|
||||
|
||||
const DICTS: Record<BoLocale, Dict> = { ru, en, hy };
|
||||
|
||||
export type T = (key: BoKey | (string & {}), params?: Record<string, string | number | null | undefined>) => string;
|
||||
|
||||
function makeT(locale: BoLocale): T {
|
||||
const dict = DICTS[locale];
|
||||
return (key, params) => {
|
||||
let s: string = (dict as Record<string, string>)[key] ?? (ru as Record<string, string>)[key] ?? key;
|
||||
if (params) for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v ?? ''));
|
||||
return s;
|
||||
};
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
locale: BoLocale;
|
||||
setLocale: (l: BoLocale) => void;
|
||||
t: T;
|
||||
/** общие переводы проекта: services.<code>.name и т.п. */
|
||||
shared: (key: string) => string;
|
||||
amd: (n: number) => string;
|
||||
date: (d: Date) => string;
|
||||
dateTime: (d: Date) => string;
|
||||
/** сокращённая сумма для плотных мест: 1,2 млн */
|
||||
amdShort: (n: number) => string;
|
||||
}
|
||||
|
||||
const I18nCtx = createContext<Ctx | null>(null);
|
||||
const KEY = 'barev.bo.locale';
|
||||
|
||||
function initialLocale(): BoLocale {
|
||||
try {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
if (saved && (BO_LOCALES as readonly string[]).includes(saved)) return saved as BoLocale;
|
||||
} catch {
|
||||
/* приватный режим */
|
||||
}
|
||||
const nav = navigator.language.slice(0, 2);
|
||||
return (BO_LOCALES as readonly string[]).includes(nav) ? (nav as BoLocale) : 'ru';
|
||||
}
|
||||
|
||||
export function BoI18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<BoLocale>(initialLocale);
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
const value = useMemo<Ctx>(() => {
|
||||
const sharedT = createSharedT(locale as SharedLocale);
|
||||
const intl = locale === 'en' ? 'en-GB' : locale === 'hy' ? 'hy-AM' : 'ru-RU';
|
||||
const compact = new Intl.NumberFormat(intl, { notation: 'compact', maximumFractionDigits: 1 });
|
||||
return {
|
||||
locale,
|
||||
setLocale: (l) => {
|
||||
setLocaleState(l);
|
||||
try {
|
||||
localStorage.setItem(KEY, l);
|
||||
} catch {
|
||||
/* приватный режим */
|
||||
}
|
||||
},
|
||||
t: makeT(locale),
|
||||
shared: sharedT,
|
||||
amd: (n) => formatAmd(locale as SharedLocale, n),
|
||||
amdShort: (n) => `${compact.format(n)} ֏`,
|
||||
date: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', year: 'numeric' }),
|
||||
dateTime: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
|
||||
};
|
||||
}, [locale]);
|
||||
return <I18nCtx.Provider value={value}>{children}</I18nCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useT() {
|
||||
const v = useContext(I18nCtx);
|
||||
if (!v) throw new Error('BoI18nProvider missing');
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Текст ошибки API: код бизнес-ошибки → перевод, иначе сообщение */
|
||||
export function errText(t: T, err: unknown): string {
|
||||
const msg = (err as { message?: string })?.message ?? '';
|
||||
const known = `err.${msg}`;
|
||||
const tr = t(known);
|
||||
return tr !== known ? tr : t('ui.error', { msg: msg || '—' });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import './styles.css';
|
||||
import { ApiProvider, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import { BoI18nProvider } from './i18n.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApiProvider withCredentials>
|
||||
<BoI18nProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BoI18nProvider>
|
||||
</ApiProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { errText, useT, type BoKey } from '../i18n.tsx';
|
||||
import { Avatar, I, PlanBadge, Skeleton, sourceLabel } from '../components/kit.tsx';
|
||||
import { actionLabel } from './Dashboard.tsx';
|
||||
import { SubDialog, type SubMode, type SubTarget } from './SubDialog.tsx';
|
||||
|
||||
const FLOWS = ['CLEANING', 'REPAIR', 'TRANSFER', 'LANGUAGE', 'CAREER', 'DOCUMENTS'];
|
||||
const DAY = 86_400_000;
|
||||
|
||||
export function ClientCard({ id, me }: { id: string; me: Me }) {
|
||||
const { t, amd, date, dateTime, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const c = useQuery(trpc.bo.clients.get.queryOptions({ id }));
|
||||
const [dlg, setDlg] = useState<{ mode: SubMode; sub?: SubTarget } | null>(null);
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
const canManage = isSuper || me.roles.includes('SUPPORT');
|
||||
|
||||
if (c.isError)
|
||||
return (
|
||||
<>
|
||||
<a className="bo-back" href="#/clients">
|
||||
<I name="back" size={16} />
|
||||
{t('card.back')}
|
||||
</a>
|
||||
<div className="bo-panel bo-empty">{errText(t, c.error)}</div>
|
||||
</>
|
||||
);
|
||||
if (!c.data)
|
||||
return (
|
||||
<div className="bo-col">
|
||||
<Skeleton h={80} />
|
||||
<Skeleton h={220} />
|
||||
<Skeleton h={300} />
|
||||
</div>
|
||||
);
|
||||
const d = c.data;
|
||||
const current = d.subscriptions.find((s) => s.active && s.plan === 'ELITE') ?? d.subscriptions.find((s) => s.active);
|
||||
const statusText = (status: string) => {
|
||||
for (const f of FLOWS) {
|
||||
const k = `status.${f}.${status}`;
|
||||
const v = shared(k);
|
||||
if (v !== k) return v;
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<a className="bo-back" href="#/clients">
|
||||
<I name="back" size={16} />
|
||||
{t('card.back')}
|
||||
</a>
|
||||
|
||||
<header className="bo-profile">
|
||||
<Avatar name={d.name} id={d.id} size={64} />
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
|
||||
{d.name}
|
||||
</h1>
|
||||
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 14px' }}>
|
||||
{d.username && <span>@{d.username}</span>}
|
||||
{d.phone && <span>{d.phone}</span>}
|
||||
<span>{t('card.since', { date: date(d.createdAt) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
{d.username && (
|
||||
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${d.username}`} target="_blank" rel="noreferrer">
|
||||
<I name="telegram" size={16} />
|
||||
{t('card.telegram')}
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="bo-client-grid">
|
||||
<div className="bo-col">
|
||||
{/* посадочный талон текущей подписки */}
|
||||
<section>
|
||||
<div className={`bo-pass bo-pass--${current?.plan ?? 'none'}`}>
|
||||
<div className="bo-pass__main">
|
||||
<div className="bo-pass__plan">{current ? t(`plan.${current.plan}`) : t('card.noSub')}</div>
|
||||
<div className="bo-pass__meta">
|
||||
{current ? (
|
||||
<>
|
||||
<span>{t(`subSource.${current.source}`)}</span>
|
||||
{current.grantedBy && <span>{t('card.grantedBy', { name: current.grantedBy })}</span>}
|
||||
{current.agency && <span>{t('card.giftFrom', { name: current.agency })}</span>}
|
||||
{current.payMethod && <span>{t(`pay.${current.payMethod}`)}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span>{t('card.noSubHint')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bo-pass__stub">
|
||||
{current ? (
|
||||
<div>
|
||||
<div className="bo-pass__date">{date(current.endsAt)}</div>
|
||||
<div className="bo-pass__left">{t('card.daysLeft', { n: Math.max(0, Math.ceil((current.endsAt.getTime() - Date.now()) / DAY)) })}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="bo-pass__card">{t('card.cardNo', { code: d.cardCode })}</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="bo-actions">
|
||||
<button type="button" className="bo-btn bo-btn--accent bo-btn--sm" onClick={() => setDlg({ mode: 'gift' })}>
|
||||
<I name="gift" size={16} />
|
||||
{t('act.gift')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setDlg({ mode: 'sell' })}>
|
||||
{t('act.sell')}
|
||||
</button>
|
||||
{current && (
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({ mode: 'extend', sub: current })}>
|
||||
{t('act.extend')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({ mode: 'plan', sub: current })}>
|
||||
{t('act.changePlan')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--danger bo-btn--sm" onClick={() => setDlg({ mode: 'cancel', sub: current })}>
|
||||
{t('act.cancel')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{d.subscriptions.length > 0 && (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.history')}</h2>
|
||||
</div>
|
||||
{d.subscriptions.map((s) => {
|
||||
const upcoming = s.status === 'ACTIVE' && s.startsAt.getTime() > Date.now();
|
||||
const off = !s.active && !upcoming;
|
||||
return (
|
||||
<div key={s.id} className={`bo-subrow${off ? ' bo-subrow--off' : ''}`}>
|
||||
<PlanBadge plan={s.plan} t={t} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{date(s.startsAt)} — {date(s.endsAt)}
|
||||
{s.amount != null && <span className="bo-num" style={{ marginLeft: 10 }}>{amd(s.amount)}</span>}
|
||||
</div>
|
||||
<div className="bo-subrow__meta">
|
||||
{[
|
||||
upcoming ? t('card.upcoming', { date: date(s.startsAt) }) : t(`subStatus.${s.status}`),
|
||||
t(`subSource.${s.source}`),
|
||||
s.grantedBy && t('card.grantedBy', { name: s.grantedBy }),
|
||||
s.agency && t('card.giftFrom', { name: s.agency }),
|
||||
s.payMethod && t(`pay.${s.payMethod}`),
|
||||
s.refundAmount ? t('card.refunded', { sum: amd(s.refundAmount) }) : null,
|
||||
s.cancelledAt && t('card.cancelledAt', { date: date(s.cancelledAt) }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</div>
|
||||
{s.note && <div className="bo-subrow__meta" style={{ whiteSpace: 'pre-line', marginTop: 2 }}>{s.note}</div>}
|
||||
</div>
|
||||
<div className="bo-subrow__menu">
|
||||
{canManage && s.status === 'ACTIVE' && !s.active && upcoming && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({ mode: 'cancel', sub: s })}>
|
||||
{t('act.cancel')}
|
||||
</button>
|
||||
)}
|
||||
{isSuper && s.amount != null && s.amount > (s.refundAmount ?? 0) && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({ mode: 'refund', sub: s })}>
|
||||
{t('act.refund')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.orders')}</h2>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled title={t('card.newOrderSoon')}>
|
||||
<I name="plus" size={16} />
|
||||
{t('card.newOrder')}
|
||||
</button>
|
||||
</div>
|
||||
{d.orders.length === 0 ? (
|
||||
<p className="bo-hint">{t('card.noOrders')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.orders.map((o) => (
|
||||
<li key={o.id} style={{ flexWrap: 'wrap' }}>
|
||||
<span className="bo-num bo-faint" style={{ width: 52 }}>
|
||||
№{o.number}
|
||||
</span>
|
||||
<span className="bo-grow" style={{ minWidth: 140 }}>
|
||||
<b style={{ fontWeight: 600 }}>{shared(`services.${o.service}.name`)}</b>
|
||||
<span className="bo-muted bo-small" style={{ display: 'block' }}>
|
||||
{dateTime(o.scheduledAt)}
|
||||
{o.provider && `, ${o.provider}`}
|
||||
{o.rating && ` ★ ${o.rating}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="bo-status" style={{ ['--dot' as string]: o.status === 'CANCELLED' ? 'var(--bc-danger)' : o.status === 'DONE' || o.status === 'COMPLETED' ? 'var(--bc-ok)' : 'var(--bc-accent)' }}>
|
||||
{statusText(o.status)}
|
||||
</span>
|
||||
{o.total != null && (
|
||||
<span style={{ textAlign: 'right', minWidth: 96 }}>
|
||||
<b className="bo-num">{amd(o.total)}</b>
|
||||
<span className="bo-small bo-muted" style={{ display: 'block' }}>
|
||||
{shared(`payment.${o.paymentStatus}`)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="bo-col">
|
||||
{d.money && (
|
||||
<section className="bo-panel bo-money-card">
|
||||
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
|
||||
{t('card.money')}
|
||||
</span>
|
||||
<div className="bo-bignum" style={{ margin: '6px 0 8px' }}>
|
||||
{amd(d.money.total)}
|
||||
</div>
|
||||
<div className="bo-small" style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<span>
|
||||
{t('card.fromSubs')} <b className="bo-num">{amd(d.money.subs)}</b>
|
||||
</span>
|
||||
<span>
|
||||
{t('card.fromOrders')} <b className="bo-num">{amd(d.money.orders)}</b>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<TagsAndNote id={d.id} tags={d.tags} note={d.adminNote ?? ''} />
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.profile')}</h2>
|
||||
</div>
|
||||
<dl className="bo-kv">
|
||||
<dt>{t('card.source')}</dt>
|
||||
<dd>{sourceLabel(t, d)}</dd>
|
||||
{d.agency && (
|
||||
<>
|
||||
<dt>{t('card.agency')}</dt>
|
||||
<dd>
|
||||
{d.agency.name}
|
||||
{d.agencyAttachedAt && <span className="bo-faint"> ({date(d.agencyAttachedAt)})</span>}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('card.language')}</dt>
|
||||
<dd>{d.locale.toUpperCase()}</dd>
|
||||
{d.segment && (
|
||||
<>
|
||||
<dt>{t('card.segment')}</dt>
|
||||
<dd>{t(`segment.${d.segment}`)}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('card.consent')}</dt>
|
||||
<dd>{d.consentAt ? date(d.consentAt) : t('ui.no')}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.stats')}</h2>
|
||||
</div>
|
||||
<div className="bo-stats">
|
||||
{(
|
||||
[
|
||||
['cardUsages', d.counts.cardUsages],
|
||||
['concierge', d.counts.concierge],
|
||||
['requests', d.counts.requests],
|
||||
['leads', d.counts.leads],
|
||||
] as [string, number][]
|
||||
).map(([k, n]) => (
|
||||
<div key={k}>
|
||||
<b>{n}</b>
|
||||
<span className="bo-small bo-muted">{t(`stat.${k}` as BoKey)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.activity')}</h2>
|
||||
</div>
|
||||
{d.history.length === 0 ? (
|
||||
<p className="bo-hint">{t('card.noActivity')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.history.map((h) => (
|
||||
<li key={h.id} style={{ alignItems: 'flex-start' }}>
|
||||
<span className="bo-feed__time">{dateTime(h.at)}</span>
|
||||
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
|
||||
<b>{h.by ?? '—'}</b> {actionLabel(t, h.action)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dlg && <SubDialog clientId={d.id} mode={dlg.mode} sub={dlg.sub} current={current} onClose={() => setDlg(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsAndNote({ id, tags, note }: { id: string; tags: string[]; note: string }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [draftTag, setDraftTag] = useState('');
|
||||
const [text, setText] = useState(note);
|
||||
useEffect(() => {
|
||||
setText(note);
|
||||
}, [note]);
|
||||
const facets = useQuery(trpc.bo.clients.facets.queryOptions());
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.clients.get.queryKey({ id }) });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.clients.facets.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.clients.list.queryKey() });
|
||||
};
|
||||
const setTags = useMutation(trpc.bo.clients.setTags.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
|
||||
const setNote = useMutation(
|
||||
trpc.bo.clients.setNote.mutationOptions({
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
toast(t('card.saved'));
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const add = () => {
|
||||
const v = draftTag.trim();
|
||||
if (v && !tags.includes(v)) setTags.mutate({ id, tags: [...tags, v] });
|
||||
setDraftTag('');
|
||||
};
|
||||
const suggestions = (facets.data?.tags ?? []).map((x) => x.tag).filter((x) => !tags.includes(x));
|
||||
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.tags')}</h2>
|
||||
</div>
|
||||
<div className="bo-tags" style={{ marginBottom: 10 }}>
|
||||
{tags.map((tg) => (
|
||||
<span key={tg} className="bo-tag" style={{ background: 'var(--bc-learn)', color: 'var(--bc-learn-ink)' }}>
|
||||
{tg}
|
||||
<button type="button" aria-label={`× ${tg}`} onClick={() => setTags.mutate({ id, tags: tags.filter((x) => x !== tg) })}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}}
|
||||
>
|
||||
<input className="bo-input" list={`tags-${id}`} value={draftTag} onChange={(e) => setDraftTag(e.target.value)} placeholder={t('card.addTag')} maxLength={40} style={{ minHeight: 36, padding: '6px 12px' }} />
|
||||
<datalist id={`tags-${id}`}>
|
||||
{suggestions.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
<button type="submit" className="bo-btn bo-btn--sm" disabled={!draftTag.trim()} aria-label={t('card.addTag')}>
|
||||
<I name="plus" size={16} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<h3 className="bo-h3" style={{ margin: '20px 0 8px' }}>
|
||||
{t('card.note')}
|
||||
</h3>
|
||||
<textarea className="bo-textarea" value={text} placeholder={t('card.notePh')} onChange={(e) => setText(e.target.value)} maxLength={4000} />
|
||||
{text !== note && (
|
||||
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 8 }} onClick={() => setNote.mutate({ id, note: text })} disabled={setNote.isPending}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { navigate, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { Avatar, Dialog, download, Field, I, PlanBadge, Skeleton, sourceLabel } from '../components/kit.tsx';
|
||||
|
||||
type Plan = 'any' | 'none' | 'BAREV_PLUS' | 'ELITE';
|
||||
interface Filters {
|
||||
search?: string;
|
||||
plan?: Plan;
|
||||
subSource?: 'PAID' | 'MANUAL' | 'AGENCY_GIFT';
|
||||
source?: string;
|
||||
agencyId?: string;
|
||||
tag?: string;
|
||||
expiringDays?: number;
|
||||
segment?: 'MOVING' | 'LIVING';
|
||||
paying?: 'yes' | 'no';
|
||||
}
|
||||
type Sort = 'new' | 'revenue' | 'ends';
|
||||
|
||||
const FILTER_KEYS = ['search', 'plan', 'subSource', 'source', 'agencyId', 'tag', 'segment', 'paying'] as const;
|
||||
|
||||
function readQuery(q: URLSearchParams): { f: Filters; sort: Sort; page: number } {
|
||||
const f: Filters = {};
|
||||
for (const k of FILTER_KEYS) {
|
||||
const v = q.get(k);
|
||||
if (v) (f as Record<string, string>)[k] = v;
|
||||
}
|
||||
const exp = Number(q.get('expiring'));
|
||||
if (exp > 0) f.expiringDays = exp;
|
||||
return { f, sort: (q.get('sort') as Sort) || 'new', page: Number(q.get('page')) || 0 };
|
||||
}
|
||||
|
||||
function writeQuery(f: Filters, sort: Sort, page: number) {
|
||||
const q = new URLSearchParams();
|
||||
for (const k of FILTER_KEYS) if (f[k]) q.set(k, String(f[k]));
|
||||
if (f.expiringDays) q.set('expiring', String(f.expiringDays));
|
||||
if (sort !== 'new') q.set('sort', sort);
|
||||
if (page) q.set('page', String(page));
|
||||
const s = q.toString();
|
||||
navigate(`/clients${s ? `?${s}` : ''}`, { replace: true });
|
||||
}
|
||||
|
||||
export function Clients({ me }: { me: Me }) {
|
||||
const { t, amd, date } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const { query } = useLocation();
|
||||
const { f, sort, page } = useMemo(() => readQuery(query), [query]);
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
|
||||
const [search, setSearch] = useState(f.search ?? '');
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => {
|
||||
if ((f.search ?? '') !== search) writeQuery({ ...f, search: search || undefined }, sort, 0);
|
||||
}, 300);
|
||||
return () => clearTimeout(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search]);
|
||||
|
||||
const set = (patch: Partial<Filters>) => writeQuery({ ...f, ...patch }, sort, 0);
|
||||
const activeCount = Object.entries(f).filter(([k, v]) => k !== 'search' && v !== undefined).length;
|
||||
const [showFilters, setShowFilters] = useState(activeCount > 1 || !!(f.source || f.agencyId || f.tag || f.segment || f.subSource));
|
||||
|
||||
const list = useQuery({ ...trpc.bo.clients.list.queryOptions({ ...f, sort, page }), placeholderData: keepPreviousData });
|
||||
const facets = useQuery(trpc.bo.clients.facets.queryOptions());
|
||||
const segments = useQuery(trpc.bo.segments.list.queryOptions());
|
||||
const removeSeg = useMutation(trpc.bo.segments.remove.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.segments.list.queryKey() }) }));
|
||||
const exportCsv = useMutation(
|
||||
trpc.bo.clients.exportCsv.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
download(r.filename, r.content);
|
||||
toast(t('clients.exported'));
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
|
||||
const data = list.data;
|
||||
const from = data ? page * data.pageSize + 1 : 0;
|
||||
const to = data ? Math.min(data.total, (page + 1) * data.pageSize) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<div>
|
||||
<h1 className="bo-title">{t('clients.title')}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" title={t('clients.broadcastSoon')} disabled>
|
||||
<I name="send" size={16} />
|
||||
{t('clients.broadcast')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => exportCsv.mutate(f)} disabled={exportCsv.isPending}>
|
||||
<I name="download" size={16} />
|
||||
{t('clients.export')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bo-toolbar">
|
||||
<label className="bo-search">
|
||||
<I name="search" size={18} />
|
||||
<span className="bo-visually-hidden">{t('clients.search')}</span>
|
||||
<input className="bo-input" type="search" value={search} placeholder={t('clients.search')} onChange={(e) => setSearch(e.target.value)} />
|
||||
</label>
|
||||
<div className="bo-pills" role="group" aria-label={t('filter.plan')}>
|
||||
{(
|
||||
[
|
||||
[undefined, t('filter.all')],
|
||||
['any', t('filter.any')],
|
||||
['BAREV_PLUS', t('plan.BAREV_PLUS')],
|
||||
['ELITE', t('plan.ELITE')],
|
||||
['none', t('filter.none')],
|
||||
] as [Plan | undefined, string][]
|
||||
).map(([v, label]) => (
|
||||
<button key={label} type="button" className="bo-pill" aria-pressed={f.plan === v} onClick={() => set({ plan: v })}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="bo-pill" aria-pressed={!!f.expiringDays} onClick={() => set({ expiringDays: f.expiringDays ? undefined : 7 })}>
|
||||
{t('filter.expiring')}
|
||||
</button>
|
||||
<button type="button" className="bo-pill" aria-pressed={showFilters} onClick={() => setShowFilters((s) => !s)} aria-expanded={showFilters}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<I name="filter" size={15} />
|
||||
{t('filter.more')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="bo-filters">
|
||||
<Field label={t('filter.subSource')}>
|
||||
<select className="bo-select" value={f.subSource ?? ''} onChange={(e) => set({ subSource: (e.target.value || undefined) as Filters['subSource'] })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
{(['PAID', 'MANUAL', 'AGENCY_GIFT'] as const).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`subSource.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('filter.source')}>
|
||||
<select className="bo-select" value={f.source ?? ''} onChange={(e) => set({ source: e.target.value || undefined })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
{(facets.data?.sources ?? []).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{sourceLabel(t, { source: s })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('filter.agency')}>
|
||||
<select className="bo-select" value={f.agencyId ?? ''} onChange={(e) => set({ agencyId: e.target.value || undefined })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
{(facets.data?.agencies ?? []).map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('filter.tag')}>
|
||||
<select className="bo-select" value={f.tag ?? ''} onChange={(e) => set({ tag: e.target.value || undefined })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
{(facets.data?.tags ?? []).map((tg) => (
|
||||
<option key={tg.tag} value={tg.tag}>
|
||||
{tg.tag} ({tg.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('filter.segment')}>
|
||||
<select className="bo-select" value={f.segment ?? ''} onChange={(e) => set({ segment: (e.target.value || undefined) as Filters['segment'] })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
<option value="MOVING">{t('segment.MOVING')}</option>
|
||||
<option value="LIVING">{t('segment.LIVING')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('filter.paying')}>
|
||||
<select className="bo-select" value={f.paying ?? ''} onChange={(e) => set({ paying: (e.target.value || undefined) as Filters['paying'] })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
<option value="yes">{t('filter.payingYes')}</option>
|
||||
<option value="no">{t('filter.payingNo')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('sort.label')}>
|
||||
<select className="bo-select" value={sort} onChange={(e) => writeQuery(f, e.target.value as Sort, 0)}>
|
||||
<option value="new">{t('sort.new')}</option>
|
||||
{isSuper && <option value="revenue">{t('sort.revenue')}</option>}
|
||||
<option value="ends">{t('sort.ends')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="bo-btn bo-btn--soft bo-btn--sm" style={{ background: '#fff' }} onClick={() => setSaveOpen(true)} disabled={activeCount === 0 && !f.search}>
|
||||
{t('segments.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--ghost bo-btn--sm"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
writeQuery({}, 'new', 0);
|
||||
}}
|
||||
>
|
||||
{t('filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!segments.data?.length && (
|
||||
<div className="bo-segments" aria-label={t('segments.title')}>
|
||||
<span className="bo-small bo-muted" style={{ marginRight: 4 }}>
|
||||
{t('segments.title')}
|
||||
</span>
|
||||
{segments.data.map((s) => (
|
||||
<span key={s.id} className="bo-seg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const sf = s.filters as Filters;
|
||||
setSearch(sf.search ?? '');
|
||||
writeQuery(sf, sort, 0);
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<button type="button" aria-label={t('segments.remove', { name: s.name })} onClick={() => removeSeg.mutate({ id: s.id })}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="bo-summary">
|
||||
<span>{t('clients.found', { n: data.total })}</span>
|
||||
{data.totalRevenue != null && <span>{t('clients.revenueTotal', { sum: amd(data.totalRevenue) })}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data ? (
|
||||
<Skeleton h={420} />
|
||||
) : data.items.length === 0 ? (
|
||||
<div className="bo-panel bo-empty">{activeCount || f.search ? t('clients.empty') : t('clients.emptyAll')}</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="bo-table" style={{ opacity: list.isPlaceholderData ? 0.6 : 1 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('col.client')}</th>
|
||||
<th>{t('col.sub')}</th>
|
||||
<th>{t('col.source')}</th>
|
||||
<th>{t('col.since')}</th>
|
||||
{isSuper && <th className="bo-r">{t('col.revenue')}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((c) => (
|
||||
<tr key={c.id} onClick={() => navigate(`/clients/${c.id}`)}>
|
||||
<td>
|
||||
<a href={`#/clients/${c.id}`} className="bo-who" style={{ textDecoration: 'none', color: 'inherit' }} onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar name={c.name} id={c.id} />
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span className="bo-who__name">{c.name}</span>
|
||||
<span className="bo-who__meta" style={{ display: 'block' }}>
|
||||
{[c.username && `@${c.username}`, c.phone].filter(Boolean).join(', ') || '—'}
|
||||
</span>
|
||||
{c.tags.length > 0 && (
|
||||
<span className="bo-tags" style={{ marginTop: 4 }}>
|
||||
{c.tags.map((tg) => (
|
||||
<span key={tg} className="bo-tag">
|
||||
{tg}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<PlanBadge plan={c.sub?.plan ?? null} t={t} />
|
||||
{c.sub && c.sub.source !== 'PAID' && <I name="gift" size={15} />}
|
||||
</div>
|
||||
{c.sub && <div className="bo-who__meta">{t('card.until', { date: date(c.sub.endsAt) })}</div>}
|
||||
</td>
|
||||
<td className="bo-small">{sourceLabel(t, c)}</td>
|
||||
<td className="bo-small bo-muted">{date(c.createdAt)}</td>
|
||||
{isSuper && <td className="bo-r bo-num">{c.revenue ? amd(c.revenue) : <span className="bo-faint">0</span>}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="bo-cards">
|
||||
{data.items.map((c) => (
|
||||
<a key={c.id} className="bo-ccard" href={`#/clients/${c.id}`}>
|
||||
<div className="bo-who">
|
||||
<Avatar name={c.name} id={c.id} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="bo-who__name">{c.name}</div>
|
||||
<div className="bo-who__meta">{sourceLabel(t, c)}</div>
|
||||
</div>
|
||||
{isSuper && c.revenue != null && <b className="bo-num">{amd(c.revenue)}</b>}
|
||||
</div>
|
||||
<div className="bo-ccard__row">
|
||||
<PlanBadge plan={c.sub?.plan ?? null} t={t} />
|
||||
{c.sub && <span>{t('card.until', { date: date(c.sub.endsAt) })}</span>}
|
||||
{c.tags.map((tg) => (
|
||||
<span key={tg} className="bo-tag">
|
||||
{tg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.total > data.pageSize && (
|
||||
<div className="bo-pager">
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={page === 0} onClick={() => writeQuery(f, sort, page - 1)}>
|
||||
{t('clients.prev')}
|
||||
</button>
|
||||
<span className="bo-small bo-muted">{t('clients.page', { from, to, total: data.total })}</span>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={to >= data.total} onClick={() => writeQuery(f, sort, page + 1)}>
|
||||
{t('clients.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SaveSegment open={saveOpen} onClose={() => setSaveOpen(false)} filters={f} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveSegment({ open, onClose, filters }: { open: boolean; onClose: () => void; filters: Filters }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const save = useMutation(
|
||||
trpc.bo.segments.save.mutationOptions({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.segments.list.queryKey() });
|
||||
toast(t('segments.saved'));
|
||||
setName('');
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('segments.save')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!name.trim() || save.isPending} onClick={() => save.mutate({ name: name.trim(), filters })}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label={t('segments.name')}>
|
||||
<input className="bo-input" value={name} onChange={(e) => setName(e.target.value)} autoFocus maxLength={60} />
|
||||
</Field>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTRPC } from '@barev/web';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { useT, type BoKey } from '../i18n.tsx';
|
||||
import { I, OLD_ADMIN_URL, PlanBadge, Skeleton, sourceKeyLabel } from '../components/kit.tsx';
|
||||
|
||||
type Pair = { orders: number; subs: number };
|
||||
const total = (p: Pair) => p.orders + p.subs;
|
||||
|
||||
function greetingKey(): BoKey {
|
||||
const h = Number(new Intl.DateTimeFormat('en-GB', { hour: 'numeric', hour12: false, timeZone: 'Asia/Yerevan' }).format(new Date()));
|
||||
return h < 12 ? 'dash.morning' : h < 18 ? 'dash.day' : 'dash.evening';
|
||||
}
|
||||
|
||||
export function Dashboard({ me }: { me: Me }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const attention = useQuery({ ...trpc.bo.dashboard.attention.queryOptions(), refetchInterval: 30_000 });
|
||||
const ov = useQuery({ ...trpc.bo.dashboard.overview.queryOptions(), refetchInterval: 60_000 });
|
||||
const firstName = me.name.split(' ')[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="bo-greet">{t(greetingKey(), { name: firstName })}</h1>
|
||||
<Attention data={attention.data} />
|
||||
|
||||
{!ov.data ? (
|
||||
<div className="bo-grid">
|
||||
<div className="bo-span-12">
|
||||
<Skeleton h={260} />
|
||||
</div>
|
||||
<div className="bo-span-6">
|
||||
<Skeleton h={220} />
|
||||
</div>
|
||||
<div className="bo-span-6">
|
||||
<Skeleton h={220} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{ov.data.money && <Money m={ov.data.money} />}
|
||||
<div className="bo-grid">
|
||||
<Subs s={ov.data.subs} />
|
||||
<Growth g={ov.data.growth} />
|
||||
{ov.data.team && <TeamBlock team={ov.data.team} />}
|
||||
<Feed items={ov.data.feed} wide={!ov.data.team} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── требует внимания ─────
|
||||
|
||||
type Att = { unassigned: number; unreadChats: number; webRequests: number; leads: number; appRequests: number; lowRatings: number; expiring: number };
|
||||
const ATT: { key: keyof Att; href: string; urgent?: boolean; fill: string; ink: string }[] = [
|
||||
{ key: 'unassigned', href: '#/orders', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
|
||||
{ key: 'unreadChats', href: '#/chats', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
|
||||
{ key: 'webRequests', href: `${OLD_ADMIN_URL}#/requests`, fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
|
||||
{ key: 'leads', href: `${OLD_ADMIN_URL}#/leads`, fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
|
||||
{ key: 'appRequests', href: `${OLD_ADMIN_URL}#/app-requests`, fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
|
||||
{ key: 'lowRatings', href: '#/orders', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
|
||||
{ key: 'expiring', href: '#/clients?expiring=7', fill: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' },
|
||||
];
|
||||
|
||||
function Attention({ data }: { data?: Att }) {
|
||||
const { t } = useT();
|
||||
if (!data) return <div className="bo-attn" style={{ minHeight: 58 }} />;
|
||||
const items = ATT.filter((a) => data[a.key] > 0);
|
||||
return (
|
||||
<section className="bo-attn" aria-label={t('dash.attention')}>
|
||||
{items.length === 0 ? (
|
||||
<span className="bo-attn__ok">
|
||||
<I name="check" size={18} />
|
||||
{t('dash.allClear')}
|
||||
</span>
|
||||
) : (
|
||||
items.map((a) => {
|
||||
const external = a.href.startsWith('http');
|
||||
return (
|
||||
<a key={a.key} className="bo-attn__item" href={a.href} {...(external ? { target: '_blank', rel: 'noreferrer' } : {})}>
|
||||
<span className="bo-attn__n" style={{ background: a.fill, color: a.ink }}>
|
||||
{data[a.key]}
|
||||
</span>
|
||||
{t(`att.${a.key}`)}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── деньги ─────
|
||||
|
||||
function Delta({ cur, prev, label }: { cur: number; prev: number; label: string }) {
|
||||
if (prev <= 0) return null;
|
||||
const pct = Math.round(((cur - prev) / prev) * 100);
|
||||
const up = pct >= 0;
|
||||
return (
|
||||
<span className={`bo-delta bo-delta--${up ? 'up' : 'down'}`} title={label}>
|
||||
<I name={up ? 'arrowUp' : 'arrowDown'} size={13} stroke={2.4} />
|
||||
{Math.abs(pct)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Money({ m }: { m: { today: Pair; yesterday: Pair; week: Pair; prevWeek: Pair; month: Pair; prevMonth: Pair; series: ({ day: string } & Pair)[] } }) {
|
||||
const { t, amd, amdShort, locale } = useT();
|
||||
const periods = [
|
||||
{ label: t('dash.today'), cur: m.today, prev: m.yesterday, extra: t('dash.vsYesterday', { sum: amd(total(m.yesterday)) }) },
|
||||
{ label: t('dash.week'), cur: m.week, prev: m.prevWeek, extra: t('dash.vsPrev', { period: t('dash.week') }) },
|
||||
{ label: t('dash.month'), cur: m.month, prev: m.prevMonth, extra: t('dash.vsPrev', { period: t('dash.month') }) },
|
||||
];
|
||||
const max = Math.max(1, ...m.series.map(total));
|
||||
const dayFmt = new Intl.DateTimeFormat(locale === 'en' ? 'en-GB' : locale === 'hy' ? 'hy-AM' : 'ru-RU', { day: 'numeric', month: 'short' });
|
||||
const ORDERS = 'var(--bc-pink)';
|
||||
const SUBS = 'var(--bc-perks)';
|
||||
return (
|
||||
<section className="bo-money" aria-label={t('dash.money')}>
|
||||
<div className="bo-money__periods">
|
||||
{periods.map((p) => (
|
||||
<div key={p.label}>
|
||||
<div className="bo-money__label">{p.label}</div>
|
||||
<div className="bo-money__sum">{amd(total(p.cur))}</div>
|
||||
<div className="bo-money__split">
|
||||
<Delta cur={total(p.cur)} prev={total(p.prev)} label={p.extra} />
|
||||
<span>
|
||||
{t('dash.orders')} {amdShort(p.cur.orders)}
|
||||
</span>
|
||||
<span>
|
||||
{t('dash.subs')} {amdShort(p.cur.subs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bo-chart">
|
||||
<div className="bo-chart__bars" role="img" aria-label={t('dash.chart')}>
|
||||
{m.series.map((d) => {
|
||||
const date = dayFmt.format(new Date(`${d.day}T12:00:00+04:00`));
|
||||
return (
|
||||
<div key={d.day} className="bo-chart__day" data-tip={`${date}: ${amd(total(d))}`}>
|
||||
<span className="bo-chart__base" />
|
||||
{d.orders > 0 && <span style={{ height: `${(d.orders / max) * 100}%`, background: ORDERS }} />}
|
||||
{d.subs > 0 && <span style={{ height: `${(d.subs / max) * 100}%`, background: SUBS }} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="bo-chart__legend">
|
||||
<span>{t('dash.chart')}</span>
|
||||
<span style={{ display: 'flex', gap: 14 }}>
|
||||
<span className="bo-key" style={{ ['--key' as string]: ORDERS }}>
|
||||
{t('dash.orders')}
|
||||
</span>
|
||||
<span className="bo-key" style={{ ['--key' as string]: SUBS }}>
|
||||
{t('dash.subs')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── подписки ─────
|
||||
|
||||
interface SubsData {
|
||||
plus: number;
|
||||
elite: number;
|
||||
gifts: number;
|
||||
expiring: { clientId: string; name: string; plan: 'BAREV_PLUS' | 'ELITE'; endsAt: Date; source: string }[];
|
||||
giftClients: number;
|
||||
giftConverted: number;
|
||||
}
|
||||
|
||||
function Subs({ s }: { s: SubsData }) {
|
||||
const { t, date } = useT();
|
||||
const conv = s.giftClients ? Math.round((s.giftConverted / s.giftClients) * 100) : 0;
|
||||
return (
|
||||
<section className="bo-panel bo-span-7">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('dash.subsTitle')}</h2>
|
||||
<a className="bo-small bo-muted" href="#/clients?plan=any">
|
||||
{t('nav.clients')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="bo-pair" style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
<PlanBadge plan="BAREV_PLUS" t={t} />
|
||||
<div className="bo-bignum" style={{ marginTop: 8 }}>
|
||||
{s.plus}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<PlanBadge plan="ELITE" t={t} />
|
||||
<div className="bo-bignum" style={{ marginTop: 8 }}>
|
||||
{s.elite}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="bo-label">{t('dash.conversion')}</span>
|
||||
<div className="bo-bignum" style={{ marginTop: 8 }}>
|
||||
{conv}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginBottom: 18 }}>
|
||||
{t('dash.gifts', { n: s.gifts })}. {t('dash.conversionHint', { paid: s.giftConverted, total: s.giftClients })}.
|
||||
</p>
|
||||
<h3 className="bo-h3" style={{ marginBottom: 4 }}>
|
||||
{t('dash.expiring')}
|
||||
</h3>
|
||||
{s.expiring.length === 0 ? (
|
||||
<p className="bo-hint">{t('dash.noExpiring')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{s.expiring.map((e) => (
|
||||
<li key={e.clientId + e.plan}>
|
||||
<PlanBadge plan={e.plan} t={t} />
|
||||
<a className="bo-grow" href={`#/clients/${e.clientId}`}>
|
||||
{e.name}
|
||||
</a>
|
||||
<span className="bo-small bo-muted">{date(e.endsAt)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── рост ─────
|
||||
|
||||
interface GrowthData {
|
||||
today: number;
|
||||
week: number;
|
||||
month: number;
|
||||
prevMonth: number;
|
||||
sources: { key: string; count: number }[];
|
||||
funnel: { leads: number; clients: number; paying: number };
|
||||
}
|
||||
|
||||
function Growth({ g }: { g: GrowthData }) {
|
||||
const { t } = useT();
|
||||
const maxSrc = Math.max(1, ...g.sources.map((s) => s.count));
|
||||
const steps = [
|
||||
{ key: 'funnel.leads' as const, n: g.funnel.leads, fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
|
||||
{ key: 'funnel.clients' as const, n: g.funnel.clients, fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
|
||||
{ key: 'funnel.paying' as const, n: g.funnel.paying, fill: 'var(--bc-home)', ink: 'var(--bc-home-ink)' },
|
||||
];
|
||||
const maxStep = Math.max(1, ...steps.map((s) => s.n));
|
||||
return (
|
||||
<section className="bo-panel bo-span-5">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('dash.growth')}</h2>
|
||||
</div>
|
||||
<span className="bo-label">{t('dash.newClients')}</span>
|
||||
<div className="bo-pair" style={{ margin: '6px 0 4px', alignItems: 'baseline' }}>
|
||||
<span>
|
||||
<span className="bo-bignum">{g.month}</span> <span className="bo-muted bo-small">{t('dash.month')}</span>
|
||||
</span>
|
||||
<span>
|
||||
<b className="bo-num" style={{ fontSize: 22 }}>
|
||||
{g.week}
|
||||
</b>{' '}
|
||||
<span className="bo-muted bo-small">{t('dash.week')}</span>
|
||||
</span>
|
||||
<span>
|
||||
<b className="bo-num" style={{ fontSize: 22 }}>
|
||||
{g.today}
|
||||
</b>{' '}
|
||||
<span className="bo-muted bo-small">{t('dash.today')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginBottom: 16 }}>
|
||||
{t('dash.prevMonth', { n: g.prevMonth })}
|
||||
</p>
|
||||
|
||||
<h3 className="bo-h3" style={{ marginBottom: 6 }}>
|
||||
{t('dash.sources')}
|
||||
</h3>
|
||||
{g.sources.length === 0 ? (
|
||||
<p className="bo-hint">{t('dash.empty')}</p>
|
||||
) : (
|
||||
g.sources.slice(0, 5).map((s) => (
|
||||
<div key={s.key} className="bo-bar">
|
||||
<span className="bo-grow bo-small">{sourceKeyLabel(t, s.key)}</span>
|
||||
<b className="bo-num bo-small">{s.count}</b>
|
||||
<div className="bo-bar__track">
|
||||
<div className="bo-bar__fill" style={{ width: `${(s.count / maxSrc) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<h3 className="bo-h3" style={{ margin: '18px 0 8px' }}>
|
||||
{t('dash.funnel')}
|
||||
</h3>
|
||||
<div className="bo-funnel">
|
||||
{steps.map((s) => (
|
||||
<div key={s.key} className="bo-funnel__step" style={{ ['--fill' as string]: s.fill, ['--ink' as string]: s.ink, width: `${Math.max(48, (s.n / maxStep) * 100)}%` }}>
|
||||
<span>{t(s.key)}</span>
|
||||
<b className="bo-num">{s.n}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── команда ─────
|
||||
|
||||
interface TeamData {
|
||||
topProviders: { id: string; name: string; avg: number; count: number }[];
|
||||
bottomProviders: { id: string; name: string; avg: number; count: number }[];
|
||||
topPartners: { id: string; name: string; scans: number }[];
|
||||
}
|
||||
|
||||
function TeamBlock({ team }: { team: TeamData }) {
|
||||
const { t } = useT();
|
||||
const prov = (p: TeamData['topProviders'][number]) => (
|
||||
<li key={p.id}>
|
||||
<span className="bo-grow">{p.name}</span>
|
||||
<span className="bo-small bo-muted">{t('dash.ratings', { n: p.count })}</span>
|
||||
<b className="bo-num">★ {p.avg.toFixed(1)}</b>
|
||||
</li>
|
||||
);
|
||||
return (
|
||||
<section className="bo-panel bo-span-5">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('dash.team')}</h2>
|
||||
</div>
|
||||
<h3 className="bo-h3">{t('dash.topProviders')}</h3>
|
||||
{team.topProviders.length ? <ul className="bo-list">{team.topProviders.map(prov)}</ul> : <p className="bo-hint">{t('dash.empty')}</p>}
|
||||
{team.bottomProviders.length > 0 && (
|
||||
<>
|
||||
<h3 className="bo-h3" style={{ marginTop: 14 }}>
|
||||
{t('dash.bottomProviders')}
|
||||
</h3>
|
||||
<ul className="bo-list">{team.bottomProviders.map(prov)}</ul>
|
||||
</>
|
||||
)}
|
||||
<h3 className="bo-h3" style={{ marginTop: 14 }}>
|
||||
{t('dash.topPartners')}
|
||||
</h3>
|
||||
{team.topPartners.length ? (
|
||||
<ul className="bo-list">
|
||||
{team.topPartners.map((p) => (
|
||||
<li key={p.id}>
|
||||
<span className="bo-grow">{p.name}</span>
|
||||
<span className="bo-small bo-muted">{t('dash.scans', { n: p.scans })}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="bo-hint">{t('dash.empty')}</p>
|
||||
)}
|
||||
<p className="bo-hint" style={{ marginTop: 14 }}>
|
||||
{t('dash.salesSoon')}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── лента ─────
|
||||
|
||||
type FeedItem =
|
||||
| { kind: 'subscription'; at: Date; clientId: string; who: string; plan: 'BAREV_PLUS' | 'ELITE'; source: string; amount: number | null }
|
||||
| { kind: 'payment'; at: Date; clientId: string; who: string; service: string; number: number; amount: number | null }
|
||||
| { kind: 'action'; at: Date; by: string | null; action: string; entityId: string | null };
|
||||
|
||||
function Feed({ items, wide }: { items: FeedItem[]; wide: boolean }) {
|
||||
const { t, dateTime, amd, shared } = useT();
|
||||
return (
|
||||
<section className={`bo-panel ${wide ? 'bo-span-12' : 'bo-span-7'}`}>
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('dash.feed')}</h2>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="bo-hint">{t('dash.empty')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{items.map((f, i) => (
|
||||
<li key={i}>
|
||||
<span className="bo-feed__time">{dateTime(f.at)}</span>
|
||||
{f.kind === 'subscription' && (
|
||||
<>
|
||||
<PlanBadge plan={f.plan} t={t} />
|
||||
<span className="bo-grow">
|
||||
<a href={`#/clients/${f.clientId}`}>{f.who}</a> <span className="bo-muted">· {t(`subSource.${f.source}`)}</span>
|
||||
</span>
|
||||
{f.amount != null && <b className="bo-num">{amd(f.amount)}</b>}
|
||||
</>
|
||||
)}
|
||||
{f.kind === 'payment' && (
|
||||
<>
|
||||
<span className="bo-grow">
|
||||
<a href={`#/clients/${f.clientId}`}>{f.who}</a> <span className="bo-muted">· {shared(`services.${f.service}.name`)} · {t('feed.payment', { number: f.number })}</span>
|
||||
</span>
|
||||
{f.amount != null && <b className="bo-num">{amd(f.amount)}</b>}
|
||||
</>
|
||||
)}
|
||||
{f.kind === 'action' && (
|
||||
<span className="bo-grow bo-muted">
|
||||
<b style={{ color: 'var(--bc-ink)' }}>{f.by ?? '—'}</b> {actionLabel(t, f.action)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function actionLabel(t: ReturnType<typeof useT>['t'], action: string) {
|
||||
const key = `audit.${action}`;
|
||||
const tr = t(key);
|
||||
return tr === key ? action : tr;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Logo } from '@barev/ui';
|
||||
import { useTRPC } from '@barev/web';
|
||||
import QRCode from 'qrcode';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BO_LOCALE_LABELS, BO_LOCALES, useT } from '../i18n.tsx';
|
||||
import { I } from '../components/kit.tsx';
|
||||
|
||||
/** Вход: ссылка в рабочий бот → бот подтверждает → страница опрашивает и получает сессию */
|
||||
export function Login() {
|
||||
const { t, locale, setLocale } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const start = useMutation(trpc.bo.auth.start.mutationOptions());
|
||||
const poll = useMutation(trpc.bo.auth.poll.mutationOptions());
|
||||
const devUsers = useQuery(trpc.bo.auth.devUsers.queryOptions());
|
||||
const devLogin = useMutation(trpc.bo.auth.devLogin.mutationOptions({ onSuccess: () => qc.invalidateQueries() }));
|
||||
const [qr, setQr] = useState<string>();
|
||||
const [expired, setExpired] = useState(false);
|
||||
|
||||
const req = start.data;
|
||||
useEffect(() => {
|
||||
start.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!req) return;
|
||||
setExpired(false);
|
||||
QRCode.toDataURL(req.url, { margin: 0, width: 312, color: { dark: '#4a1028', light: '#ffffff' } }).then(setQr, () => setQr(undefined));
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const r = await poll.mutateAsync({ code: req.code, pollKey: req.pollKey });
|
||||
if (r.status === 'ok') {
|
||||
clearInterval(timer);
|
||||
await qc.invalidateQueries();
|
||||
} else if (r.status === 'expired') {
|
||||
clearInterval(timer);
|
||||
setExpired(true);
|
||||
}
|
||||
} catch {
|
||||
/* сеть моргнула — следующий опрос */
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [req?.code]);
|
||||
|
||||
return (
|
||||
<div className="bo-login">
|
||||
<section className="bo-login__cover">
|
||||
<Logo size={24} color="#f6e9dc" />
|
||||
<p className="bo-login__word">
|
||||
Barev,
|
||||
<br />
|
||||
<i>{t('login.coverWord')}</i>
|
||||
</p>
|
||||
<div className="bo-langs" role="group" aria-label="Language">
|
||||
{BO_LOCALES.map((l) => (
|
||||
<button key={l} type="button" className="bo-lang" aria-pressed={l === locale} onClick={() => setLocale(l)}>
|
||||
{BO_LOCALE_LABELS[l]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bo-login__form">
|
||||
<h1 className="bo-title">{t('login.title')}</h1>
|
||||
<p className="bo-sub" style={{ marginTop: 0 }}>
|
||||
{t('login.lead')}
|
||||
</p>
|
||||
|
||||
{expired ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }}>
|
||||
<span className="bo-muted">{t('login.expired')}</span>
|
||||
<button type="button" className="bo-btn" onClick={() => start.mutate()}>
|
||||
{t('login.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<a className="bo-btn bo-btn--accent" style={{ alignSelf: 'flex-start', height: 50, padding: '0 24px' }} href={req?.url} target="_blank" rel="noreferrer" aria-disabled={!req}>
|
||||
<I name="telegram" size={18} />
|
||||
{t('login.open')}
|
||||
</a>
|
||||
{qr && (
|
||||
<div className="bo-qr-wrap" style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
|
||||
<img className="bo-qr" src={qr} alt="" />
|
||||
<span className="bo-muted bo-small" style={{ maxWidth: 200 }}>
|
||||
{t('login.qr')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{req && (
|
||||
<span className="bo-wait" role="status">
|
||||
<span className="bo-pulse" />
|
||||
{t('login.waiting')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="bo-faint bo-small" style={{ margin: 0 }}>
|
||||
{t('login.noTelegram')}
|
||||
</p>
|
||||
|
||||
{!!devUsers.data?.length && (
|
||||
<div className="bo-dev">
|
||||
<div>
|
||||
<div className="bo-h3">{t('login.dev')}</div>
|
||||
<div className="bo-faint bo-small">{t('login.devHint')}</div>
|
||||
</div>
|
||||
<div className="bo-pills">
|
||||
{devUsers.data.map((u) => (
|
||||
<button key={u.id} type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => devLogin.mutate({ id: u.id })}>
|
||||
{u.name} · {u.roles.map((r) => t(`role.${r}`)).join(', ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useT } from '../i18n.tsx';
|
||||
import { I, OLD_ADMIN_URL } from '../components/kit.tsx';
|
||||
|
||||
export function Soon({ title, stage }: { title: string; stage: number }) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{title}</h1>
|
||||
</div>
|
||||
<div className="bo-soon">
|
||||
<h2 className="bo-h2">{t('soon.title', { section: title })}</h2>
|
||||
<p className="bo-sub" style={{ marginBottom: 20 }}>
|
||||
{t('soon.text', { n: stage })}
|
||||
</p>
|
||||
<a className="bo-btn" href={OLD_ADMIN_URL} target="_blank" rel="noreferrer">
|
||||
{t('soon.open')}
|
||||
<I name="external" size={16} />
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { errText, useT, type BoKey } from '../i18n.tsx';
|
||||
import { Dialog, Field } from '../components/kit.tsx';
|
||||
|
||||
export type SubMode = 'gift' | 'sell' | 'extend' | 'cancel' | 'plan' | 'refund';
|
||||
export interface SubTarget {
|
||||
id: string;
|
||||
plan: 'BAREV_PLUS' | 'ELITE';
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
amount: number | null;
|
||||
refundAmount: number | null;
|
||||
}
|
||||
type Plan = 'BAREV_PLUS' | 'ELITE';
|
||||
type PayMethod = 'cash' | 'transfer' | 'card' | 'link';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const PERIODS = [
|
||||
{ days: 30, key: 'dlg.period1' },
|
||||
{ days: 90, key: 'dlg.period3' },
|
||||
{ days: 180, key: 'dlg.period6' },
|
||||
{ days: 365, key: 'dlg.period12' },
|
||||
] as const;
|
||||
|
||||
const TITLE: Record<SubMode, BoKey> = { gift: 'dlg.giftTitle', sell: 'dlg.sellTitle', extend: 'dlg.extendTitle', cancel: 'dlg.cancelTitle', plan: 'dlg.planTitle', refund: 'dlg.refundTitle' };
|
||||
const CONFIRM: Record<SubMode, BoKey> = { gift: 'dlg.giftConfirm', sell: 'dlg.sellConfirm', extend: 'dlg.extendConfirm', cancel: 'dlg.cancelConfirm', plan: 'dlg.planConfirm', refund: 'dlg.refundConfirm' };
|
||||
const DONE: Record<SubMode, BoKey> = { gift: 'done.gift', sell: 'done.sell', extend: 'done.extend', cancel: 'done.cancel', plan: 'done.changePlan', refund: 'done.refund' };
|
||||
|
||||
export function SubDialog({ clientId, mode, sub, current, onClose }: { clientId: string; mode: SubMode; sub?: SubTarget; current?: SubTarget; onClose: () => void }) {
|
||||
const { t, amd, date } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
const [plan, setPlan] = useState<Plan>(mode === 'plan' && sub ? (sub.plan === 'ELITE' ? 'BAREV_PLUS' : 'ELITE') : (current?.plan ?? 'BAREV_PLUS'));
|
||||
const [days, setDays] = useState(30);
|
||||
const [custom, setCustom] = useState(false);
|
||||
const [amount, setAmount] = useState('');
|
||||
const [method, setMethod] = useState<PayMethod>('cash');
|
||||
const [note, setNote] = useState('');
|
||||
const [delta, setDelta] = useState('30');
|
||||
const [refund, setRefund] = useState(sub?.amount ? String(sub.amount - (sub.refundAmount ?? 0)) : '');
|
||||
const [cancelToo, setCancelToo] = useState(true);
|
||||
|
||||
const opts = {
|
||||
onSuccess: () => {
|
||||
toast(t(DONE[mode]));
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.clients.get.queryKey({ id: clientId }) });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.clients.list.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.dashboard.overview.queryKey() });
|
||||
onClose();
|
||||
},
|
||||
onError: (e: unknown) => toast(errText(t, e), 'error'),
|
||||
};
|
||||
const gift = useMutation(trpc.bo.clients.gift.mutationOptions(opts));
|
||||
const sell = useMutation(trpc.bo.clients.sell.mutationOptions(opts));
|
||||
const extend = useMutation(trpc.bo.clients.extend.mutationOptions(opts));
|
||||
const cancel = useMutation(trpc.bo.clients.cancel.mutationOptions(opts));
|
||||
const changePlan = useMutation(trpc.bo.clients.changePlan.mutationOptions(opts));
|
||||
const refundM = useMutation(trpc.bo.clients.refund.mutationOptions(opts));
|
||||
const pending = [gift, sell, extend, cancel, changePlan, refundM].some((m) => m.isPending);
|
||||
|
||||
// новая подписка того же тарифа встаёт в очередь за текущей
|
||||
const queuedAfter = (mode === 'gift' || mode === 'sell') && current && current.plan === plan ? current.endsAt : null;
|
||||
const deltaN = Number(delta) || 0;
|
||||
const amountN = Number(amount.replace(/\s/g, ''));
|
||||
const refundN = Number(refund.replace(/\s/g, ''));
|
||||
|
||||
const valid =
|
||||
mode === 'gift'
|
||||
? days > 0
|
||||
: mode === 'sell'
|
||||
? days > 0 && amount !== '' && amountN >= 0 && method !== 'link'
|
||||
: mode === 'extend'
|
||||
? deltaN !== 0
|
||||
: mode === 'refund'
|
||||
? refundN > 0
|
||||
: true;
|
||||
|
||||
const submit = () => {
|
||||
const base = { id: clientId };
|
||||
if (mode === 'gift') gift.mutate({ ...base, plan, days, note: note || undefined });
|
||||
if (mode === 'sell') sell.mutate({ ...base, plan, days, amount: amountN, payMethod: method, note: note || undefined });
|
||||
if (mode === 'extend' && sub) extend.mutate({ ...base, subscriptionId: sub.id, days: deltaN });
|
||||
if (mode === 'cancel' && sub) cancel.mutate({ ...base, subscriptionId: sub.id, reason: note || undefined });
|
||||
if (mode === 'plan' && sub) changePlan.mutate({ ...base, subscriptionId: sub.id, plan });
|
||||
if (mode === 'refund' && sub) refundM.mutate({ ...base, subscriptionId: sub.id, amount: refundN, cancel: cancelToo });
|
||||
};
|
||||
|
||||
const planPicker = (
|
||||
<Field label={t('dlg.plan')}>
|
||||
<div className="bo-pills">
|
||||
{(['BAREV_PLUS', 'ELITE'] as const).map((p) => (
|
||||
<button key={p} type="button" className="bo-pill" aria-pressed={plan === p} onClick={() => setPlan(p)} disabled={mode === 'plan' && sub?.plan === p}>
|
||||
{t(`plan.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={t(TITLE[mode])}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className={`bo-btn ${mode === 'cancel' || mode === 'refund' ? 'bo-btn--accent' : ''}`} disabled={!valid || pending} onClick={submit}>
|
||||
{t(CONFIRM[mode])}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(mode === 'gift' || mode === 'sell') && (
|
||||
<>
|
||||
{mode === 'gift' && <p className="bo-hint">{t('dlg.giftHint')}</p>}
|
||||
{planPicker}
|
||||
<Field label={t('dlg.period')}>
|
||||
<div className="bo-pills">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.days}
|
||||
type="button"
|
||||
className="bo-pill"
|
||||
aria-pressed={!custom && days === p.days}
|
||||
onClick={() => {
|
||||
setCustom(false);
|
||||
setDays(p.days);
|
||||
}}
|
||||
>
|
||||
{t(p.key)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="bo-pill" aria-pressed={custom} onClick={() => setCustom(true)}>
|
||||
{t('dlg.periodCustom')}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
{custom && (
|
||||
<Field label={t('dlg.days')}>
|
||||
<input className="bo-input" type="number" min={1} max={3660} value={days} onChange={(e) => setDays(Math.max(0, Number(e.target.value)))} />
|
||||
</Field>
|
||||
)}
|
||||
{queuedAfter && <p className="bo-hint">{t('card.upcoming', { date: date(queuedAfter) })}</p>}
|
||||
{mode === 'sell' && (
|
||||
<>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('dlg.amount')}>
|
||||
<input className="bo-input" inputMode="numeric" value={amount} onChange={(e) => setAmount(e.target.value.replace(/[^\d\s]/g, ''))} />
|
||||
</Field>
|
||||
<Field label={t('dlg.method')}>
|
||||
<select className="bo-select" value={method} onChange={(e) => setMethod(e.target.value as PayMethod)}>
|
||||
{(['cash', 'transfer', 'card', 'link'] as const).map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{t(`pay.${m}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{method === 'link' && <p className="bo-hint">{t('dlg.linkSoon')}</p>}
|
||||
</>
|
||||
)}
|
||||
<Field label={t('dlg.note')}>
|
||||
<input className="bo-input" value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'extend' && sub && (
|
||||
<>
|
||||
<Field label={t('dlg.days')} hint={t('dlg.extendHint')}>
|
||||
<input className="bo-input" type="number" value={delta} onChange={(e) => setDelta(e.target.value)} autoFocus />
|
||||
</Field>
|
||||
<div className="bo-pills">
|
||||
{[7, 30, 90].map((n) => (
|
||||
<button key={n} type="button" className="bo-pill" onClick={() => setDelta(String(n))} aria-pressed={deltaN === n}>
|
||||
+{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{deltaN !== 0 && <p style={{ margin: 0, fontWeight: 600 }}>{t('dlg.newEnd', { date: date(new Date(sub.endsAt.getTime() + deltaN * DAY)) })}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'plan' && planPicker}
|
||||
|
||||
{mode === 'cancel' && (
|
||||
<>
|
||||
<p className="bo-hint">{t('dlg.cancelHint')}</p>
|
||||
<Field label={t('dlg.reason')}>
|
||||
<input className="bo-input" value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} autoFocus />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'refund' && sub && (
|
||||
<>
|
||||
<p className="bo-hint">{t('dlg.refundPaid', { sum: amd(sub.amount ?? 0), back: amd(sub.refundAmount ?? 0) })}</p>
|
||||
<Field label={t('dlg.amount')}>
|
||||
<input className="bo-input" inputMode="numeric" value={refund} onChange={(e) => setRefund(e.target.value.replace(/[^\d\s]/g, ''))} autoFocus />
|
||||
</Field>
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={cancelToo} onChange={(e) => setCancelToo(e.target.checked)} />
|
||||
{t('dlg.refundCancel')}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import type { Me, Role } from '../App.tsx';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
|
||||
|
||||
const ROLES: Role[] = ['SUPERADMIN', 'SUPPORT', 'CONCIERGE', 'SALES'];
|
||||
|
||||
export function Team({ me }: { me: Me }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const list = useQuery(trpc.bo.team.list.queryOptions());
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: trpc.bo.team.list.queryKey() });
|
||||
const update = useMutation(trpc.bo.team.update.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
|
||||
const [link, setLink] = useState<{ name: string; url: string } | null>(null);
|
||||
const linkUrl = useMutation(trpc.bo.team.linkUrl.mutationOptions({ onError: (e) => toast(errText(t, e), 'error') }));
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<div>
|
||||
<h1 className="bo-title">{t('team.title')}</h1>
|
||||
<p className="bo-sub">{t('team.lead')}</p>
|
||||
</div>
|
||||
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setAdding(true)}>
|
||||
<I name="plus" size={16} />
|
||||
{t('team.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!list.data ? (
|
||||
<Skeleton h={240} />
|
||||
) : (
|
||||
<div className="bo-team">
|
||||
{list.data.map((m) => {
|
||||
const roles = m.roles as Role[];
|
||||
const self = m.id === me.id;
|
||||
return (
|
||||
<div key={m.id} className={`bo-member${m.active ? '' : ' bo-member--off'}`}>
|
||||
<Avatar name={m.name} id={m.id} size={44} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{m.name} {self && <span className="bo-faint bo-small">({t('team.you')})</span>}
|
||||
</div>
|
||||
<div className="bo-small" style={{ color: m.telegramLinked ? 'var(--bc-ok)' : 'var(--bc-ink-3)' }}>
|
||||
{!m.active ? t('team.disabled') : m.telegramLinked ? t('team.tgLinked') : t('team.tgNotLinked')}
|
||||
</div>
|
||||
<div className="bo-roles" role="group" aria-label={t('team.roles')}>
|
||||
{ROLES.map((r) => {
|
||||
const on = roles.includes(r);
|
||||
return (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className="bo-role"
|
||||
aria-pressed={on}
|
||||
title={t(`roleHint.${r}`)}
|
||||
disabled={update.isPending || (self && r === 'SUPERADMIN')}
|
||||
onClick={() => {
|
||||
const next = on ? roles.filter((x) => x !== r) : [...roles, r];
|
||||
if (next.length) update.mutate({ id: m.id, roles: next }, { onSuccess: () => toast(t('team.saved')) });
|
||||
}}
|
||||
>
|
||||
{on && <I name="check" size={14} stroke={2.4} />}
|
||||
{t(`role.${r}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{m.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--ghost bo-btn--sm"
|
||||
onClick={() => linkUrl.mutate({ id: m.id }, { onSuccess: (r) => setLink({ name: m.name, url: r.url }) })}
|
||||
>
|
||||
<I name="telegram" size={15} />
|
||||
{t('team.newLink')}
|
||||
</button>
|
||||
)}
|
||||
{!self && (
|
||||
<button type="button" className={`bo-btn bo-btn--sm ${m.active ? 'bo-btn--danger' : 'bo-btn--ghost'}`} onClick={() => update.mutate({ id: m.id, active: !m.active })}>
|
||||
{m.active ? t('team.disable') : t('team.enable')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddMember
|
||||
open={adding}
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={(name, url) => {
|
||||
setAdding(false);
|
||||
refresh();
|
||||
setLink({ name, url });
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={!!link}
|
||||
onClose={() => setLink(null)}
|
||||
title={link ? t('team.linkTitle', { name: link.name }) : ''}
|
||||
footer={
|
||||
<button type="button" className="bo-btn" onClick={() => setLink(null)}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<p className="bo-hint">{t('team.linkHint')}</p>
|
||||
{link && (
|
||||
<div className="bo-linkbox">
|
||||
<span style={{ flex: 1 }}>{link.url}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--sm"
|
||||
onClick={() => navigator.clipboard.writeText(link.url).then(() => toast(t('ui.copied')), () => undefined)}
|
||||
>
|
||||
{t('ui.copy')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddMember({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (name: string, url: string) => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [roles, setRoles] = useState<Role[]>(['SUPPORT']);
|
||||
const create = useMutation(
|
||||
trpc.bo.team.create.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
onCreated(r.name, r.linkUrl);
|
||||
setName('');
|
||||
setRoles(['SUPPORT']);
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('team.add')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!name.trim() || !roles.length || create.isPending} onClick={() => create.mutate({ name: name.trim(), roles })}>
|
||||
{t('team.create')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label={t('team.name')}>
|
||||
<input className="bo-input" value={name} onChange={(e) => setName(e.target.value)} maxLength={80} autoFocus />
|
||||
</Field>
|
||||
<Field label={t('team.roles')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ROLES.map((r) => (
|
||||
<label key={r} className="bo-check">
|
||||
<input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles(e.target.checked ? [...roles, r] : roles.filter((x) => x !== r))} />
|
||||
<span>
|
||||
<b style={{ fontWeight: 600 }}>{t(`role.${r}`)}</b> <span className="bo-muted">— {t(`roleHint.${r}`)}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "types": ["node", "vite/client"] },
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Новая админка. API проксируется — cookie-сессия на том же origin.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: true,
|
||||
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000' },
|
||||
},
|
||||
build: { target: 'es2022', sourcemap: true },
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@barev/bot-client",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@barev/core": "workspace:*",
|
||||
"@barev/db": "workspace:*",
|
||||
"@barev/i18n": "workspace:*",
|
||||
"@barev/services": "workspace:*",
|
||||
"dotenv": "^17.0.0",
|
||||
"grammy": "^1.46.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Клиентский бот: точка входа и онбординг (ONB-1…6).
|
||||
* /start <payload> → язык → согласие на ПДн → подарок агентства → «Открыть приложение».
|
||||
*/
|
||||
import { getDb } from '@barev/db';
|
||||
import { createT, formatDateTime, isLocale, LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { decodeStartPayload } from '@barev/core';
|
||||
import { addClientMessage, applyAgencyCode, DomainError, getClientByTelegramId, giveConsent, linkWebRequest, setLocale, startClient, type Ctx } from '@barev/services';
|
||||
import { loadRootEnv } from '@barev/services/env';
|
||||
import { createQueueNotifier } from '@barev/services/queue';
|
||||
import { Bot, InlineKeyboard, type Context } from 'grammy';
|
||||
|
||||
loadRootEnv();
|
||||
|
||||
const token = process.env.CLIENT_BOT_TOKEN ?? '';
|
||||
// без токена бот не запускается, но процесс (общий с API в продакшене) продолжает работать
|
||||
if (!token) console.log('CLIENT_BOT_TOKEN не задан — клиентский бот не запущен. Создайте бота в BotFather и добавьте токен в .env.');
|
||||
|
||||
const db = getDb();
|
||||
const notifier = createQueueNotifier(process.env.REDIS_URL ?? 'redis://localhost:6379');
|
||||
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
|
||||
const APP_URL = process.env.CLIENT_MINIAPP_URL ?? '';
|
||||
const LANDING = process.env.LANDING_URL ?? '';
|
||||
const bot = new Bot(token || 'disabled:token');
|
||||
|
||||
/** Ожидаем ввод кода агентства от этих чатов */
|
||||
const awaitingCode = new Set<number>();
|
||||
|
||||
async function localeOf(from: Context['from']): Promise<Locale> {
|
||||
if (!from) return 'ru';
|
||||
const c = await getClientByTelegramId(db, BigInt(from.id));
|
||||
return (c?.user.locale as Locale) ?? 'ru';
|
||||
}
|
||||
|
||||
function languageKeyboard() {
|
||||
const kb = new InlineKeyboard();
|
||||
LOCALES.forEach((l, i) => {
|
||||
kb.text(LOCALE_NAMES[l], `lang:${l}`);
|
||||
if (i % 2 === 1) kb.row();
|
||||
});
|
||||
return kb;
|
||||
}
|
||||
|
||||
function mainKeyboard(locale: Locale) {
|
||||
const t = createT(locale);
|
||||
const kb = new InlineKeyboard();
|
||||
// Telegram принимает Mini App только по https; локально — обычная ссылка не нужна
|
||||
if (APP_URL.startsWith('https://')) kb.webApp(t('bot.client.openApp'), APP_URL).row();
|
||||
kb.text(t('bot.client.enterCode'), 'code').row();
|
||||
kb.text(t('bot.client.changeLanguage'), 'language');
|
||||
return kb;
|
||||
}
|
||||
|
||||
async function askConsent(c: Context, locale: Locale) {
|
||||
const t = createT(locale);
|
||||
const kb = new InlineKeyboard().text(t('bot.client.consentAccept'), 'consent');
|
||||
await c.reply(t('bot.client.consentText', { url: `${LANDING}/${locale}/privacy` }), { reply_markup: kb, parse_mode: 'HTML', link_preview_options: { is_disabled: true } });
|
||||
}
|
||||
|
||||
async function showMain(c: Context, locale: Locale, gift?: { endsAt: Date } | null) {
|
||||
const t = createT(locale);
|
||||
if (gift) await c.reply(t('bot.client.giftActivated', { date: formatDateTime(locale, gift.endsAt, { dateStyle: 'long' }) }));
|
||||
await c.reply(t('bot.client.welcome'), { reply_markup: mainKeyboard(locale) });
|
||||
}
|
||||
|
||||
async function continueOnboarding(c: Context, locale: Locale) {
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
|
||||
if (!client?.consentAt) return askConsent(c, locale);
|
||||
return showMain(c, locale);
|
||||
}
|
||||
|
||||
bot.command('start', async (c) => {
|
||||
const res = await startClient(ctx, c.from!, c.match || undefined);
|
||||
// пришёл с сайта после заявки: язык и согласие уже есть — сразу говорим, что с заявкой
|
||||
const token = decodeStartPayload(c.match).token;
|
||||
const link = token ? await linkWebRequest(ctx, token, res.clientId) : null;
|
||||
if (link && link.kind !== 'none') {
|
||||
const t = createT(res.locale);
|
||||
if (link.kind === 'ordered') {
|
||||
const date = formatDateTime(res.locale, link.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
await c.reply(t('bot.client.webRequestOrdered', { request: link.requestNumber, order: link.orderNumber, date }));
|
||||
} else {
|
||||
await c.reply(t('bot.client.webRequestLinked', { request: link.requestNumber }));
|
||||
}
|
||||
return showMain(c, res.locale, link.gift ?? res.gift);
|
||||
}
|
||||
if (res.needsLocale) {
|
||||
await c.reply(createT('ru')('bot.client.chooseLanguage'), { reply_markup: languageKeyboard() });
|
||||
return;
|
||||
}
|
||||
if (res.agencyName && res.isNew) await c.reply(createT(res.locale)('bot.client.fromAgency', { agency: res.agencyName }));
|
||||
if (res.needsConsent) return askConsent(c, res.locale);
|
||||
return showMain(c, res.locale, res.gift);
|
||||
});
|
||||
|
||||
bot.command('language', (c) => c.reply(createT('ru')('bot.client.chooseLanguage'), { reply_markup: languageKeyboard() }));
|
||||
bot.callbackQuery('language', async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
await c.reply(createT(await localeOf(c.from))('bot.client.chooseLanguage'), { reply_markup: languageKeyboard() });
|
||||
});
|
||||
|
||||
bot.callbackQuery(/^lang:(\w+)$/, async (c) => {
|
||||
const locale = c.match[1];
|
||||
await c.answerCallbackQuery();
|
||||
if (!isLocale(locale)) return;
|
||||
// клиента могло не быть, если кнопка нажата до /start
|
||||
await startClient(ctx, c.from);
|
||||
await setLocale(db, BigInt(c.from.id), locale);
|
||||
await c.editMessageText(createT(locale)('bot.client.languageSet', { language: LOCALE_NAMES[locale] }));
|
||||
await continueOnboarding(c, locale);
|
||||
});
|
||||
|
||||
bot.callbackQuery('consent', async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from.id));
|
||||
if (!client) return;
|
||||
const locale = client.user.locale as Locale;
|
||||
const gift = client.consentAt ? null : await giveConsent(ctx, client.id);
|
||||
await c.editMessageReplyMarkup();
|
||||
await showMain(c, locale, gift);
|
||||
});
|
||||
|
||||
async function promptCode(c: Context) {
|
||||
const locale = await localeOf(c.from);
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
|
||||
if (!client?.consentAt) return askConsent(c, locale);
|
||||
awaitingCode.add(c.chat!.id);
|
||||
await c.reply(createT(locale)('bot.client.codePrompt'));
|
||||
}
|
||||
bot.command('code', promptCode);
|
||||
bot.callbackQuery('code', async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
await promptCode(c);
|
||||
});
|
||||
|
||||
bot.command('app', async (c) => {
|
||||
const locale = await localeOf(c.from);
|
||||
await c.reply(createT(locale)('bot.client.welcome'), { reply_markup: mainKeyboard(locale) });
|
||||
});
|
||||
|
||||
bot.command('help', async (c) => c.reply(createT(await localeOf(c.from))('bot.client.help')));
|
||||
|
||||
/** ONB-6: ручной ввод кода агентства; любой другой текст — консьержу */
|
||||
bot.on('message:text', async (c) => {
|
||||
const locale = await localeOf(c.from);
|
||||
const t = createT(locale);
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from.id));
|
||||
if (!awaitingCode.has(c.chat.id)) {
|
||||
if (!client?.consentAt) return askConsent(c, locale);
|
||||
// чат с консьержем: сообщение видит оператор в админке, ответ придёт сюда же
|
||||
const first = !(await db.conciergeMessage.count({ where: { clientId: client.id } }));
|
||||
await addClientMessage(ctx, client.id, c.message.text);
|
||||
if (first) await c.reply(t('bot.client.conciergeReceived'));
|
||||
return;
|
||||
}
|
||||
if (!client) return;
|
||||
try {
|
||||
const res = await applyAgencyCode(ctx, client.id, c.message.text);
|
||||
awaitingCode.delete(c.chat.id);
|
||||
await c.reply(t('bot.client.codeAccepted', { agency: res.agencyName }));
|
||||
await showMain(c, locale, res.gift);
|
||||
} catch (e) {
|
||||
if (e instanceof DomainError) await c.reply(t(`errors.${e.code}`));
|
||||
else throw e;
|
||||
}
|
||||
});
|
||||
|
||||
bot.catch((err) => console.error('[bot-client]', err.error));
|
||||
|
||||
async function main() {
|
||||
if (!token) return;
|
||||
// ONB-5: кнопка меню открывает Mini App. Telegram не локализует её текст — используем название бренда
|
||||
if (APP_URL.startsWith('https://')) {
|
||||
await bot.api.setChatMenuButton({ menu_button: { type: 'web_app', text: 'Barev Club', web_app: { url: APP_URL } } }).catch(() => {});
|
||||
}
|
||||
for (const l of LOCALES) {
|
||||
const t = createT(l);
|
||||
await bot.api.setMyCommands(
|
||||
[
|
||||
{ command: 'start', description: t('bot.client.cmdStart') },
|
||||
{ command: 'app', description: t('bot.client.cmdApp') },
|
||||
{ command: 'code', description: t('bot.client.cmdCode') },
|
||||
{ command: 'language', description: t('bot.client.cmdLanguage') },
|
||||
{ command: 'help', description: t('bot.client.cmdHelp') },
|
||||
],
|
||||
l === 'ru' ? {} : { language_code: l },
|
||||
);
|
||||
}
|
||||
await bot.start({ onStart: (me) => console.log(`Клиентский бот @${me.username} запущен (long polling)`) });
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src"] }
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@barev/bot-pro",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@barev/core": "workspace:*",
|
||||
"@barev/db": "workspace:*",
|
||||
"@barev/i18n": "workspace:*",
|
||||
"@barev/services": "workspace:*",
|
||||
"dotenv": "^17.0.0",
|
||||
"grammy": "^1.46.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Рабочий бот: исполнители (инвайт, принятие/отказ от заказов PRO-5, кабинет, переписка с оператором)
|
||||
* и операторы админки (уведомления о заявках и сообщениях, ответ клиенту прямо из Telegram).
|
||||
*/
|
||||
import { getDb } from '@barev/db';
|
||||
import { createT, formatDateTime, type Locale } from '@barev/i18n';
|
||||
import { acceptPartnerInvite, addProviderMessage, checkCard, DomainError, linkByInvite, proIdentity, redeemOffer, replyTo, respondToOffer, type Ctx, type Peer } from '@barev/services';
|
||||
import { loadRootEnv } from '@barev/services/env';
|
||||
import { createQueueNotifier } from '@barev/services/queue';
|
||||
import { Bot, InlineKeyboard, type Context } from 'grammy';
|
||||
|
||||
loadRootEnv();
|
||||
|
||||
const token = process.env.PRO_BOT_TOKEN ?? '';
|
||||
// без токена бот не запускается, но процесс (общий с API в продакшене) продолжает работать
|
||||
if (!token) console.log('PRO_BOT_TOKEN не задан — бот исполнителей не запущен. Создайте бота в BotFather и добавьте токен в .env.');
|
||||
|
||||
const db = getDb();
|
||||
const notifier = createQueueNotifier(process.env.REDIS_URL ?? 'redis://localhost:6379');
|
||||
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
|
||||
const APP_URL = process.env.PRO_MINIAPP_URL ?? '';
|
||||
const bot = new Bot(token || 'disabled:token');
|
||||
|
||||
async function identity(c: Context) {
|
||||
return c.from ? proIdentity(db, BigInt(c.from.id)) : null;
|
||||
}
|
||||
const localeOf = (id: Awaited<ReturnType<typeof identity>>): Locale => (id?.user.locale as Locale) ?? 'ru';
|
||||
|
||||
function cabinetKeyboard(locale: Locale) {
|
||||
const kb = new InlineKeyboard();
|
||||
if (APP_URL.startsWith('https://')) kb.webApp(createT(locale)('bot.pro.openCabinet'), APP_URL);
|
||||
return kb;
|
||||
}
|
||||
|
||||
async function greet(c: Context) {
|
||||
const id = await identity(c);
|
||||
const locale = localeOf(id);
|
||||
const t = createT(locale);
|
||||
if (!id || (!id.provider && id.companies.length === 0 && id.partners.length === 0)) {
|
||||
await c.reply(t('bot.pro.notLinked'));
|
||||
return;
|
||||
}
|
||||
const roles = [
|
||||
id.provider ? t('bot.pro.youAreProvider', { name: id.provider.name, role: t(`roles.${id.provider.role}`) }) : null,
|
||||
...id.companies.map((co) => t('bot.pro.youAreDispatcher', { company: co.name })),
|
||||
...id.partners.map((p) => t(p.role === 'OWNER' ? 'bot.partner.youAreOwner' : 'bot.partner.youAreStaff', { partner: p.name })),
|
||||
].filter(Boolean);
|
||||
await c.reply(`${roles.join('\n')}\n\n${t('bot.pro.welcome')}`, { reply_markup: cabinetKeyboard(locale) });
|
||||
}
|
||||
|
||||
/** Оператор админки, привязавший этот Telegram */
|
||||
const adminOf = (c: Context) => (c.from ? db.adminUser.findFirst({ where: { telegramId: BigInt(c.from.id), active: true } }) : null);
|
||||
const adminT = createT('ru');
|
||||
|
||||
/** Оператор нажал «Ответить» — следующий его текст уйдёт этому клиенту или исполнителю */
|
||||
const replyingTo = new Map<number, Peer & { name: string }>();
|
||||
|
||||
const LEVEL_NAME: Record<string, string> = { BAREV: 'Barev', BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' };
|
||||
type Check = Awaited<ReturnType<typeof checkCard>>;
|
||||
|
||||
/** Результат проверки карты: уровень клиента и что ему положено; лимитные предложения — кнопками «Списать» */
|
||||
function checkReply(check: Check, locale: Locale) {
|
||||
const t = createT(locale);
|
||||
const name = (title: Record<string, string>) => title[locale] || title.ru || '';
|
||||
const lines = [
|
||||
`✅ <b>${t('bot.partner.valid', { name: check.client.firstName, level: LEVEL_NAME[check.level] ?? check.level })}</b>`,
|
||||
check.endsAt ? t('bot.partner.until', { date: formatDateTime(locale, check.endsAt, { day: 'numeric', month: 'long' }) }) : '',
|
||||
'',
|
||||
];
|
||||
const kb = new InlineKeyboard();
|
||||
for (const o of check.offers) {
|
||||
if (!o.eligible) {
|
||||
lines.push(`▫️ ${name(o.title)} — ${t('bot.partner.needLevel', { level: LEVEL_NAME[o.minLevel ?? 'BAREV'] })}`);
|
||||
} else if (o.remaining === null) {
|
||||
lines.push(`🎁 ${name(o.title)}`);
|
||||
} else if (o.remaining > 0) {
|
||||
lines.push(`🎁 ${name(o.title)} — ${t(`bot.partner.left_${o.limit}`, { count: o.remaining })}`);
|
||||
kb.text(`${t('bot.partner.redeem')}: ${name(o.title)}`.slice(0, 60), `rd:${check.checkId}:${o.id}`).row();
|
||||
} else {
|
||||
lines.push(`✖️ ${name(o.title)} — ${t(`bot.partner.used_${o.limit}`)}`);
|
||||
}
|
||||
}
|
||||
if (!check.offers.length) lines.push(t('bot.partner.noOffers'));
|
||||
return { text: lines.filter((l, i) => l || i === 2).join('\n'), kb };
|
||||
}
|
||||
|
||||
bot.command('start', async (c) => {
|
||||
const code = c.match?.trim();
|
||||
// клубная карта клиента: QR открыли камерой — проверяет собственник или сотрудник партнёра
|
||||
if (code?.startsWith('cd-')) {
|
||||
const id = await identity(c);
|
||||
const locale = localeOf(id);
|
||||
try {
|
||||
const check = await checkCard(ctx, { telegramId: BigInt(c.from!.id), token: code });
|
||||
const { text, kb } = checkReply(check, locale);
|
||||
await c.reply(text, { parse_mode: 'HTML', reply_markup: kb });
|
||||
} catch (e) {
|
||||
if (!(e instanceof DomainError)) throw e;
|
||||
await c.reply(createT(locale)(e.code === 'FORBIDDEN' ? 'bot.partner.notPartner' : `errors.${e.code}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// приглашение партнёра: po- — собственник (из админки), pi- — сотрудник (QR от собственника)
|
||||
if (code?.startsWith('po-') || code?.startsWith('pi-')) {
|
||||
const locale = localeOf(await identity(c));
|
||||
try {
|
||||
const res = await acceptPartnerInvite(ctx, c.from!, code);
|
||||
await c.reply(createT(locale)(res.role === 'OWNER' ? 'bot.partner.linkedOwner' : 'bot.partner.linkedStaff', { partner: res.partnerName }), { reply_markup: cabinetKeyboard(locale) });
|
||||
} catch (e) {
|
||||
if (!(e instanceof DomainError)) throw e;
|
||||
await c.reply(createT(locale)(`errors.${e.code}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// вход в новую админку: страница показала ссылку lg-<код>, подтверждаем по Telegram сотрудника
|
||||
if (code?.startsWith('lg-')) {
|
||||
const req = await db.adminLoginRequest.findUnique({ where: { code: code.slice(3) } });
|
||||
if (!req || req.approvedAt || req.expiresAt < new Date()) {
|
||||
await c.reply(adminT('bot.admin.loginExpired'));
|
||||
return;
|
||||
}
|
||||
const staff = await db.adminUser.findUnique({ where: { telegramId: BigInt(c.from!.id) } });
|
||||
if (!staff || !staff.active || staff.roles.length === 0) {
|
||||
await c.reply(adminT('bot.admin.notAdmin'));
|
||||
return;
|
||||
}
|
||||
await db.adminLoginRequest.update({ where: { id: req.id }, data: { approvedAt: new Date(), adminUserId: staff.id } });
|
||||
await c.reply(adminT('bot.admin.loginOk'));
|
||||
return;
|
||||
}
|
||||
// привязка оператора по ссылке из админки
|
||||
if (code?.startsWith('adm-')) {
|
||||
const admin = await db.adminUser.findUnique({ where: { tgLinkToken: code.slice(4) } });
|
||||
if (!admin) {
|
||||
await c.reply(adminT('bot.admin.linkInvalid'));
|
||||
return;
|
||||
}
|
||||
await db.adminUser.updateMany({ where: { telegramId: BigInt(c.from!.id) }, data: { telegramId: null } });
|
||||
await db.adminUser.update({ where: { id: admin.id }, data: { telegramId: BigInt(c.from!.id), tgLinkToken: null, tgNotify: true } });
|
||||
await c.reply(adminT('bot.admin.linked', { name: admin.name }));
|
||||
return;
|
||||
}
|
||||
if (code) {
|
||||
try {
|
||||
const res = await linkByInvite(ctx, c.from!, code);
|
||||
const id = await identity(c);
|
||||
await c.reply(createT(localeOf(id))(res.kind === 'provider' ? 'bot.pro.linkedProvider' : 'bot.pro.linkedDispatcher', { name: res.name }));
|
||||
} catch (e) {
|
||||
if (e instanceof DomainError) {
|
||||
await c.reply(createT(localeOf(await identity(c)))(`errors.${e.code}`));
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
await greet(c);
|
||||
});
|
||||
|
||||
bot.command('app', greet);
|
||||
|
||||
/** PRO-5: принять / отклонить из уведомления */
|
||||
bot.callbackQuery(/^offer:(accept|decline):(\w+)$/, async (c) => {
|
||||
const [, decision, offerId] = c.match;
|
||||
const id = await identity(c);
|
||||
const t = createT(localeOf(id));
|
||||
if (!id?.provider) {
|
||||
await c.answerCallbackQuery({ text: t('bot.pro.notLinked'), show_alert: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await respondToOffer(ctx, { offerId: offerId!, providerId: id.provider.id, accept: decision === 'accept' });
|
||||
await c.answerCallbackQuery({ text: t(decision === 'accept' ? 'pro.offer.accepted' : 'pro.offer.declined') });
|
||||
await c.editMessageReplyMarkup({ reply_markup: cabinetKeyboard(localeOf(id)) });
|
||||
await c.reply(t(decision === 'accept' ? 'pro.offer.accepted' : 'pro.offer.declined'));
|
||||
} catch (e) {
|
||||
if (e instanceof DomainError) {
|
||||
await c.answerCallbackQuery({ text: t(`errors.${e.code}`), show_alert: true });
|
||||
await c.editMessageReplyMarkup().catch(() => {});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
/** Списать лимитное предложение после проверки */
|
||||
bot.callbackQuery(/^rd:(\w+):(\w+)$/, async (c) => {
|
||||
const locale = localeOf(await identity(c));
|
||||
const t = createT(locale);
|
||||
try {
|
||||
const offer = await redeemOffer(ctx, { telegramId: BigInt(c.from.id), checkId: c.match[1]!, offerId: c.match[2]! });
|
||||
const title = offer.title[locale] || offer.title.ru || '';
|
||||
await c.answerCallbackQuery({ text: t('bot.partner.redeemed') });
|
||||
await c.reply(`✅ ${t('bot.partner.redeemedText', { offer: title })}${offer.remaining !== null ? ` · ${t(`bot.partner.left_${offer.limit}`, { count: offer.remaining })}` : ''}`);
|
||||
} catch (e) {
|
||||
if (!(e instanceof DomainError)) throw e;
|
||||
await c.answerCallbackQuery({ text: t(`errors.${e.code}`), show_alert: true });
|
||||
}
|
||||
});
|
||||
|
||||
/** «Ответить» под уведомлением о сообщении */
|
||||
bot.callbackQuery(/^ar:(c|p):(\w+)$/, async (c) => {
|
||||
const admin = await adminOf(c);
|
||||
if (!admin) {
|
||||
await c.answerCallbackQuery({ text: adminT('bot.admin.notAdmin'), show_alert: true });
|
||||
return;
|
||||
}
|
||||
const [, kind, id] = c.match;
|
||||
const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
|
||||
const name =
|
||||
kind === 'c'
|
||||
? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
|
||||
: ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
|
||||
replyingTo.set(c.chat!.id, { ...peer, name });
|
||||
await c.answerCallbackQuery();
|
||||
await c.reply(adminT('bot.admin.replyPrompt', { name }), { reply_markup: { force_reply: true, input_field_placeholder: adminT('bot.admin.replyPlaceholder') } });
|
||||
});
|
||||
|
||||
bot.command('cancel', async (c) => {
|
||||
replyingTo.delete(c.chat.id);
|
||||
await c.reply(adminT('bot.admin.replyCancelled'));
|
||||
});
|
||||
|
||||
bot.on('message:text', async (c) => {
|
||||
// оператор отвечает клиенту или исполнителю
|
||||
const target = replyingTo.get(c.chat.id);
|
||||
const admin = await adminOf(c);
|
||||
if (target && admin) {
|
||||
try {
|
||||
await replyTo(ctx, { ...(target.clientId ? { clientId: target.clientId } : { providerId: target.providerId! }), adminUserId: admin.id, text: c.message.text });
|
||||
replyingTo.delete(c.chat.id);
|
||||
await c.reply(adminT('bot.admin.replySent', { name: target.name }));
|
||||
} catch (e) {
|
||||
if (e instanceof DomainError) await c.reply(adminT(`errors.${e.code}`));
|
||||
else throw e;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// исполнитель пишет оператору — сообщение появится в админке
|
||||
const id = await identity(c);
|
||||
if (id?.provider) {
|
||||
const first = !(await db.conciergeMessage.count({ where: { providerId: id.provider.id } }));
|
||||
await addProviderMessage(ctx, id.provider.id, c.message.text);
|
||||
if (first) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
|
||||
return;
|
||||
}
|
||||
if (admin) {
|
||||
await c.reply(adminT('bot.admin.hello', { name: admin.name }));
|
||||
return;
|
||||
}
|
||||
await greet(c);
|
||||
});
|
||||
|
||||
bot.on('message', greet);
|
||||
bot.catch((err) => console.error('[bot-pro]', err.error));
|
||||
|
||||
async function main() {
|
||||
if (!token) return;
|
||||
if (APP_URL.startsWith('https://')) {
|
||||
await bot.api.setChatMenuButton({ menu_button: { type: 'web_app', text: 'Barev Pro', web_app: { url: APP_URL } } }).catch(() => {});
|
||||
}
|
||||
await bot.start({ onStart: (me) => console.log(`Бот исполнителей @${me.username} запущен (long polling)`) });
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src"] }
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/** /apartments → /ru/apartments (страница квартир есть только на русском); ?a= и UTM сохраняются */
|
||||
export default async function ApartmentsRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(await searchParams)) if (typeof v === 'string') qs.set(k, v);
|
||||
redirect(`/ru/apartments${qs.size ? `?${qs}` : ''}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** barevclub.com/clean → /<язык>/clean — короткий адрес для рекламы и QR */
|
||||
export default async function CleanRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/clean');
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/** /flower → /ru/flower (страница цветов пока только на русском); ?a= и UTM сохраняются */
|
||||
export default async function FlowerRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(await searchParams)) if (typeof v === 'string') qs.set(k, v);
|
||||
redirect(`/ru/flower${qs.size ? `?${qs}` : ''}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land1 → /<язык>/land1 */
|
||||
export default async function Land1Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land1');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land2 → /<язык>/land2 */
|
||||
export default async function Land2Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land2');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land3 → /<язык>/land3 */
|
||||
export default async function Land3Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land3');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land4 → /<язык>/land4 */
|
||||
export default async function Land4Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land4');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land5 → /<язык>/land5 */
|
||||
export default async function Land5Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land5');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land6 → /<язык>/land6 */
|
||||
export default async function Land6Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land6');
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Отдельный root layout для «/», который только перенаправляет на язык
|
||||
export default function RedirectLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /live → /<язык>/live */
|
||||
export default async function LiveRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/live');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../lib/redirect';
|
||||
|
||||
/** «/» → /<язык> */
|
||||
export default async function Root({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createT, isLocale, LOCALES } from '@barev/i18n';
|
||||
import { OG_SIZE, ogImage } from '../../../lib/og';
|
||||
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** OG-картинка страницы /airport — заголовок H1 */
|
||||
export default async function OgImage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
return ogImage(createT(isLocale(locale) ? locale : 'ru')('landing.svc.airport.h1'));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import '../../variants.css';
|
||||
import '../../home.css';
|
||||
import '../../service.css';
|
||||
import { isLocale, LOCALES } from '@barev/i18n';
|
||||
import type { Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ServicePage, serviceMetadata } from '../../../components/service-page';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return serviceMetadata(params, 'airport');
|
||||
}
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
/** /airport — страница услуги под поисковый запрос; тексты landing.svc.airport, цены из админки */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <ServicePage locale={locale} id="airport" />;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import '../../variants.css';
|
||||
import '../../home.css';
|
||||
import '../../service.css';
|
||||
import '../../flats.css';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Flats } from '../../../components/flats';
|
||||
import { HomeFooter } from '../../../components/home';
|
||||
import { VariantHeader } from '../../../components/variant';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
import { getFlats } from '../../../lib/flats';
|
||||
|
||||
// Квартиры из CRM меняются в течение дня — страница собирается на каждый запрос, список кешируется на минуту.
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamicParams = false;
|
||||
/** Только русская версия (решение владельца, 20.10.2026) */
|
||||
export function generateStaticParams() {
|
||||
return [{ locale: 'ru' }];
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Квартиры в аренду в Ереване — запись на просмотр | Barev Club',
|
||||
description: 'Проверенные квартиры от агентства-партнёра: фото, описание, цена. Выберите квартиру и удобный день — агент позвонит и покажет её.',
|
||||
alternates: { canonical: '/ru/apartments' },
|
||||
openGraph: {
|
||||
title: 'Квартиры в аренду в Ереване',
|
||||
locale: 'ru',
|
||||
type: 'website',
|
||||
siteName: 'Barev Club',
|
||||
},
|
||||
};
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
/** /ru/apartments — «Найдём квартиру»: объекты AREV из CRM и запись на просмотр */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (locale !== 'ru') notFound();
|
||||
const [flats, catalog] = await Promise.all([getFlats(), getCatalog()]);
|
||||
const telegramUrl = `https://t.me/${catalog.botUsername}`;
|
||||
|
||||
return (
|
||||
<div className="v2">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale="ru" path="" className="vh--dark" logoColor="#F6C3D3" />
|
||||
|
||||
<nav className="s-crumbs" aria-label="Навигация">
|
||||
<a href="/ru">Главная</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span aria-current="page">Квартиры</span>
|
||||
</nav>
|
||||
|
||||
<section className="fl-hero">
|
||||
<h1 className="s-title">Найдём квартиру</h1>
|
||||
<p className="s-lead">
|
||||
Квартиры, которые агент уже проверил и согласовал с собственником. Листайте фото, читайте описание и выберите день — агент позвонит, подтвердит
|
||||
время и покажет квартиру.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{flats?.length ? (
|
||||
<Flats flats={flats} telegramUrl={telegramUrl} />
|
||||
) : (
|
||||
<section className="fl-empty">
|
||||
<h2>{flats ? 'Сейчас свободных квартир нет' : 'Список квартир временно недоступен'}</h2>
|
||||
<p>Напишите нам в Telegram — подберём варианты под ваш бюджет и район.</p>
|
||||
<a className="bc-btn fl-book" href={telegramUrl}>
|
||||
Написать в Telegram
|
||||
</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<HomeFooter locale="ru" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createT, isLocale, LOCALES } from '@barev/i18n';
|
||||
import { OG_SIZE, ogImage } from '../../../lib/og';
|
||||
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** OG-картинка страницы заказа уборки */
|
||||
export default async function OgImage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = createT(isLocale(locale) ? locale : 'ru');
|
||||
return ogImage(t('landing.clean.meta.title').replace(/ — Barev Club$/, ''));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import '../../clean.css';
|
||||
import { createT, isLocale, LOCALES, pickMessages, type Locale } from '@barev/i18n';
|
||||
import { Logo } from '@barev/ui';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { CleanForm } from '../../../components/clean';
|
||||
import { FooterContacts, LegalLine } from '../../../components/seo';
|
||||
import { LegalLinks } from '../../../components/legal';
|
||||
import { BotLink, BotLinkProvider, LangMenu, LangSwitch } from '../../../components/client';
|
||||
import { getCleaning } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = createT(locale);
|
||||
return {
|
||||
title: t('landing.clean.meta.title'),
|
||||
description: t('landing.clean.meta.description'),
|
||||
alternates: {
|
||||
canonical: `/${locale}/clean`,
|
||||
languages: Object.fromEntries([...LOCALES.map((l) => [l, `/${l}/clean`]), ['x-default', '/ru/clean']]),
|
||||
},
|
||||
openGraph: { title: t('landing.clean.meta.title'), description: t('landing.clean.meta.description'), locale, type: 'website', siteName: 'Barev Club' },
|
||||
};
|
||||
}
|
||||
|
||||
const ZONES = ['kitchen', 'bathroom', 'rooms'] as const;
|
||||
|
||||
/** barevclub.com/clean — заказ уборки с сайта по модели qlean.ru */
|
||||
export default async function CleanPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const config = await getCleaning();
|
||||
const messages = pickMessages(locale, ['landing.clean.', 'params.cleaning.', 'extras.', 'districts.', 'errors.', 'common.']);
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={config.botUsername}>
|
||||
<div className="wrap cl-wrap">
|
||||
<header className="header">
|
||||
<a href={`/${locale}`} aria-label={t('landing.clean.home')} style={{ display: 'inline-flex' }}>
|
||||
<Logo />
|
||||
</a>
|
||||
<nav className="nav" aria-label={t('landing.nav.label')}>
|
||||
<a href={`/${locale}`}>{t('landing.clean.home')}</a>
|
||||
<a href="#included">{t('landing.clean.includedTitle')}</a>
|
||||
<a href="#how">{t('landing.clean.howTitle')}</a>
|
||||
</nav>
|
||||
<div className="langs">
|
||||
<span className="langs__row">
|
||||
<LangSwitch current={locale} path="/clean" />
|
||||
</span>
|
||||
<LangMenu current={locale} path="/clean" />
|
||||
<BotLink className="bc-btn bc-btn--dark bc-btn--sm" style={{ marginLeft: 6 }}>
|
||||
<span className="tg-long">{t('landing.openTelegram')}</span>
|
||||
<span className="tg-short">Telegram</span>
|
||||
</BotLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<CleanForm locale={locale} config={config} messages={messages} />
|
||||
|
||||
<section id="included" className="cl-included">
|
||||
<h2 className="cl-section-title">{t('landing.clean.includedTitle')}</h2>
|
||||
<div className="cl-zones">
|
||||
{ZONES.map((z) => (
|
||||
<div key={z} className="cl-zone">
|
||||
<h3>{t(`landing.clean.zone.${z}`)}</h3>
|
||||
<ul>
|
||||
{t(`landing.clean.inc.${z}`)
|
||||
.split('\n')
|
||||
.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="cl-variants">
|
||||
<p>{t('landing.clean.general')}</p>
|
||||
<p>{t('landing.clean.renovation')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how" className="how cl-how">
|
||||
<h2 className="bc-h2" style={{ fontSize: 34 }}>
|
||||
{t('landing.clean.howTitle')}
|
||||
</h2>
|
||||
<ol className="cl-how__grid">
|
||||
{([1, 2, 3] as const).map((n) => (
|
||||
<li key={n} className="how__step">
|
||||
<div className="how__num" style={{ background: n === 1 ? 'var(--bc-home)' : n === 2 ? 'var(--bc-blue)' : 'var(--bc-pink)', color: 'var(--bc-ink)' }}>
|
||||
{n}
|
||||
</div>
|
||||
<div style={{ fontWeight: 600 }}>{t(`landing.clean.how${n}`)}</div>
|
||||
<div style={{ fontSize: 13, color: '#CFC8C2' }}>{t(`landing.clean.how${n}Sub`)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<footer className="footer">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontFamily: 'var(--bc-font-head)', fontWeight: 700, color: 'var(--bc-plum)' }}>{t('landing.footer.brand')}</span>
|
||||
<LegalLine locale={locale} />
|
||||
</div>
|
||||
<div className="footer__links">
|
||||
<a href={`/${locale}`}>{t('landing.clean.home')}</a>
|
||||
<FooterContacts />
|
||||
<LegalLinks locale={locale} />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import '../../variants.css';
|
||||
import '../../home.css';
|
||||
import '../../service.css';
|
||||
import '../../flower.css';
|
||||
import { Flower } from '@barev/ui';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { FlowerPlans, NextDelivery } from '../../../components/flowers';
|
||||
import { HomeFooter } from '../../../components/home';
|
||||
import { VariantHeader } from '../../../components/variant';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
/** Пока только русская версия — как /apartments */
|
||||
export function generateStaticParams() {
|
||||
return [{ locale: 'ru' }];
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Цветы по подписке в Ереване — свежий букет каждую неделю | Barev Club',
|
||||
description: 'Сезонные букеты S, M и L по подписке: раз в неделю, раз в 2 недели или раз в месяц. Цветы из армянских теплиц, доставка по Еревану бесплатно, пауза в один клик.',
|
||||
alternates: { canonical: '/ru/flower' },
|
||||
openGraph: { title: 'Цветы по подписке в Ереване', locale: 'ru', type: 'website', siteName: 'Barev Club' },
|
||||
};
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
const STEPS = [
|
||||
{ when: 'Сегодня', title: 'Выберите размер и частоту', text: 'S, M или L — раз в неделю, раз в 2 недели или раз в месяц. Пожелания вроде «без лилий» или «светлые тона» напишите в чат.' },
|
||||
{ when: 'За день', title: 'Флорист соберёт букет', text: 'Из того, что сейчас самое свежее: цветы приезжают с теплиц Гохта и Армавира за 1–2 дня до вас. Каждый раз новый букет.' },
|
||||
{ when: 'В ваш день', title: 'Привезём домой или в офис', text: 'Утром, 9–13, или вечером, 17–21. Курьер позвонит за 30 минут. Доставка по Еревану бесплатно.' },
|
||||
{ when: 'Когда угодно', title: 'Пауза или отмена в один клик', text: 'Уезжаете — поставьте подписку на паузу в Telegram за 48 часов до доставки. Без звонков и штрафов.' },
|
||||
];
|
||||
|
||||
const SEASONS = [
|
||||
{ tone: 'learn', name: 'Весна', text: 'Тюльпаны, ранункулюсы, мимоза и гиацинты. К 8 марта и 7 апреля — особые букеты.' },
|
||||
{ tone: 'pink', name: 'Начало лета', text: 'Пионы, пионовидные розы, альстромерия и полевые цветы.' },
|
||||
{ tone: 'perks', name: 'Лето', text: 'Подсолнухи, эустома, гортензия и розы на пике сезона.' },
|
||||
{ tone: 'docs', name: 'Осень и зима', text: 'Георгины, хризантемы, гиперикум, эвкалипт и тепличные розы.' },
|
||||
] as const;
|
||||
|
||||
const FAQ = [
|
||||
['Можно выбрать цветы самому?', 'Состав подбирает флорист из того, что сейчас в сезоне, — поэтому букеты свежее и каждый раз разные. Пожелания и аллергии учтём.'],
|
||||
['Когда списываются деньги?', 'За 2 дня до каждой доставки. Предоплаты за месяц нет. Пакеты на 3, 6 и 12 месяцев оплачиваются сразу.'],
|
||||
['Как поставить на паузу или отменить?', 'В Telegram, не позже чем за 48 часов до доставки. Пауза — на любой срок, отмена — без штрафов.'],
|
||||
['Куда вы доставляете?', 'По всему Еревану бесплатно. В Абовян, Эчмиадзин и Масис — за 2 000 ֏.'],
|
||||
['Что, если меня не будет дома?', 'Курьер позвонит за 30 минут. Оставим букет консьержу или соседу либо перенесём доставку на следующий день.'],
|
||||
['Зимой букеты будут такими же?', 'Размер и количество стеблей не меняются. Зимой в букетах больше тепличных роз, хризантем и эвкалипта.'],
|
||||
] as const;
|
||||
|
||||
/** /ru/flower — цветы по подписке: тарифы S/M/L, оформление в Telegram */
|
||||
export default async function FlowerPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (locale !== 'ru') notFound();
|
||||
const catalog = await getCatalog();
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale="ru" bot={catalog.botUsername}>
|
||||
<div className="v2">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale="ru" path="" className="vh--dark" logoColor="#F6C3D3" />
|
||||
|
||||
<nav className="s-crumbs" aria-label="Навигация">
|
||||
<a href="/ru">Главная</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span aria-current="page">Цветы</span>
|
||||
</nav>
|
||||
|
||||
<section className="v2-hero fw-hero">
|
||||
<div className="v2-word fw-word" aria-hidden="true">
|
||||
Цветы
|
||||
<span className="fw-word__sticker">
|
||||
<Flower size={112} color="#F7DA6E" textColor="#4A1028" rotate={-12} fontSize={17}>
|
||||
{'от\n8 900 ֏'}
|
||||
</Flower>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="v2-hello" aria-hidden="true">
|
||||
<li>каждую неделю</li>
|
||||
<li>раз в 2 недели</li>
|
||||
<li>раз в месяц</li>
|
||||
</ul>
|
||||
<div className="v2-hero__text">
|
||||
<h1 className="v2-title">Свежие цветы дома — по подписке</h1>
|
||||
<div className="h-hero__side">
|
||||
<p className="v2-sub">Флорист соберёт сезонный букет, а мы привезём его в удобный день. Вам останется поставить цветы в вазу.</p>
|
||||
<div className="h-actions">
|
||||
<a href="#plans" className="bc-btn h-cta">
|
||||
Выбрать букет
|
||||
</a>
|
||||
<BotLink className="bc-btn h-cta-ghost">Написать в Telegram</BotLink>
|
||||
</div>
|
||||
<NextDelivery />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ol className="v2-line" aria-label="Как это работает">
|
||||
{STEPS.map((s, i) => (
|
||||
<li key={s.when} className="v2-step" data-live={i === 0}>
|
||||
<div className="v2-step__when">{s.when}</div>
|
||||
<div className="v2-step__body">
|
||||
<h2>{s.title}</h2>
|
||||
<p>{s.text}</p>
|
||||
</div>
|
||||
<div className="v2-step__meta">{i === 2 && <span className="v2-chip">0 ֏ доставка</span>}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<FlowerPlans />
|
||||
|
||||
<section className="fw-section" aria-labelledby="season-title">
|
||||
<div className="h-section-head">
|
||||
<h2 id="season-title">Что будет в букетах</h2>
|
||||
<p>Берём то, что сейчас в сезоне в армянских теплицах, поэтому цветы свежее и стоят дольше.</p>
|
||||
</div>
|
||||
<div className="fw-seasons">
|
||||
{SEASONS.map((s) => (
|
||||
<div key={s.name} className={`fw-season bc-tone-${s.tone}`}>
|
||||
<h3>{s.name}</h3>
|
||||
<p>{s.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="fw-duo">
|
||||
<div className="fw-gift">
|
||||
<span className="fw-kicker">Для тех, кто далеко</span>
|
||||
<h2>Цветы маме в Ереван — каждый месяц</h2>
|
||||
<p>Оплачиваете из любой страны, мы вручаем букет с вашей открыткой и присылаем фото в WhatsApp или Telegram. Пакеты на 3, 6 и 12 месяцев.</p>
|
||||
<div className="fw-gift__prices">
|
||||
<div>
|
||||
<b>$39</b>
|
||||
<span>букет M</span>
|
||||
</div>
|
||||
<div>
|
||||
<b>$59</b>
|
||||
<span>букет L</span>
|
||||
</div>
|
||||
<div>
|
||||
<b>$89</b>
|
||||
<span>L + ваза и сладости</span>
|
||||
</div>
|
||||
</div>
|
||||
<BotLink className="bc-btn fw-gift__cta">Подарить подписку</BotLink>
|
||||
</div>
|
||||
<div className="fw-guarantee">
|
||||
<Flower size={84} color="#1F3311" textColor="#A8C38A" rotate={8} fontSize={15}>
|
||||
100%
|
||||
</Flower>
|
||||
<h2>Первый букет без риска</h2>
|
||||
<p>Не понравится — пришлите фото в течение суток, и мы вернём деньги полностью. Если букет простоял меньше 5 дней, заменим его бесплатно.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="fw-faq-wrap" aria-labelledby="faq-title">
|
||||
<div className="h-section-head">
|
||||
<h2 id="faq-title">Частые вопросы</h2>
|
||||
</div>
|
||||
<div className="faq fw-faq">
|
||||
{FAQ.map(([q, a]) => (
|
||||
<details key={q}>
|
||||
<summary>{q}</summary>
|
||||
<p>{a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="v2-final">
|
||||
<div>
|
||||
<h2>Первый букет — уже на этой неделе</h2>
|
||||
<p>Напишите в Telegram размер и частоту — подтвердим день доставки и пришлём ссылку на оплату. Цена для первых 100 подписчиков сохранится, даже когда вырастет.</p>
|
||||
</div>
|
||||
<BotLink className="bc-btn v2-final__cta">Оформить подписку</BotLink>
|
||||
</section>
|
||||
|
||||
<HomeFooter locale="ru" />
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { CATEGORY_ICON, Flower, Icon, Logo, type CategoryCode } from '@barev/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider, LangSwitch } from '../../../components/client';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
/** Прежняя главная (до октября 2026) — сохранена для сравнения, в поиск не попадает */
|
||||
export const metadata = { robots: { index: false, follow: true } };
|
||||
|
||||
const TONE: Record<CategoryCode, { bg: string; ink: string }> = {
|
||||
HOME: { bg: 'var(--bc-home)', ink: 'var(--bc-home-ink)' },
|
||||
DOCS: { bg: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
|
||||
LEARNING: { bg: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
|
||||
PERKS: { bg: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' },
|
||||
};
|
||||
|
||||
/** Прежняя главная: лендинг v2 · плотный (LND-1) */
|
||||
export default async function Landing({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const amd = (n: number) => formatAmd(locale, n);
|
||||
const catalog = await getCatalog();
|
||||
const cat = (code: CategoryCode) => catalog.categories.find((c) => c.code === code);
|
||||
const cleaningFrom = cat('HOME')?.services.find((s) => s.code === 'cleaning')?.fromPrice ?? 0;
|
||||
const maxDiscount = Math.max(0, ...catalog.categories.map((c) => c.barevPlusDiscountPct));
|
||||
const discounts = catalog.categories.filter((c) => c.barevPlusDiscountPct > 0);
|
||||
const preview = [
|
||||
{ tone: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)', title: t('landing.preview.cleaningRegular'), meta: t('landing.preview.cleaningRegularMeta'), badge: t('status.CLEANING.EN_ROUTE') },
|
||||
{ tone: 'var(--bc-home)', ink: 'var(--bc-home-ink)', title: t('landing.preview.cleaning'), meta: t('landing.preview.cleaningMeta'), badge: t('status.CLEANING.ASSIGNED') },
|
||||
{ tone: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)', title: t('services.social_card.name'), meta: t('status.DOCUMENTS.DOCS_REVIEW'), badge: t('landing.preview.inWork') },
|
||||
{ tone: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)', title: t('landing.preview.lesson'), meta: t('landing.preview.lessonMeta'), badge: t('landing.preview.today') },
|
||||
];
|
||||
const faq = ['install', 'languages', 'plus', 'gift'] as const;
|
||||
const check = (color: string) => <Icon name="check" size={18} strokeWidth={2.6} style={{ color }} />;
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="wrap">
|
||||
<header className="header">
|
||||
<Logo />
|
||||
<nav className="nav" aria-label={t('landing.nav.label')}>
|
||||
<a href="#services">{t('landing.nav.services')}</a>
|
||||
<a href="#how">{t('landing.nav.how')}</a>
|
||||
<a href="#plans">{t('landing.nav.plans')}</a>
|
||||
<a href="#faq">{t('landing.nav.faq')}</a>
|
||||
</nav>
|
||||
<div className="langs">
|
||||
<LangSwitch current={locale} path="/land0" />
|
||||
<BotLink className="bc-btn bc-btn--dark bc-btn--sm" style={{ marginLeft: 6 }}>
|
||||
{t('landing.openTelegram')}
|
||||
</BotLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="row">
|
||||
<div className="hero">
|
||||
<div className="hero__tag">{t('landing.hero.tag')}</div>
|
||||
<h1 className="bc-h1">{t('landing.hero.title')}</h1>
|
||||
<p className="hero__sub">{t('landing.hero.sub')}</p>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<BotLink className="bc-btn bc-btn--dark" style={{ height: 56, padding: '0 28px' }}>
|
||||
{t('landing.hero.cta')}
|
||||
<Icon name="arrow" size={18} strokeWidth={2.2} />
|
||||
</BotLink>
|
||||
<a href="#services" className="bc-btn bc-btn--light" style={{ height: 56 }}>
|
||||
{t('landing.hero.services')}
|
||||
</a>
|
||||
</div>
|
||||
{/* сейчас заказать можно уборку — короткий путь на форму /clean */}
|
||||
<a href={`/${locale}/clean`} className="clean-promo">
|
||||
<span className="clean-promo__text">
|
||||
<b>{t('landing.hero.cleanTitle', { price: amd(cleaningFrom) })}</b>
|
||||
<span>{t('landing.hero.cleanText')}</span>
|
||||
</span>
|
||||
<span className="bc-btn bc-btn--accent clean-promo__cta">{t('landing.hero.cleanCta')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="side">
|
||||
<div className="bc-card" style={{ borderRadius: 36, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="bc-between" style={{ padding: '0 4px 6px' }}>
|
||||
<div className="bc-h3" style={{ fontSize: 20 }}>
|
||||
{t('landing.preview.title')}
|
||||
</div>
|
||||
<div className="bc-small bc-muted">{t('landing.preview.inApp')}</div>
|
||||
</div>
|
||||
{preview.map((p) => (
|
||||
<div key={p.title} className="preview-row" style={{ background: p.tone, color: p.ink }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{p.title}</div>
|
||||
<div style={{ fontSize: 12 }}>{p.meta}</div>
|
||||
</div>
|
||||
<span className="bc-badge">{p.badge}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="bc-card bc-tone-perks" style={{ flex: 1, borderRadius: 30, padding: 18, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Flower size={72} rotate={-10}>
|
||||
5
|
||||
</Flower>
|
||||
<div className="langs-sticker">
|
||||
<span>{t('landing.languages')}</span>
|
||||
<span className="langs-sticker__list">RU EN 中文 HY ES</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bc-card bc-tone-learn" style={{ flex: 1, borderRadius: 30, padding: 18, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 4 }}>
|
||||
<div style={{ fontFamily: 'var(--bc-font-head)', fontWeight: 700, fontSize: 18 }}>{t('landing.noInstall')}</div>
|
||||
<div className="bc-small">{t('landing.noInstallSub')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="services" className="row">
|
||||
{(['HOME', 'DOCS', 'LEARNING'] as const).map((code) => (
|
||||
<div key={code} className="dir" style={{ background: TONE[code].bg, color: TONE[code].ink }}>
|
||||
<div className="bc-between">
|
||||
<h2 className="dir__title" style={{ margin: 0 }}>
|
||||
{t(`categories.${code}.name`)}
|
||||
</h2>
|
||||
<Icon name={CATEGORY_ICON[code]} size={28} />
|
||||
</div>
|
||||
<div className="bc-stack" style={{ gap: 6 }}>
|
||||
{(cat(code)?.services ?? []).map((s) =>
|
||||
s.code === 'cleaning' && s.active ? (
|
||||
<a key={s.code} href={`/${locale}/clean`} className="bc-row-pill">
|
||||
<span style={{ fontWeight: 600 }}>{t(`services.${s.code}.name`)}</span>
|
||||
<span>{t('common.from', { price: amd(s.fromPrice) })}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div key={s.code} className="bc-row-pill" data-soon={!s.active}>
|
||||
<span style={{ fontWeight: 600 }}>{t(`services.${s.code}.name`)}</span>
|
||||
<span>{s.active ? t('common.from', { price: amd(s.fromPrice) }) : t('common.soon')}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 14 }}>{t(`landing.dir.${code}`)}</div>
|
||||
{code === 'HOME' ? (
|
||||
<a href={`/${locale}/clean`} style={{ marginTop: 'auto', fontWeight: 700, color: TONE[code].ink }}>
|
||||
{t('landing.hero.cleanCta')} →
|
||||
</a>
|
||||
) : (
|
||||
<BotLink style={{ marginTop: 'auto', fontWeight: 700, color: TONE[code].ink }}>
|
||||
{(cat(code)?.services ?? []).some((s) => s.active) ? t(`landing.dir.${code}_cta`) : t('landing.dir.soonCta')} →
|
||||
</BotLink>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="dir" style={{ background: TONE.PERKS.bg, color: TONE.PERKS.ink }}>
|
||||
<div className="bc-between">
|
||||
<h2 className="dir__title" style={{ margin: 0 }}>
|
||||
{t('categories.PERKS.name')}
|
||||
</h2>
|
||||
<Icon name="gift" size={28} />
|
||||
</div>
|
||||
<div className="perks-big">{t('landing.perks.upTo', { pct: maxDiscount })}</div>
|
||||
<div className="bc-chips">
|
||||
{(['cafes', 'restaurants', 'gifts'] as const).map((k) => (
|
||||
<span key={k} className="bc-badge" style={{ padding: '8px 14px', fontSize: 14, fontWeight: 500 }}>
|
||||
{t(`landing.perks.${k}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 14 }}>{t('landing.perks.text')}</div>
|
||||
<BotLink style={{ marginTop: 'auto', fontWeight: 700, color: TONE.PERKS.ink }}>{t('landing.perks.cta')} →</BotLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how" className="row">
|
||||
<div className="how">
|
||||
<h2 className="bc-h2" style={{ fontSize: 34 }}>
|
||||
<span style={{ fontWeight: 700 }}>{t('landing.how.titleBold')}</span> <span className="bc-italic">{t('landing.how.titleItalic')}</span>
|
||||
</h2>
|
||||
<div className="how__grid">
|
||||
{[
|
||||
{ bg: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
|
||||
{ bg: 'var(--bc-perks)', ink: 'var(--bc-plum)' },
|
||||
{ bg: 'var(--bc-home)', ink: 'var(--bc-home-ink)' },
|
||||
{ bg: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
|
||||
].map((c, i) => (
|
||||
<div key={i} className="how__step">
|
||||
<div className="how__num" style={{ background: c.bg, color: c.ink }}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div style={{ fontWeight: 600 }}>{t(`landing.how.step${i + 1}`)}</div>
|
||||
<div style={{ fontSize: 13, color: '#CFC8C2' }}>{t(`landing.how.step${i + 1}_sub`)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="agency">
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontFamily: 'var(--bc-font-head)', fontWeight: 800, fontSize: 23, lineHeight: 1.1, textWrap: 'balance', hyphens: 'manual' }}>{t('landing.agency.title')}</div>
|
||||
<div style={{ fontSize: 15 }}>{t('landing.agency.text')}</div>
|
||||
</div>
|
||||
<div className="sticker">
|
||||
<div style={{ fontFamily: 'var(--bc-font-head)', fontStyle: 'italic', fontSize: 20, fontWeight: 500 }}>{t('landing.agency.gift')}</div>
|
||||
<div style={{ fontSize: 12 }}>{t('landing.agency.giftSub')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="plans" className="row" style={{ alignItems: 'stretch' }}>
|
||||
<div className="plan" style={{ background: '#fff' }}>
|
||||
<div className="bc-between" style={{ alignItems: 'baseline' }}>
|
||||
<div className="bc-h2" style={{ fontSize: 28 }}>
|
||||
Barev
|
||||
</div>
|
||||
<div className="bc-num" style={{ fontSize: 32 }}>
|
||||
{amd(0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="plan__list">
|
||||
<div>
|
||||
{check('var(--bc-ok)')}
|
||||
{t('plans.free.perks')}
|
||||
</div>
|
||||
<div>
|
||||
{check('var(--bc-ok)')}
|
||||
{t('plans.free.prices')}
|
||||
</div>
|
||||
<div>
|
||||
{check('var(--bc-ok)')}
|
||||
{t('plans.free.status')}
|
||||
</div>
|
||||
</div>
|
||||
<BotLink className="bc-btn bc-btn--outline bc-btn--block" style={{ marginTop: 'auto' }}>
|
||||
{t('landing.hero.cta')}
|
||||
</BotLink>
|
||||
</div>
|
||||
<div className="plan bc-tone-learn">
|
||||
<div className="bc-between" style={{ alignItems: 'baseline' }}>
|
||||
<div className="bc-h2" style={{ fontSize: 28 }}>
|
||||
Barev+
|
||||
</div>
|
||||
<div className="bc-num" style={{ fontSize: 24 }}>
|
||||
{t('plans.plus.price', { price: amd(catalog.barevPlus.monthlyPrice) })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="plan__list">
|
||||
<div>
|
||||
{check('var(--bc-accent)')}
|
||||
{t('plans.plus.all')}
|
||||
</div>
|
||||
{discounts.map((c) => (
|
||||
<div key={c.code}>
|
||||
{check('var(--bc-accent)')}
|
||||
{t('plans.plus.discount', { pct: c.barevPlusDiscountPct, category: t(`categories.${c.code}.name`) })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<BotLink className="bc-btn bc-btn--dark bc-btn--block" style={{ marginTop: 'auto' }}>
|
||||
{t('landing.plans.tryPlus')}
|
||||
</BotLink>
|
||||
</div>
|
||||
<div id="faq" className="faq">
|
||||
<h2 className="bc-h2" style={{ fontSize: 28, marginBottom: 4 }}>
|
||||
{t('landing.nav.faq')}
|
||||
</h2>
|
||||
{faq.map((k, i) => (
|
||||
<details key={k} open={i === 0}>
|
||||
<summary>{t(`landing.faq.${k}.q`)}</summary>
|
||||
<p>{t(`landing.faq.${k}.a`, { pct: maxDiscount, days: catalog.barevPlus.giftDays })}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="footer">
|
||||
<div style={{ fontFamily: 'var(--bc-font-head)', fontWeight: 700, color: 'var(--bc-plum)' }}>{t('landing.footer.brand')}</div>
|
||||
<div className="footer__links">
|
||||
<BotLink>{t('landing.footer.partner')}</BotLink>
|
||||
{process.env.CONTACT_EMAIL && <a href={`mailto:${process.env.CONTACT_EMAIL}`}>{process.env.CONTACT_EMAIL}</a>}
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import '../../variants.css';
|
||||
import { quote } from '@barev/core';
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { CATEGORY_ICON, Icon, type CategoryCode } from '@barev/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { VariantHeader, variantMetadata } from '../../../components/variant';
|
||||
import { getCatalog, getCleaning } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return variantMetadata(params, '/land1', 'landing.v1.title');
|
||||
}
|
||||
|
||||
/** Версия 1 «Чат»: продукт живёт в Telegram, поэтому страница — переписка с ботом, кнопки — как inline-клавиатура */
|
||||
export default async function Land1({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const amd = (n: number) => formatAmd(locale, n);
|
||||
const [catalog, cleaning] = await Promise.all([getCatalog(), getCleaning()]);
|
||||
// цена в диалоге — настоящая: обычная уборка, 2 комнаты, 1 санузел
|
||||
const demoPrice = quote(cleaning.pricing, { cleaningType: 'regular', rooms: 2, bathrooms: 1 }).totalPrice;
|
||||
const groups = catalog.categories.filter((c) => c.code !== 'PERKS' && c.services.length > 0);
|
||||
|
||||
const chat = [
|
||||
{ from: 'me', text: t('landing.v1.u1'), time: '10:02' },
|
||||
{ from: 'bot', text: t('landing.v1.b1', { price: amd(demoPrice) }), time: '10:02' },
|
||||
{ from: 'me', text: t('landing.v1.u2'), time: '10:03' },
|
||||
{ from: 'bot', text: t('landing.v1.b2'), time: '10:03' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v1">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/land1" />
|
||||
|
||||
<section className="v1-hero">
|
||||
<div className="v1-hero__text">
|
||||
<h1 className="v1-title">{t('landing.v1.title')}</h1>
|
||||
<p className="v1-sub">{t('landing.v1.sub')}</p>
|
||||
</div>
|
||||
|
||||
<div className="v1-chat" aria-label={t('landing.v1.botName')}>
|
||||
<div className="v1-chat__head">
|
||||
<span className="v1-avatar" aria-hidden="true">
|
||||
B
|
||||
</span>
|
||||
<span>
|
||||
<b>{t('landing.v1.botName')}</b>
|
||||
<small>{t('landing.v1.botStatus')}</small>
|
||||
</span>
|
||||
</div>
|
||||
<ol className="v1-chat__log">
|
||||
{chat.map((m, i) => (
|
||||
<li key={i} className={`v1-msg v1-msg--${m.from}`} style={{ ['--d' as string]: `${0.3 + i * 0.9}s` }}>
|
||||
{m.text}
|
||||
<time>{m.time}</time>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{/* как inline-клавиатура под последним сообщением бота */}
|
||||
<div className="v1-keys" style={{ ['--d' as string]: `${0.3 + chat.length * 0.9}s` }}>
|
||||
<a href={`/${locale}/clean`} className="v1-key v1-key--main">
|
||||
{t('landing.hero.cleanCta')}
|
||||
</a>
|
||||
<BotLink className="v1-key">{t('landing.v1.cta')}</BotLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="v1-card">
|
||||
<h2 className="v1-h2">{t('landing.v1.canTitle')}</h2>
|
||||
<p className="v1-muted">{t('landing.v1.canText')}</p>
|
||||
<div className="v1-groups">
|
||||
{groups.map((g) => (
|
||||
<div key={g.code} className="v1-group">
|
||||
<h3>
|
||||
<Icon name={CATEGORY_ICON[g.code as CategoryCode]} size={20} />
|
||||
{t(`categories.${g.code}.name`)}
|
||||
</h3>
|
||||
<div className="v1-group__keys">
|
||||
{g.services.map((s) =>
|
||||
s.code === 'cleaning' && s.active ? (
|
||||
<a key={s.code} href={`/${locale}/clean`} className="v1-svc v1-svc--live">
|
||||
<span>{t(`services.${s.code}.name`)}</span>
|
||||
<span>{t('common.from', { price: amd(s.fromPrice) })}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div key={s.code} className="v1-svc">
|
||||
<span>{t(`services.${s.code}.name`)}</span>
|
||||
<span>{s.active ? t('common.from', { price: amd(s.fromPrice) }) : t('common.soon')}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="v1-why">
|
||||
<h2 className="v1-h2">{t('landing.v1.whyTitle')}</h2>
|
||||
<div className="v1-why__grid">
|
||||
{([1, 2, 3] as const).map((n) => (
|
||||
<div key={n} className="v1-why__item">
|
||||
<b>{t(`landing.v1.why${n}`)}</b>
|
||||
<span>{t(`landing.v1.why${n}Sub`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="v1-final">
|
||||
<div>
|
||||
<h2 className="v1-final__title">{t('landing.v1.ctaTitle')}</h2>
|
||||
<p>{t('landing.v1.ctaText')}</p>
|
||||
</div>
|
||||
{/* поле ввода сообщения: «Barev!» уже набрано, кнопка отправки открывает бота */}
|
||||
<BotLink className="v1-compose">
|
||||
<span className="v1-compose__text">Barev!</span>
|
||||
<span className="v1-compose__send" aria-hidden="true">
|
||||
<Icon name="arrow" size={20} strokeWidth={2.4} />
|
||||
</span>
|
||||
<span className="v-sr">{t('landing.v1.cta')}</span>
|
||||
</BotLink>
|
||||
</section>
|
||||
|
||||
<footer className="v-footer">
|
||||
<span>{t('landing.footer.brand')}</span>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { isLocale } from '@barev/i18n';
|
||||
import { notFound, permanentRedirect } from 'next/navigation';
|
||||
|
||||
/** Версия 2 «Переезд по шагам» стала главной — старые ссылки /land2 ведут на неё с теми же параметрами */
|
||||
export default async function Land2({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const [{ locale }, sp] = await Promise.all([params, searchParams]);
|
||||
if (!isLocale(locale)) notFound();
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(sp)) if (typeof v === 'string') qs.set(k, v);
|
||||
permanentRedirect(`/${locale}${qs.size ? `?${qs}` : ''}`);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import '../../variants.css';
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Icon } from '@barev/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { GiftCode } from '../../../components/gift-code';
|
||||
import { VariantHeader, variantMetadata } from '../../../components/variant';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return variantMetadata(params, '/land3', 'landing.v3.title');
|
||||
}
|
||||
|
||||
/** Версия 3 «Подарок от агентства»: для тех, кто пришёл по QR из договора аренды. Бирка для ключей с месяцем Barev+ */
|
||||
export default async function Land3({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const amd = (n: number) => formatAmd(locale, n);
|
||||
const catalog = await getCatalog();
|
||||
const home = catalog.categories.find((c) => c.code === 'HOME');
|
||||
const cleaningFrom = home?.services.find((s) => s.code === 'cleaning')?.fromPrice ?? 0;
|
||||
const homePct = home?.barevPlusDiscountPct ?? 0;
|
||||
// так же округляет скидку сервер: вниз до целого драма
|
||||
const cleaningWithPlus = cleaningFrom - Math.floor((cleaningFrom * homePct) / 100);
|
||||
const discounts = catalog.categories.filter((c) => c.barevPlusDiscountPct > 0);
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v3">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/land3" />
|
||||
|
||||
<section className="v3-hero">
|
||||
<div className="v3-hero__text">
|
||||
<span className="v3-kicker">{t('landing.v3.tag')}</span>
|
||||
<h1 className="v3-title">{t('landing.v3.title')}</h1>
|
||||
<p className="v3-sub">{t('landing.v3.sub')}</p>
|
||||
<BotLink className="bc-btn bc-btn--accent v3-cta">{t('landing.v3.cta')}</BotLink>
|
||||
</div>
|
||||
<div className="v3-tagwrap" aria-hidden="true">
|
||||
<svg className="v3-string" viewBox="0 0 120 160" fill="none">
|
||||
<path d="M60 0 C 20 40, 100 80, 60 132" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<div className="v3-tag">
|
||||
<span className="v3-tag__hole" />
|
||||
<span className="v3-tag__label">{t('landing.agency.gift')}</span>
|
||||
<span className="v3-tag__title">{t('landing.v3.giftTitle')}</span>
|
||||
<GiftCode withCode={t('landing.v3.giftCode')} withoutCode={t('landing.v3.giftNoCode')} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="v3-steps">
|
||||
<h2 className="v3-h2">{t('landing.v3.stepsTitle')}</h2>
|
||||
<ol>
|
||||
{([1, 2, 3] as const).map((n) => (
|
||||
<li key={n}>
|
||||
<span className="v3-num">{n}</span>
|
||||
<b>{t(`landing.v3.step${n}`)}</b>
|
||||
<span>{t(`landing.v3.step${n}Sub`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="v3-benefits">
|
||||
<div className="v3-deal">
|
||||
<h2 className="v3-h2">{t('landing.v3.benefitsTitle')}</h2>
|
||||
{homePct > 0 && cleaningFrom > 0 && (
|
||||
<a href={`/${locale}/clean`} className="v3-deal__card">
|
||||
<b>{t('landing.v3.cleaningDeal', { price: amd(cleaningWithPlus), full: amd(cleaningFrom) })}</b>
|
||||
<span>{t('landing.v3.cleaningDealSub')}</span>
|
||||
<span className="v3-deal__go">{t('landing.hero.cleanCta')}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<ul className="v3-list">
|
||||
{discounts.map((c) => (
|
||||
<li key={c.code}>
|
||||
<Icon name="check" size={20} strokeWidth={2.6} />
|
||||
{t('plans.plus.discount', { pct: c.barevPlusDiscountPct, category: t(`categories.${c.code}.name`) })}
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Icon name="check" size={20} strokeWidth={2.6} />
|
||||
{t('plans.free.perks')}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="v3-final">
|
||||
<p>{t('landing.v3.noCode')}</p>
|
||||
<BotLink className="bc-btn bc-btn--dark">{t('landing.v3.cta')}</BotLink>
|
||||
</section>
|
||||
|
||||
<footer className="v-footer">
|
||||
<span>{t('landing.footer.brand')}</span>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import '../../variants.css';
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { VariantHeader, variantMetadata } from '../../../components/variant';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return variantMetadata(params, '/land4', 'landing.v4.title');
|
||||
}
|
||||
|
||||
/** Текст табло: каждая буква — отдельная перекидная пластинка, при загрузке они один раз «перещёлкиваются» по очереди */
|
||||
function Flaps({ text, start = 0 }: { text: string; start?: number }) {
|
||||
return (
|
||||
<span className="v4-flaps" aria-label={text}>
|
||||
{Array.from(text).map((ch, i) =>
|
||||
ch === ' ' ? (
|
||||
<span key={i} className="v4-gap" aria-hidden="true" />
|
||||
) : (
|
||||
<span key={i} className="v4-flap" aria-hidden="true" style={{ ['--i' as string]: start + i }}>
|
||||
{ch}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Версия 4 «Табло прилёта»: для тех, кто только прилетел. Услуги клуба — как рейсы на табло, CTA — посадочный талон */
|
||||
export default async function Land4({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const amd = (n: number) => formatAmd(locale, n);
|
||||
const catalog = await getCatalog();
|
||||
const rows = catalog.categories
|
||||
.filter((c) => c.code !== 'PERKS')
|
||||
.flatMap((c) => c.services)
|
||||
.sort((a, b) => Number(b.active) - Number(a.active));
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v4">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/land4" />
|
||||
|
||||
<section className="v4-hero">
|
||||
<h1 className="v4-title">{t('landing.v4.title')}</h1>
|
||||
<p className="v4-sub">{t('landing.v4.sub')}</p>
|
||||
</section>
|
||||
|
||||
<section className="v4-board" aria-label={`${t('landing.v4.board')}: ${t('landing.v4.city')}`}>
|
||||
<div className="v4-board__head">
|
||||
<span className="v4-board__name">
|
||||
{t('landing.v4.board')}
|
||||
</span>
|
||||
<span className="v4-board__city">{t('landing.v4.city')} EVN</span>
|
||||
</div>
|
||||
<table className="v4-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('landing.v4.colFlight')}</th>
|
||||
<th>{t('landing.v4.colService')}</th>
|
||||
<th>{t('landing.v4.colPrice')}</th>
|
||||
<th>{t('landing.v4.colStatus')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s, i) => (
|
||||
<tr key={s.code} data-live={s.active}>
|
||||
<td className="v4-code">BC {String(i + 1).padStart(3, '0')}</td>
|
||||
<td className="v4-name">
|
||||
{s.code === 'cleaning' && s.active ? (
|
||||
<a href={`/${locale}/clean`}>
|
||||
<Flaps text={t(`services.${s.code}.name`)} start={i * 3} />
|
||||
</a>
|
||||
) : (
|
||||
<Flaps text={t(`services.${s.code}.name`)} start={i * 3} />
|
||||
)}
|
||||
</td>
|
||||
<td className="v4-price">{s.active ? t('common.from', { price: amd(s.fromPrice) }) : '—'}</td>
|
||||
<td>
|
||||
<span className="v4-status">{s.active ? t('landing.v4.live') : t('landing.v4.soon')}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* посадочный талон: корешок отрывается по пунктиру, кнопка — посадка в бота */}
|
||||
<section className="v4-pass">
|
||||
<div className="v4-pass__main">
|
||||
<div className="v4-pass__title">{t('landing.v4.passTitle')}</div>
|
||||
<dl className="v4-pass__grid">
|
||||
<div>
|
||||
<dt>{t('landing.v4.passenger')}</dt>
|
||||
<dd>{t('landing.v4.passengerValue')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('landing.v4.from')}</dt>
|
||||
<dd>{t('landing.v4.fromValue')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('landing.v4.to')}</dt>
|
||||
<dd>{t('landing.v4.toValue')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('landing.v4.gate')}</dt>
|
||||
<dd>{t('landing.v4.gateValue')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="v4-pass__stub">
|
||||
<BotLink className="bc-btn v4-pass__cta">{t('landing.v4.cta')}</BotLink>
|
||||
<a href={`/${locale}/clean`} className="v4-pass__link">
|
||||
{t('landing.v4.clean')}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="v-footer">
|
||||
<span>{t('landing.footer.brand')}</span>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import '../../variants.css';
|
||||
import { createT, isLocale, LOCALES, pickMessages, type Locale } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { Savings } from '../../../components/savings';
|
||||
import { VariantHeader, variantMetadata } from '../../../components/variant';
|
||||
import { getCatalog, getCleaning } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return variantMetadata(params, '/land5', 'landing.v5.title');
|
||||
}
|
||||
|
||||
/** Версия 5 «Калькулятор Barev+»: для тех, кто решает про подписку. Честная экономия за вычетом цены подписки */
|
||||
export default async function Land5({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const [catalog, cleaning] = await Promise.all([getCatalog(), getCleaning()]);
|
||||
const others = catalog.categories.filter((c) => c.code !== 'HOME' && c.barevPlusDiscountPct > 0);
|
||||
const messages = pickMessages(locale, ['landing.v5.', 'params.cleaning.', 'common.']);
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v5">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/land5" />
|
||||
|
||||
<section className="v5-hero">
|
||||
<h1 className="v5-title">{t('landing.v5.title')}</h1>
|
||||
<p className="v5-sub">{t('landing.v5.sub')}</p>
|
||||
</section>
|
||||
|
||||
<Savings locale={locale} config={cleaning} monthlyPrice={catalog.barevPlus.monthlyPrice} messages={messages} />
|
||||
|
||||
<section className="v5-more">
|
||||
<div>
|
||||
<h2>{t('landing.v5.other')}</h2>
|
||||
<ul>
|
||||
{others.map((c) => (
|
||||
<li key={c.code}>
|
||||
<b>−{c.barevPlusDiscountPct}%</b>
|
||||
{t(`categories.${c.code}.name`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="v5-more__cta">
|
||||
<BotLink className="bc-btn bc-btn--dark">{t('landing.v5.cta')}</BotLink>
|
||||
<p>{t('landing.v5.gift')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="v-footer">
|
||||
<span>{t('landing.footer.brand')}</span>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import '../../variants.css';
|
||||
import { quote } from '@barev/core';
|
||||
import { createT, formatAmd, formatDateTime, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { VariantHeader, variantMetadata } from '../../../components/variant';
|
||||
import { getCatalog, getCleaning } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return variantMetadata(params, '/land6', 'landing.v6.tag');
|
||||
}
|
||||
|
||||
/** Пример дохода агентства: условные клиенты и заказы, цена и процент — настоящие из каталога и настроек */
|
||||
const EXAMPLE = { clients: 20, orders: 2 };
|
||||
|
||||
/** Печать клуба: текст по кругу и «Barev» в центре */
|
||||
function Stamp() {
|
||||
return (
|
||||
<svg className="v6-stamp" viewBox="0 0 160 160" aria-hidden="true">
|
||||
<defs>
|
||||
<path id="v6-ring" d="M80 80 m-58 0 a58 58 0 1 1 116 0 a58 58 0 1 1 -116 0" />
|
||||
</defs>
|
||||
<circle cx="80" cy="80" r="74" fill="none" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="80" cy="80" r="44" fill="none" stroke="currentColor" strokeWidth="2" />
|
||||
{/* длина текста = окружность r=58 (2π·58 ≈ 364), чтобы конец не наезжал на начало */}
|
||||
<text fontSize="14" fontWeight="700" fill="currentColor">
|
||||
<textPath href="#v6-ring" textLength="356" lengthAdjust="spacing">
|
||||
BAREV CLUB ✦ YEREVAN ✦ PARTNER ✦
|
||||
</textPath>
|
||||
</text>
|
||||
<text x="80" y="88" textAnchor="middle" fontSize="24" fontWeight="800" fontStyle="italic" fill="currentColor">
|
||||
Barev
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Версия 6 «Для агентств»: B2B-страница главного канала привлечения, оформленная как договор с пунктами и печатью */
|
||||
export default async function Land6({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const amd = (n: number) => formatAmd(locale, n);
|
||||
const [catalog, cleaning] = await Promise.all([getCatalog(), getCleaning()]);
|
||||
const agency = catalog.agency ?? { commissionPct: 10, attributionMonths: 12 };
|
||||
const price = quote(cleaning.pricing, { cleaningType: 'regular', rooms: 2, bathrooms: 1 }).totalPrice;
|
||||
const amount = Math.round((EXAMPLE.clients * EXAMPLE.orders * price * agency.commissionPct) / 100);
|
||||
const vars = { pct: agency.commissionPct, months: agency.attributionMonths, days: catalog.barevPlus.giftDays };
|
||||
const sections = [
|
||||
{ key: 's1', items: ['s1i1', 's1i2', 's1i3'] },
|
||||
{ key: 's2', items: ['s2i1', 's2i2', 's2i3'] },
|
||||
{ key: 's3', items: ['s3i1', 's3i2', 's3i3'] },
|
||||
];
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v6">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/land6" />
|
||||
|
||||
<article className="v6-doc">
|
||||
<div className="v6-doc__top">
|
||||
<span className="v6-tag">{t('landing.v6.tag')}</span>
|
||||
<span className="v6-date">
|
||||
{t('landing.v6.docDate')}, {formatDateTime(locale, new Date(), { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="v6-title">{t('landing.v6.title')}</h1>
|
||||
<p className="v6-sub">{t('landing.v6.sub')}</p>
|
||||
|
||||
<div className="v6-body">
|
||||
<div className="v6-clauses">
|
||||
{sections.map((s, i) => (
|
||||
<section key={s.key} className="v6-clause">
|
||||
<h2>
|
||||
<span>{i + 1}.</span> {t(`landing.v6.${s.key}`)}
|
||||
</h2>
|
||||
<ol>
|
||||
{s.items.map((item, j) => (
|
||||
<li key={item}>
|
||||
<span className="v6-num">
|
||||
{i + 1}.{j + 1}
|
||||
</span>
|
||||
<span>{t(`landing.v6.${item}`, vars)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<aside className="v6-example">
|
||||
<div className="v6-example__title">{t('landing.v6.exampleTitle')}</div>
|
||||
<div className="v6-example__calc">{t('landing.v6.exampleText', { clients: EXAMPLE.clients, orders: EXAMPLE.orders, price: amd(price) })}</div>
|
||||
<div className="v6-example__pct">× {agency.commissionPct}%</div>
|
||||
<div className="v6-example__result">{t('landing.v6.exampleResult', { amount: amd(amount) })}</div>
|
||||
<p>{t('landing.v6.exampleNote')}</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="v6-sign">
|
||||
<div className="v6-sign__line">
|
||||
<span />
|
||||
{t('landing.v6.signAgency')}
|
||||
</div>
|
||||
<div className="v6-sign__line v6-sign__line--us">
|
||||
<Stamp />
|
||||
<span />
|
||||
{t('landing.v6.signUs')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BotLink className="bc-btn bc-btn--accent v6-cta">{t('landing.v6.cta')}</BotLink>
|
||||
</article>
|
||||
|
||||
<footer className="v-footer">
|
||||
<span>{t('landing.footer.brand')}</span>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import '../landing.css';
|
||||
import { createT, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Noto_Sans_Armenian, Onest, Rubik } from 'next/font/google';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const rubik = Rubik({ subsets: ['latin', 'cyrillic'], weight: ['400', '500', '700', '800'], style: ['normal', 'italic'], variable: '--font-rubik', display: 'swap' });
|
||||
const onest = Onest({ subsets: ['latin', 'cyrillic'], weight: ['400', '500', '600'], variable: '--font-onest', display: 'swap' });
|
||||
const armenian = Noto_Sans_Armenian({ subsets: ['armenian'], weight: ['400', '600', '700'], variable: '--font-armenian', display: 'swap', preload: false });
|
||||
|
||||
const SITE = process.env.LANDING_URL ?? 'http://localhost:3000';
|
||||
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export const viewport: Viewport = { themeColor: '#FBF6EE', width: 'device-width', initialScale: 1 };
|
||||
|
||||
/** LND-4: SEO-мета на каждом языке и hreflang */
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = createT(locale);
|
||||
// по умолчанию — заголовок и описание главной (страницы задают свои)
|
||||
const title = t('landing.home.metaTitle');
|
||||
const description = t('landing.home.metaDescription');
|
||||
return {
|
||||
metadataBase: new URL(SITE),
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `/${locale}`,
|
||||
languages: Object.fromEntries([...LOCALES.map((l) => [l, `/${l}`]), ['x-default', '/ru']]),
|
||||
},
|
||||
openGraph: { title, description, locale, type: 'website', siteName: 'Barev Club' },
|
||||
twitter: { card: 'summary_large_image' },
|
||||
// подтверждение прав в Search Console, Яндекс Вебмастере и Bing — коды из .env (можно и через DNS)
|
||||
verification: {
|
||||
google: process.env.GOOGLE_SITE_VERIFICATION || undefined,
|
||||
yandex: process.env.YANDEX_VERIFICATION || undefined,
|
||||
other: process.env.BING_SITE_VERIFICATION ? { 'msvalidate.01': process.env.BING_SITE_VERIFICATION } : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return (
|
||||
<html lang={locale as Locale} className={`${rubik.variable} ${onest.variable} ${armenian.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createT, isLocale, LOCALES } from '@barev/i18n';
|
||||
import { OG_SIZE, ogImage } from '../../../lib/og';
|
||||
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** OG-картинка «Уже живу в Армении» */
|
||||
export default async function OgImage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
return ogImage(createT(isLocale(locale) ? locale : 'ru')('landing.live.title'));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import '../../variants.css';
|
||||
import '../../home.css';
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import type { Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { AudienceSwitch, ClubPlans, HomeFooter, homeMetadata } from '../../../components/home';
|
||||
import { ClubJsonLd } from '../../../components/seo';
|
||||
import { VariantHeader } from '../../../components/variant';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return homeMetadata(params, '/live');
|
||||
}
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
/**
|
||||
* Прайс по подписке: клининг можно заказать сразу, трансфер — со скидкой Elite, документы — «от N ֏»,
|
||||
* остальное пока «скоро».
|
||||
*/
|
||||
const ITEMS = [
|
||||
{ key: 'cleaning', value: 'order' },
|
||||
{ key: 'repair', value: 'soon' },
|
||||
{ key: 'transfer', value: 'pct' },
|
||||
{ key: 'tours', value: 'soon' },
|
||||
{ key: 'docs', value: 'from' },
|
||||
{ key: 'lang', value: 'soon' },
|
||||
{ key: 'career', value: 'soon' },
|
||||
{ key: 'partners', value: 'soon' },
|
||||
] as const;
|
||||
|
||||
/** Версия главной «Уже живу в Армении»: что даёт подписка — скидки по направлениям и тарифы */
|
||||
export default async function Live({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const catalog = await getCatalog();
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v2">
|
||||
<ClubJsonLd locale={locale} path="/live" bot={catalog.botUsername} plusPrice={catalog.barevPlus.monthlyPrice} offer={catalog.offer} />
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/live" className="vh--dark" logoColor="#F6C3D3" />
|
||||
<AudienceSwitch locale={locale} current="live" />
|
||||
|
||||
<section className="v2-hero">
|
||||
<div className="v2-word" aria-hidden="true">
|
||||
Barev
|
||||
</div>
|
||||
<ul className="v2-hello" aria-hidden="true">
|
||||
{t('landing.v2.hello')
|
||||
.split('\n')
|
||||
.map((h) => (
|
||||
<li key={h}>{h}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="v2-hero__text">
|
||||
<h1 className="v2-title">{t('landing.live.title')}</h1>
|
||||
<div className="h-hero__side">
|
||||
<p className="v2-sub">{t('landing.live.sub')}</p>
|
||||
<div className="h-actions">
|
||||
<BotLink className="bc-btn h-cta">{t('landing.live.cta')}</BotLink>
|
||||
<a href="#plans" className="bc-btn h-cta-ghost">
|
||||
{t('landing.live.ctaPlans')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="discounts" className="h-price" aria-labelledby="discounts-title">
|
||||
<div className="h-section-head">
|
||||
<h2 id="discounts-title">{t('landing.live.listTitle')}</h2>
|
||||
<p>{t('landing.live.listNote', { pct: catalog.offer.plusMaxPct })}</p>
|
||||
</div>
|
||||
<ul className="h-price__list">
|
||||
{ITEMS.map((it) => (
|
||||
<li key={it.key} className="h-price__row" data-kind={it.value}>
|
||||
<b className="h-price__name">{t(`landing.live.${it.key}`)}</b>
|
||||
<span className="h-price__dots" aria-hidden="true" />
|
||||
{it.value === 'order' ? (
|
||||
<a href={`/${locale}/clean`} className="bc-btn h-price__order">
|
||||
{t('landing.live.orderNow')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="h-price__value">
|
||||
{it.value === 'pct'
|
||||
? t('landing.live.upTo', { pct: catalog.offer.eliteMaxPct })
|
||||
: it.value === 'from'
|
||||
? t('landing.live.docsFrom', { price: formatAmd(locale, catalog.offer.docsFrom) })
|
||||
: t('common.soon')}
|
||||
</span>
|
||||
)}
|
||||
<small className="h-price__hint">{t(`landing.live.${it.key}Hint`)}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<ClubPlans locale={locale} plusPrice={catalog.barevPlus.monthlyPrice} offer={catalog.offer} />
|
||||
|
||||
<section className="v2-final">
|
||||
<div>
|
||||
<h2>{t('landing.live.finalTitle')}</h2>
|
||||
<p>{t('landing.live.finalText')}</p>
|
||||
</div>
|
||||
<BotLink className="bc-btn v2-final__cta">{t('landing.live.cta')}</BotLink>
|
||||
</section>
|
||||
|
||||
<HomeFooter locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user