diff --git a/README_v2_2_1.md b/README_v2_2_1.md
new file mode 100644
index 0000000..4dc94fb
--- /dev/null
+++ b/README_v2_2_1.md
@@ -0,0 +1,14 @@
+# BotFactory v2.2.1 — Restore Design Hotfix
+
+Фронтенд возвращён к исходному тёмному дизайну BotFactory из первого архива.
+
+Сохранены backend-исправления v2.2:
+- исправленный запуск platform/admin bot;
+- реальные API `/api/admin/*`;
+- недельный тестовый тариф;
+- индивидуальная постоплата;
+- подтверждение/отклонение заказов через API;
+- ремонтный скрипт `scripts/repair_server.sh`.
+
+Важно: демо-данные больше не используются как начальное состояние. Панель грузит данные из API `/api/admin/overview`.
+Если база пустая, интерфейс будет пустым, а не с фейковыми пользователями/магазинами.
diff --git a/VERSION b/VERSION
index ccbccc3..c043eea 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.2.0
+2.2.1
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 20f8cce..4931899 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "botfactory-frontend",
- "version": "2.1.0",
+ "version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "botfactory-frontend",
- "version": "2.1.0",
+ "version": "2.0.0",
"dependencies": {
"axios": "^1.7.2",
"react": "^18.3.1",
@@ -23,7 +23,6 @@
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
@@ -123,7 +122,6 @@
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
@@ -185,6 +183,7 @@
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -194,6 +193,7 @@
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0"
@@ -255,7 +255,6 @@
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
@@ -578,7 +577,6 @@
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1124,6 +1122,7 @@
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@babel/types": "^7.0.0"
}
@@ -1192,13 +1191,13 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "license": "MIT"
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.16.1",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+ "license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
@@ -1238,7 +1237,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -1291,6 +1289,7 @@
"version": "1.0.8",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -1386,6 +1385,7 @@
"version": "2.1.0",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
@@ -1455,7 +1455,6 @@
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
- "license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -1607,6 +1606,7 @@
"version": "5.0.1",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
@@ -1639,7 +1639,6 @@
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
- "license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
@@ -1673,7 +1672,6 @@
"version": "1.1.0",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "license": "MIT",
"engines": {
"node": ">= 0.4"
}
@@ -1716,7 +1714,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -1832,6 +1829,7 @@
"version": "6.30.3",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+ "license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
@@ -1849,6 +1847,7 @@
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/rollup/-/rollup-4.60.4.tgz",
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -1953,6 +1952,7 @@
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
diff --git a/frontend/package.json b/frontend/package.json
index 1fba9b4..a7e597c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,21 +1,21 @@
{
"name": "botfactory-frontend",
- "version": "2.1.0",
+ "version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
- "dev": "vite --port 3000",
- "build": "vite build",
+ "dev": "vite --port 3000",
+ "build": "vite build",
"preview": "vite preview --port 3000"
},
"dependencies": {
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
- "axios": "^1.7.2"
+ "axios": "^1.7.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
- "vite": "^5.2.12"
+ "vite": "^5.2.12"
}
}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 557fe5b..d1eb82c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,19 +1,113 @@
-import { useEffect, useMemo, useState } from "react";
-import "./style.css";
+import { useState, useEffect, useCallback } from "react";
-const API = "/api/admin";
-const plans = [
- ["trial_week", "Тест 7 дней"],
- ["trial", "Trial"],
- ["basic", "Basic"],
- ["pro", "Pro"],
- ["enterprise", "Enterprise"],
- ["postpaid_custom", "Индивидуальная постоплата"],
+// ─── HOOKS ────────────────────────────────────────────────────────────────────
+const useMobile = () => {
+ const [w, setW] = useState(typeof window !== "undefined" ? window.innerWidth : 1200);
+ useEffect(() => {
+ const fn = () => setW(window.innerWidth);
+ window.addEventListener("resize", fn);
+ return () => window.removeEventListener("resize", fn);
+ }, []);
+ return w < 768;
+};
+
+// ─── CONSTANTS ────────────────────────────────────────────────────────────────
+const R = {
+ accent:"#4f6ef7", orange:"#f7934f", green:"#4fd98e",
+ red:"#f75f4f", yellow:"#f7d64f", cyan:"#4fd9f7",
+ purple:"#b04ff7",
+ bg:"#07080a", surface:"#0e1014", card:"#13151a",
+ border:"#1c1f27", border2:"#252830",
+ text:"#e8eaed", muted:"#6b7280",
+};
+
+const PLANS = {
+ trial_week: { label:"Тест 7 дней", color:"#4fd9f7", maxBots:1, rate:10, price:0 },
+ trial: { label:"Trial", color:"#888", maxBots:1, rate:10, price:0 },
+ basic: { label:"Basic", color:"#4ECDC4", maxBots:3, rate:7, price:990 },
+ pro: { label:"Pro", color:"#f7d64f", maxBots:10, rate:5, price:2490 },
+ enterprise: { label:"Enterprise", color:"#f7934f", maxBots:999, rate:3, price:7990 },
+ postpaid_custom: { label:"Индивидуал", color:"#b04ff7", maxBots:999, rate:0, price:0 },
+};
+
+// ─── MOCK DATA ────────────────────────────────────────────────────────────────
+const INIT_TENANT = {
+ id:1, name:"Алексей Громов", email:"alex@shop.ru", plan:"pro",
+ status:"active", balance:3450, alertThreshold:500,
+ totalOrders:142, totalTurnover:284500, totalCommission:14225,
+};
+
+// Each shop = one "магазин" with:
+// controlBot – отдельный бот-оператора (не банится вместе с магазином)
+// tokens – список токенов shop-бота (один активный, остальные резерв)
+const INIT_SHOPS = [
+ {
+ id:1, name:"ShopBot Alpha", revenue:178000, orders:89, products:12,
+ controlBot:{ token:"7100000001:CTRL_BOT_AAA", username:"@shopbot_alpha_ctrl", status:"active" },
+ tokens:[
+ { id:1, token:"7123456789:AAFxxx", username:"@shopbot_alpha", active:true, status:"active", note:"Основной", addedAt:"2026-02-01" },
+ { id:2, token:"7987654321:BBByyy", username:"@shopbot_alpha_2", active:false, status:"standby", note:"Резервный", addedAt:"2026-04-10" },
+ ],
+ admins:[{ id:1, name:"Алексей Громов", username:"@alex_gromov", role:"admin" }],
+ moderators:[
+ { id:2, name:"Сергей Романов", username:"@sergey_mod", role:"moderator", addedAt:"2026-03-01" },
+ { id:3, name:"Анна Белова", username:"@anna_cashier", role:"moderator", addedAt:"2026-03-15" },
+ ],
+ },
+ {
+ id:2, name:"Keys & Codes", revenue:106500, orders:53, products:28,
+ controlBot:{ token:"7200000002:CTRL_BOT_BBB", username:"@keys_codes_ctrl", status:"active" },
+ tokens:[
+ { id:1, token:"7111111111:CCCzzz", username:"@keys_codes_bot", active:true, status:"active", note:"Основной", addedAt:"2026-03-15" },
+ ],
+ admins:[{ id:1, name:"Алексей Громов", username:"@alex_gromov", role:"admin" }],
+ moderators:[
+ { id:4, name:"Дмитрий Петров", username:"@dmitry_mod", role:"moderator", addedAt:"2026-04-01" },
+ ],
+ },
];
-const fmt = (v) => `${Number(v || 0).toLocaleString("ru-RU")} ₽`;
-const date = (v) => (v ? new Date(v).toLocaleString("ru-RU") : "—");
-async function api(path, options = {}) {
+const INIT_CARDS = [
+ { id:1, shopId:1, bank:"Сбербанк", number:"4276 **** **** 4521", holder:"ALEKSEY GROMOV", phone:"+7 999 123-45-67", active:true, orders:34, received:68000 },
+ { id:2, shopId:1, bank:"Тинькофф", number:"5536 **** **** 7832", holder:"ALEKSEY GROMOV", phone:"+7 988 987-65-43", active:true, orders:28, received:56000 },
+ { id:3, shopId:2, bank:"ВТБ", number:"4272 **** **** 1203", holder:"ALEKSEY GROMOV", phone:"+7 977 555-11-22", active:true, orders:18, received:36000 },
+ { id:4, shopId:1, bank:"Альфа", number:"5486 **** **** 9944", holder:"MARIA GROMOVA", phone:"", active:false, orders:6, received:12000 },
+];
+
+const INIT_ORDERS = [
+ { id:101, shopId:1, shopName:"ShopBot Alpha", product:"VPN Premium 3 мес", buyer:"@user_alex99", buyerId:9910001, amount:599, commission:30, cardId:1, status:"pending", date:"2026-05-24 14:52", proof:null },
+ { id:102, shopId:1, shopName:"ShopBot Alpha", product:"Spotify Premium", buyer:"@music_lover", buyerId:9920002, amount:299, commission:15, cardId:2, status:"confirming", date:"2026-05-24 13:30", proof:"screenshot.jpg" },
+ { id:103, shopId:2, shopName:"Keys & Codes", product:"Windows 11 Pro Key", buyer:"@techguy_77", buyerId:9930003, amount:890, commission:45, cardId:3, status:"completed", date:"2026-05-24 12:00", proof:"pay_proof.jpg" },
+ { id:104, shopId:1, shopName:"ShopBot Alpha", product:"VPN Premium 3 мес", buyer:"@vpn_user22", buyerId:9940004, amount:599, commission:30, cardId:1, status:"completed", date:"2026-05-23 20:15", proof:"done.jpg" },
+ { id:105, shopId:2, shopName:"Keys & Codes", product:"Office 2024 Key", buyer:"@office_pro", buyerId:9950005, amount:1290, commission:65, cardId:3, status:"rejected", date:"2026-05-23 18:00", proof:"fake.jpg" },
+];
+
+const INIT_TX = [
+ { id:1, type:"deposit", amount:+5000, date:"2026-05-20 10:00", note:"Пополнение баланса" },
+ { id:2, type:"commission", amount:-30, date:"2026-05-24 12:00", note:"Комиссия: заказ #101 (599₽ × 5%)" },
+ { id:3, type:"commission", amount:-15, date:"2026-05-24 13:30", note:"Комиссия: заказ #102 (299₽ × 5%)" },
+ { id:4, type:"commission", amount:-45, date:"2026-05-24 12:00", note:"Комиссия: заказ #103 (890₽ × 5%)" },
+ { id:5, type:"alert", amount:0, date:"2026-05-22 08:00", note:"⚠️ Уведомление: баланс ниже 500₽" },
+ { id:6, type:"deposit", amount:+3000, date:"2026-05-15 14:00", note:"Пополнение баланса" },
+];
+
+const MOCK_PLATFORM_TENANTS = [
+ { id:1, name:"Алексей Громов", email:"alex@shop.ru", plan:"pro", status:"active", balance:3450, alertThreshold:500, totalOrders:142, totalTurnover:284500, totalCommission:14225 },
+ { id:2, name:"Мария Селезнёва", email:"maria@digital.ru",plan:"basic", status:"active", balance:120, alertThreshold:200, totalOrders:87, totalTurnover:174000, totalCommission:12180 },
+ { id:3, name:"Иван Кузнецов", email:"ivan@goods.ru", plan:"pro", status:"blocked", balance:0, alertThreshold:300, totalOrders:44, totalTurnover:98000, totalCommission:4900 },
+ { id:4, name:"Дмитрий Попов", email:"dm@newshop.ru", plan:"trial", status:"active", balance:75, alertThreshold:50, totalOrders:5, totalTurnover:4500, totalCommission:450 },
+];
+
+
+// ─── REAL API MAPPING ─────────────────────────────────────────────────────────
+const API = "/api/admin";
+
+const EMPTY_TENANT = {
+ id:null, name:"Нет владельца", email:"", plan:"trial_week", status:"active",
+ balance:0, alertThreshold:200, totalOrders:0, totalTurnover:0, totalCommission:0,
+};
+
+async function adminApi(path, options = {}) {
const res = await fetch(`${API}${path}`, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
@@ -23,168 +117,1269 @@ async function api(path, options = {}) {
return body;
}
-function useOverview() {
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState("");
- const load = async () => {
- setLoading(true);
- setError("");
- try { setData(await api("/overview")); }
- catch (e) { setError(e.message); }
- finally { setLoading(false); }
- };
- useEffect(() => { load(); }, []);
- return { data, loading, error, load };
+function mapOverview(data, prevTenantId = null) {
+ const rawTenants = data?.tenants || [];
+ const rawShops = data?.shops || [];
+ const rawTokens = data?.tokens || [];
+ const rawMembers = data?.members || [];
+ const rawOrders = data?.orders || [];
+ const rawCards = data?.cards || [];
+ const rawProducts = data?.products || [];
+ const rawTx = data?.transactions || [];
+
+ const productById = Object.fromEntries(rawProducts.map(p => [p.id, p]));
+ const shopById = Object.fromEntries(rawShops.map(s => [s.id, s]));
+
+ const tenants = rawTenants.map(t => {
+ const tenantShopIds = rawShops.filter(s => s.tenant_id === t.id).map(s => s.id);
+ const tenantOrders = rawOrders.filter(o => tenantShopIds.includes(o.shop_id));
+ return {
+ id:t.id, name:t.name || `Пользователь #${t.id}`, email:t.email || "", plan:t.plan || "trial_week",
+ status:t.is_blocked ? "blocked" : "active",
+ balance:Number(t.balance || 0), alertThreshold:Number(t.alert_threshold || 0),
+ totalOrders:tenantOrders.length, totalTurnover:Number(t.total_revenue || 0),
+ totalCommission:Number(t.total_commission || 0),
+ postpaidDue:Number(t.postpaid_previous_month_due || 0),
+ postpaidDueDate:t.postpaid_due_date,
+ postpaidPercent:t.postpaid_commission_percent,
+ };
+ });
+
+ const selectedTenantId = prevTenantId && tenants.some(t => t.id === prevTenantId)
+ ? prevTenantId
+ : tenants[0]?.id;
+ const tenant = tenants.find(t => t.id === selectedTenantId) || EMPTY_TENANT;
+
+ const tenantShops = rawShops.filter(s => !tenant.id || s.tenant_id === tenant.id).map(s => ({
+ id:s.id, name:s.name, revenue:Number(s.revenue || 0), orders:Number(s.orders_count || 0), products:Number(s.products_count || 0),
+ controlBot:{ token:s.ctrl_bot_token_set ? "set" : "", username:s.ctrl_bot_username || "—", status:s.is_active ? "active" : "disabled" },
+ tokens:rawTokens.filter(t => t.shop_id === s.id).map(t => ({
+ id:t.id, token:t.token_masked || "", username:t.username || "—", active:!!t.is_active,
+ status:t.is_active ? "active" : "standby", note:t.note || "Резервный", addedAt:(t.added_at || "").slice(0,10),
+ })),
+ admins:[],
+ moderators:rawMembers.filter(m => m.shop_id === s.id).map(m => ({
+ id:m.id, name:m.name, username:m.username || String(m.telegram_id || ""), role:m.role || "moderator", addedAt:(m.added_at || "").slice(0,10),
+ })),
+ }));
+
+ const tenantShopIds = tenantShops.map(s => s.id);
+ const cards = rawCards.filter(c => tenantShopIds.includes(c.shop_id)).map(c => ({
+ id:c.id, shopId:c.shop_id, bank:c.bank, number:c.number, holder:c.holder, phone:c.phone || "",
+ active:!!c.is_active, orders:Number(c.orders_count || 0), received:Number(c.received_total || 0),
+ }));
+
+ const orders = rawOrders.filter(o => tenantShopIds.includes(o.shop_id) || !tenant.id).map(o => {
+ const shop = shopById[o.shop_id];
+ const product = productById[o.product_id];
+ return {
+ id:o.id, shopId:o.shop_id, shopName:shop?.name || `Магазин #${o.shop_id}`,
+ product:product?.name || `Товар #${o.product_id || "—"}`, buyer:o.buyer_username || `ID ${o.buyer_telegram_id || "—"}`,
+ buyerId:o.buyer_telegram_id || "—", amount:Number(o.amount || 0), commission:Number(o.commission || 0),
+ cardId:o.card_id || "—", status:o.status || "pending", date:(o.created_at || "").replace("T", " ").slice(0,16),
+ proof:o.proof_file_id ? "чек получен" : null,
+ };
+ });
+
+ const txs = rawTx.filter(x => !tenant.id || x.tenant_id === tenant.id).map(x => ({
+ id:x.id, type:x.type, amount:Number(x.amount || 0),
+ date:(x.created_at || "").replace("T", " ").slice(0,16), note:x.note || "",
+ }));
+
+ return { tenant, shops:tenantShops, cards, orders, txs, platformTenants:tenants };
}
-function Field({ label, children }) {
- return {label} {children} ;
-}
+// ═══════════════════════════════════════════════════════════════════════════════
+// ICONS
+// ═══════════════════════════════════════════════════════════════════════════════
+const P = {
+ dashboard:"M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z",
+ shops:"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z",
+ orders:"M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm2 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z",
+ cards:"M20 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z",
+ team:"M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z",
+ settings:"M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.57 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z",
+ plus:"M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z",
+ check:"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z",
+ close:"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z",
+ trash:"M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z",
+ eye:"M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z",
+ wallet:"M21 18v1c0 1.1-.9 2-2 2H5c-1.11 0-2-.9-2-2V5c0-1.1.89-2 2-2h14c1.1 0 2 .9 2 2v1h-9c-1.11 0-2 .9-2 2v8c0 1.1.89 2 2 2h9zm-9-2h10V8H12v8zm4-2.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z",
+ alert:"M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z",
+ lock:"M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z",
+ shuffle:"M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z",
+ swap:"M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z",
+ key:"M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z",
+ ctrl:"M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z",
+ shield:"M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z",
+ users:"M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z",
+ revenue:"M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4z",
+ history:"M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z",
+ menu:"M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z",
+ back:"M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z",
+ bell:"M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z",
+ edit:"M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z",
+ topup:"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z",
+ percent:"M7.5 11C9.43 11 11 9.43 11 7.5S9.43 4 7.5 4 4 5.57 4 7.5 5.57 11 7.5 11zm0-5C8.33 6 9 6.67 9 7.5S8.33 9 7.5 9 6 8.33 6 7.5 6.67 6 7.5 6zM4 18.5L18.5 4l1.5 1.5L5.5 20 4 18.5zm12-2.5c-1.93 0-3.5 1.57-3.5 3.5S14.07 23 16 23s3.5-1.57 3.5-3.5S17.93 16 16 16zm0 5c-.83 0-1.5-.67-1.5-1.5S15.17 18 16 18s1.5.67 1.5 1.5S16.83 21 16 21z",
+};
-function Modal({ title, onClose, children }) {
- return
e.target === e.currentTarget && onClose()}>
-
-
{title} ×
+const Ic = ({ n, s=16, c }) => (
+
+
+
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// ATOMS
+// ═══════════════════════════════════════════════════════════════════════════════
+const css = `
+ @import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;600&display=swap');
+ *{box-sizing:border-box;margin:0;padding:0}
+ body{background:#07080a;color:#e8eaed;-webkit-font-smoothing:antialiased}
+ ::-webkit-scrollbar{width:4px;height:4px}
+ ::-webkit-scrollbar-track{background:#111}
+ ::-webkit-scrollbar-thumb{background:#2a2a2a;border-radius:2px}
+ select option{background:#161920}
+ input::placeholder,textarea::placeholder{color:#3a3f4a}
+ input:focus,textarea:focus,select:focus{outline:none!important;border-color:#4f6ef7!important}
+ tr:hover>td{background:#0f1118!important}
+ @media(max-width:767px){
+ .hide-mobile{display:none!important}
+ .mob-full{width:100%!important}
+ .mob-stack{flex-direction:column!important}
+ .mob-p{padding:16px!important}
+ .mob-grid1{grid-template-columns:1fr!important}
+ .mob-grid2{grid-template-columns:1fr 1fr!important}
+ .mob-fs13{font-size:13px!important}
+ .mob-fs22{font-size:22px!important}
+ }
+`;
+
+const Tag = ({label,color,bg})=>(
+
{label}
+);
+const PlanTag = ({plan})=>{const p=PLANS[plan]||PLANS.trial_week;return
;};
+
+const Btn = ({children,onClick,v="primary",sm,full,disabled,style:s={}})=>{
+ const st={
+ primary:{bg:R.accent,color:"#fff",bo:R.accent},
+ ghost:{bg:"transparent",color:R.muted,bo:R.border2},
+ danger:{bg:`${R.red}15`,color:R.red,bo:`${R.red}40`},
+ success:{bg:`${R.green}15`,color:R.green,bo:`${R.green}40`},
+ warning:{bg:`${R.yellow}15`,color:R.yellow,bo:`${R.yellow}40`},
+ orange:{bg:`${R.orange}15`,color:R.orange,bo:`${R.orange}40`},
+ purple:{bg:`${R.purple}15`,color:R.purple,bo:`${R.purple}40`},
+ cyan:{bg:`${R.cyan}15`,color:R.cyan,bo:`${R.cyan}40`},
+ }[v]||{bg:R.surface,color:R.muted,bo:R.border2};
+ return(
+
{children}
+
+ );
+};
+
+const F = ({label,children,style:s={}})=>(
+
+ {label&&{label} }
+ {children}
+
+);
+const baseInput = {width:"100%",background:R.surface,border:`1px solid ${R.border2}`,borderRadius:8,padding:"9px 12px",color:R.text,fontSize:13,fontFamily:"'JetBrains Mono'"};
+const Inp = ({label,...p})=> ;
+const Sel = ({label,children,...p})=>{children} ;
+const Txa = ({label,...p})=> ;
+
+const Card = ({children,style:s={},accent,glow,onClick})=>(
+
+ {accent&&
}
+ {children}
+
+);
+
+const Stat = ({label,value,sub,accent=R.accent,icon,glow,mobile})=>(
+
+
+
+ {label}
+ {icon&&}
+
+
{value}
+ {sub&&
{sub}
}
- ;
-}
+
+);
-function Empty({ title, text }) {
- return {title} {text}
;
-}
+const Modal = ({title,onClose,wide,children})=>(
+ e.target===e.currentTarget&&onClose()}>
+
+
+ {title}
+
+
+
{children}
+
+
+);
-function App() {
- const { data, loading, error, load } = useOverview();
- const [tab, setTab] = useState("dashboard");
- const [modal, setModal] = useState(null);
- const [toast, setToast] = useState("");
- const [busy, setBusy] = useState(false);
+const TH = ({ch})=>{ch} ;
+const TD = ({children,muted,accent})=>{children} ;
- const shopsById = useMemo(() => Object.fromEntries((data?.shops || []).map(s => [s.id, s])), [data]);
- const tenantsById = useMemo(() => Object.fromEntries((data?.tenants || []).map(t => [t.id, t])), [data]);
+const OrderBadge = ({status})=>{
+ const m={pending:{l:"Ожидает оплаты",c:R.yellow},confirming:{l:"На проверке",c:R.orange},completed:{l:"Выполнен",c:R.green},rejected:{l:"Отклонён",c:R.red}}[status]||{l:status,c:R.muted};
+ return ;
+};
- const submit = async (path, payload) => {
- setBusy(true);
+const Toggle = ({on,onChange})=>(
+
+);
+
+const Toast = ({msg,ok=true,onDone})=>{
+ useEffect(()=>{const t=setTimeout(onDone,3000);return()=>clearTimeout(t);},[]);
+ return {ok?"✓ ":"✗ "}{msg}
;
+};
+
+const SHead = ({sup,title,action,mobile})=>(
+
+);
+
+// ── Mobile card row (replaces table rows on mobile) ──────────────────────────
+const MobCard = ({children,onClick})=>(
+
+ {children}
+
+);
+
+// ── Balance bar (always visible in tenant mode) ───────────────────────────────
+const BalBar = ({tenant,onTopup,mobile})=>{
+ const empty=tenant.balance<=0, low=tenant.balance>0&&tenant.balance<=tenant.alertThreshold;
+ const c=empty?R.red:low?R.yellow:R.green;
+ return(
+
+
+
+
Баланс платформы
+
{tenant.balance.toLocaleString()} ₽
+
+ {empty&&}
+ {low&&!empty&&}
+ {!empty&&!low&&Пополнить → }
+
+ );
+};
+
+// ─── TOPUP MODAL ──────────────────────────────────────────────────────────────
+const TopupModal = ({tenant,onClose,onDone})=>{
+ const [amt,setAmt]=useState("");
+ const pre=[500,1000,2000,5000];
+ return(
+
+
+
Текущий баланс: {tenant.balance.toLocaleString()} ₽
+
Комиссия {(PLANS[tenant.plan]||PLANS.trial_week).rate}% списывается с каждого выполненного заказа
+
+
+
+ {pre.map(p=>setAmt(String(p))} style={{background:amt===String(p)?`${R.accent}25`:R.surface,border:`1px solid ${amt===String(p)?R.accent:R.border2}`,color:amt===String(p)?R.accent:R.muted,borderRadius:8,padding:"8px 14px",fontSize:13,fontFamily:"'JetBrains Mono'",cursor:"pointer"}}>{p.toLocaleString()} ₽ )}
+
+
+ setAmt(e.target.value)}/>
+
+
Реквизиты платформы:
+
🏦 Тинькофф · 5536 **** **** 0001
+
👤 ООО «БотФактори»
+
📝 Назначение: Пополнение #{tenant.id} BotFactory
+
⚠️ Обязательно укажите назначение платежа
+
+
+ Отмена
+ {onDone(Number(amt)||0);onClose();}} disabled={!amt||Number(amt)<100}> Оплатил
+
+
+ );
+};
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TENANT SECTIONS
+// ═══════════════════════════════════════════════════════════════════════════════
+
+// ── DASHBOARD ─────────────────────────────────────────────────────────────────
+const TDashboard = ({tenant,shops,orders,onTopup,mobile})=>{
+ const pending=orders.filter(o=>o.status==="pending"||o.status==="confirming").length;
+ return(
+
+
+
+ a+b.tokens.length,0)}`} accent={R.accent} icon="shops" mobile={mobile}/>
+
+
+
+
+
+
+
Мои магазины
+ {shops.map(sh=>(
+
+
+
+
{sh.name}
+
{sh.tokens.find(t=>t.active)?.username} · {sh.orders} заказов
+
+
{(sh.revenue/1000).toFixed(0)}K
+
+ ))}
+
+
+
Схема работы
+ {["Покупатель → переводит на вашу карту","Кассир подтверждает → бот выдаёт товар",`Списывается комиссия с комиссионного баланса BotFactory`,`При ${tenant.alertThreshold}₽ → уведомление в Telegram`,"При 0₽ → боты приостанавливаются"].map((s,i)=>(
+
+ ))}
+
+
+ );
+};
+
+// ── SHOPS / BOTS ──────────────────────────────────────────────────────────────
+const TShops = ({shops,setShops,tenant,onTopup,toast,mobile})=>{
+ const [addModal,setAddModal]=useState(false);
+ const [tokenModal,setTokenModal]=useState(null); // shopId
+ const [form,setForm]=useState({name:"",ctrlToken:"",firstToken:"",firstUsername:""});
+ const [tokForm,setTokForm]=useState({token:"",username:"",note:""});
+ const maxShops=(PLANS[tenant.plan]||PLANS.trial_week).maxBots;
+ const isBlocked=tenant.balance<=0;
+
+ const createShop=()=>{
+ if(!form.name||!form.ctrlToken||!form.firstToken)return;
+ setShops([...shops,{
+ id:Date.now(),name:form.name,revenue:0,orders:0,products:0,
+ controlBot:{token:form.ctrlToken,username:`@${form.name.toLowerCase().replace(/\s+/g,"_")}_ctrl`,status:"active"},
+ tokens:[{id:1,token:form.firstToken,username:form.firstUsername||`@${form.name.toLowerCase().replace(/\s+/g,"_")}_bot`,active:true,status:"active",note:"Основной",addedAt:new Date().toISOString().split("T")[0]}],
+ admins:[{id:1,name:tenant.name,username:"@"+tenant.email.split("@")[0],role:"admin"}],
+ moderators:[],
+ }]);
+ setAddModal(false);setForm({name:"",ctrlToken:"",firstToken:"",firstUsername:""});
+ toast("Магазин создан!");
+ };
+
+ const addToken=(shopId)=>{
+ if(!tokForm.token)return;
+ setShops(shops.map(sh=>sh.id===shopId?{...sh,tokens:[...sh.tokens,{id:Date.now(),token:tokForm.token,username:tokForm.username||"@new_bot",active:false,status:"standby",note:tokForm.note||"Резервный",addedAt:new Date().toISOString().split("T")[0]}]}:sh));
+ setTokenModal(null);setTokForm({token:"",username:"",note:""});
+ toast("Токен добавлен в пул");
+ };
+
+ const setActiveToken=(shopId,tokenId)=>{
+ setShops(shops.map(sh=>sh.id===shopId?{...sh,tokens:sh.tokens.map(t=>({...t,active:t.id===tokenId}))}:sh));
+ toast("Активный токен переключён");
+ };
+
+ const removeToken=(shopId,tokenId)=>{
+ setShops(shops.map(sh=>sh.id===shopId?{...sh,tokens:sh.tokens.filter(t=>t.id!==tokenId)}:sh));
+ toast("Токен удалён");
+ };
+
+ return(
+
shops.length Создать магазин}
+ mobile={mobile}
+ />
+
+
+ {isBlocked&&(
+
+
+
+
Все магазин-боты приостановлены
+
Боты управления работают. Пополните баланс.
+
+ Пополнить
+
+ )}
+
+ {/* Info block */}
+
+
+
+
+ Бот управления — постоянный
+
+
Отдельный бот для вас и кассиров. Никогда не меняется и не блокируется вместе с ботом магазина.
+
+
+
+
+ Бот магазина — заменяемый
+
+
Добавьте несколько токенов. Если Telegram заблокирует бот магазина — переключитесь на резервный одним кликом.
+
+
+
+ {shops.map(sh=>{
+ const activeToken=sh.tokens.find(t=>t.active)||sh.tokens[0];
+ return(
+
+
+ {/* Shop header */}
+
+
+
{sh.name}
+
{sh.orders} заказов · {sh.products} товаров · {(sh.revenue/1000).toFixed(0)}K ₽
+
+ {isBlocked&&
}
+ {!isBlocked&&}
+
+
+ {/* CONTROL BOT — fixed, never changes */}
+
+
+
+
+
+
БОТ УПРАВЛЕНИЯ (оператор)
+
{sh.controlBot.username}
+
+
+
+
+
+ TOKEN: {sh.controlBot.token}
+
+
+ Функции: Все (для admin) · Подтверждение платежей (для кассира)
+
+
+
+ {/* TOKENS POOL — main feature */}
+
+
+
+
+
+
БОТ МАГАЗИНА — токены
+
Активен: {sh.tokens.find(t=>t.active)?.username} · Всего токенов: {sh.tokens.length}
+
+
+
setTokenModal(sh.id)}> Добавить токен
+
+
+ {sh.tokens.map(tok=>{
+ const isActive = tok.active;
+ return (
+
+
+
+
+ {tok.username}
+ {isActive
+ ?
+ :
+ }
+
+
{tok.token} · добавлен {tok.addedAt}
+
+
+ {!isActive&&(
+ setActiveToken(sh.id,tok.id)}>
+ Переключить на этот
+
+ )}
+ {sh.tokens.length>1&&!isActive&&(
+ removeToken(sh.id,tok.id)}>
+ )}
+
+
+ );
+ })}
+
+
+
+
+ Если бот магазина заблокирован Telegram — создайте нового бота в @BotFather, добавьте его токен и нажмите «Переключить на этот» . Бот управления при этом не пострадает.
+
+
+
+
+ {/* Stats row */}
+
+ {[["Заказов",sh.orders,R.accent],["Товаров",sh.products,R.cyan],["Оборот",`${(sh.revenue/1000).toFixed(0)}K₽`,R.green]].map(([k,v,c])=>(
+
+ ))}
+
+
+
+ );
+ })}
+
+ {/* CREATE SHOP MODAL */}
+ {addModal&&(
+ setAddModal(false)} wide>
+
+
📋 Вам нужно создать два бота в @BotFather:
+
1. Бот управления — для вас и кассиров (скрытый, не для клиентов) 2. Бот магазина — для покупателей
+
+ setForm({...form,name:e.target.value})}/>
+
+
🛡️ БОТ УПРАВЛЕНИЯ (для вас и кассиров)
+
setForm({...form,ctrlToken:e.target.value})}/>
+
+
+
🛒 БОТ МАГАЗИНА (для покупателей)
+
setForm({...form,firstToken:e.target.value})}/>
+ setForm({...form,firstUsername:e.target.value})}/>
+
+
+ setAddModal(false)}>Отмена
+ Создать магазин
+
+
+ )}
+
+ {/* ADD TOKEN MODAL */}
+ {tokenModal&&(
+ setTokenModal(null)}>
+
+
Новый токен добавляется как резервный. Переключить на него можно в любой момент — например при блокировке основного бота.
+
+ setTokForm({...tokForm,token:e.target.value})}/>
+ setTokForm({...tokForm,username:e.target.value})}/>
+ setTokForm({...tokForm,note:e.target.value})}/>
+
+ setTokenModal(null)}>Отмена
+ addToken(tokenModal)}> Добавить
+
+
+ )}
+ );
+};
+
+// ── ORDERS ─────────────────────────────────────────────────────────────────────
+const TOrders = ({orders,setOrders,tenant,onTopup,role,mobile})=>{
+ const [filter,setFilter]=useState("all");
+ const [sel,setSel]=useState(null);
+ const empty=tenant.balance<=0;
+ const filtered=filter==="all"?orders:orders.filter(o=>o.status===filter);
+ const ord=sel?orders.find(o=>o.id===sel):null;
+ const confirm=async id=>{
+ if(empty&&role!=="moderator"){return;}
+ try{
+ await adminApi(`/orders/${id}/confirm`,{method:"POST"});
+ setOrders(orders.map(o=>o.id===id?{...o,status:"completed"}:o));
+ setSel(null);
+ window.bfReload&&window.bfReload();
+ }catch(e){ alert(e.message||"Ошибка подтверждения"); }
+ };
+ const reject=async id=>{
+ try{
+ await adminApi(`/orders/${id}/reject`,{method:"POST"});
+ setOrders(orders.map(o=>o.id===id?{...o,status:"rejected"}:o));setSel(null);
+ window.bfReload&&window.bfReload();
+ }catch(e){ alert(e.message||"Ошибка отклонения"); }
+ };
+
+ return(
+
}
+ />
+ {role!=="moderator"&&}
+ {empty&&role!=="moderator"&&(
+
+ Подтверждение недоступно — баланс исчерпан. Пополнить →
+
+ )}
+
+ {/* Filter tabs */}
+
+ {[["all","Все"],["pending","Ожидают"],["confirming","Проверка"],["completed","Выполнены"],["rejected","Отклонены"]].map(([v,l])=>(
+ setFilter(v)} style={{background:filter===v?R.accent:R.surface,color:filter===v?"#fff":R.muted,border:`1px solid ${filter===v?R.accent:R.border2}`,borderRadius:20,padding:"6px 12px",fontSize:11,fontFamily:"'JetBrains Mono'",cursor:"pointer"}}>
+ {l}{v!=="all"&&` (${orders.filter(o=>o.status===v).length})`}
+
+ ))}
+
+
+ {/* Mobile: card list | Desktop: table */}
+ {mobile?(
+
+ {filtered.map(o=>(
+
setSel(o.id)}>
+
+
+
{o.product}
+
{o.buyer} · {o.shopName}
+
+
+
{o.amount}₽
+
−{o.commission}₽
+
+
+
+
+ ))}
+
+ ):(
+
+
+
+ {["#","Товар","Покупатель","Магазин","Сумма","Комиссия","Карта","Дата","Статус",""].map(h=>)}
+ {filtered.map(o=>(
+ setSel(o.id)} style={{cursor:"pointer"}}>
+ #{o.id}
+ {o.product}
+ {o.buyer}
+ {o.shopName}
+ {o.amount}₽
+ −{o.commission}₽
+ *{o.cardId}
+ {o.date}
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Order detail modal */}
+ {ord&&(
+ setSel(null)}>
+
+ {[["Покупатель",ord.buyer,R.cyan],["Telegram ID",ord.buyerId,null],["Товар",ord.product,null],["Магазин",ord.shopName,null],["К получению",`${ord.amount}₽`,R.green],["Комиссия BF",`−${ord.commission}₽`,R.accent]].map(([k,v,c])=>(
+
+ ))}
+
+
+
Скриншот оплаты
+ {ord.proof?
📎 {ord.proof}
:
Скриншот не прикреплён
}
+
+
+ {(ord.status==="confirming"||ord.status==="pending")&&(
+
+ reject(ord.id)}> Отклонить
+ confirm(ord.id)}> Подтвердить — выдать товар
+
+ )}
+
+ )}
+ );
+};
+
+// ── CARDS ──────────────────────────────────────────────────────────────────────
+const TCards = ({cards,setCards,shops,toast,mobile})=>{
+ const [modal,setModal]=useState(false);
+ const [shopF,setShopF]=useState("all");
+ const [form,setForm]=useState({shopId:shops[0]?.id||"",bank:"",number:"",holder:"",phone:""});
+ const filtered=shopF==="all"?cards:cards.filter(c=>c.shopId===Number(shopF));
+ const banks=["Сбербанк","Тинькофф","ВТБ","Альфа-банк","Газпромбанк","МТС Банк","Открытие","Райффайзен"];
+
+ return(
+
setModal(true)}> Добавить карту}
+ mobile={mobile}
+ />
+
+
+ Активная карта выдаётся покупателям случайно для равномерного распределения
+
+
+ {/* Shop filter */}
+
+ {[["all","Все магазины"],...shops.map(s=>[String(s.id),s.name])].map(([v,l])=>(
+ setShopF(v)} style={{background:shopF===v?R.accent:R.surface,color:shopF===v?"#fff":R.muted,border:`1px solid ${shopF===v?R.accent:R.border2}`,borderRadius:20,padding:"6px 12px",fontSize:11,fontFamily:"'JetBrains Mono'",cursor:"pointer"}}>{l}
+ ))}
+
+
+
+ {filtered.map(c=>(
+
+
+
+
{c.bank}
+
+ {c.active&&В пуле }
+
+
+
+
{c.number}
+
{c.holder}
+ {c.phone&&
СБП: {c.phone}
}
+
+ {[["Заказов",c.orders,R.accent],["Получено",`${(c.received/1000).toFixed(0)}K₽`,R.green]].map(([k,v,col])=>(
+
+ ))}
+
+
+ {setCards(cards.map(x=>x.id===c.id?{...x,active:!x.active}:x));toast(c.active?"Убрана из пула":"В пул");}}>
+ {c.active?"Убрать":"В пул"}
+
+ {setCards(cards.filter(x=>x.id!==c.id));toast("Удалена");}}>
+
+
+
+ ))}
+
+
+ {modal&&(
+ setModal(false)}>
+ setForm({...form,shopId:e.target.value})}>
+ {shops.map(s=>{s.name} )}
+
+ setForm({...form,bank:e.target.value})}>
+ Выберите банк...
+ {banks.map(b=>{b} )}
+
+ setForm({...form,number:e.target.value})}/>
+ setForm({...form,holder:e.target.value})}/>
+ setForm({...form,phone:e.target.value})}/>
+
+ setModal(false)}>Отмена
+ {if(!form.number||!form.holder)return;setCards([...cards,{id:Date.now(),shopId:Number(form.shopId),bank:form.bank,number:form.number,holder:form.holder.toUpperCase(),phone:form.phone,active:true,orders:0,received:0}]);setModal(false);toast("Карта добавлена");}}> Добавить
+
+
+ )}
+ );
+};
+
+// ── TEAM (moderators per shop) ─────────────────────────────────────────────────
+const TTeam = ({shops,setShops,toast,mobile})=>{
+ const [modal,setModal]=useState(null); // shopId
+ const [form,setForm]=useState({name:"",username:"",role:"moderator"});
+
+ const addMember=(shopId)=>{
+ if(!form.name||!form.username)return;
+ setShops(shops.map(sh=>sh.id===shopId?{...sh,moderators:[...sh.moderators,{id:Date.now(),name:form.name,username:form.username,role:form.role,addedAt:new Date().toISOString().split("T")[0]}]}:sh));
+ setModal(null);setForm({name:"",username:"",role:"moderator"});
+ toast("Участник добавлен в бот управления");
+ };
+
+ const removeMember=(shopId,memberId)=>{
+ setShops(shops.map(sh=>sh.id===shopId?{...sh,moderators:sh.moderators.filter(m=>m.id!==memberId)}:sh));
+ toast("Участник удалён");
+ };
+
+ return(
+
+
+
+
Бот управления для команды
+
+ Каждый магазин имеет свой бот управления. Кассир получает доступ через него.
+ Кассир: подтверждение/отклонение платежей
+ Администратор: всё — товары, карты, статистика, баланс, назначение кассиров
+
+
+
+ {shops.map(sh=>(
+
+
+
+
+
{sh.name}
+
Бот управления: {sh.controlBot.username}
+
+
setModal(sh.id)}> Добавить кассира
+
+
+ {/* Admins */}
+
+
Администраторы
+ {sh.admins.map(a=>(
+
+
А
+
+
{a.name}
+
{a.username}
+
+
+
+ ))}
+
+
+ {/* Moderators */}
+
+
Кассиры / Модераторы
+ {sh.moderators.length===0&&
Кассиры не добавлены
}
+ {sh.moderators.map(m=>(
+
+
К
+
+
{m.name}
+
{m.username} · добавлен {m.addedAt}
+
+
+ removeMember(sh.id,m.id)}>
+
+ ))}
+
+
+
+ ))}
+
+ {modal&&(
+ s.id===modal)?.name}`} onClose={()=>setModal(null)}>
+
+
Участник получит доступ через бот управления магазина
+
+ setForm({...form,name:e.target.value})}/>
+ setForm({...form,username:e.target.value})}/>
+ setForm({...form,role:e.target.value})}>
+ Кассир — только подтверждение платежей
+
+
+ setModal(null)}>Отмена
+ addMember(modal)}> Добавить
+
+
+ )}
+ );
+};
+
+// ── BALANCE ────────────────────────────────────────────────────────────────────
+const TBalance = ({tenant,setTenant,txs,onTopup,mobile})=>{
+ const [thresh,setThresh]=useState(String(tenant.alertThreshold));
+ const plan=PLANS[tenant.plan]||PLANS.trial_week;
+ const empty=tenant.balance<=0, low=tenant.balance>0&&tenant.balance<=tenant.alertThreshold;
+ const c=empty?R.red:low?R.yellow:R.green;
+
+ return(
+
Пополнить}
+ mobile={mobile}
+ />
+
+ {/* Big balance card */}
+
+
+
+
+
Комиссионный баланс BotFactory
+
{tenant.balance.toLocaleString()} ₽
+
Тариф: {plan.label} · Комиссия: {plan.rate}% с заказа
+
+
Пополнить баланс
+
+ {(empty||low)&&(
+
+
+
+
{empty?"⛔ Боты остановлены — пополните баланс":"⚠️ Баланс ниже порога уведомления"}
+
{empty?"Как только пополните — боты запустятся автоматически.":`Уведомление в Telegram уже отправлено. Порог: ${tenant.alertThreshold.toLocaleString()} ₽`}
+
+
+ )}
+
+
+
+
+ {/* Alert threshold */}
+
+
+
+
+ При достижении этой суммы — вам придёт сообщение в бот управления. Боты продолжат работать до нуля.
+
+
+ setThresh(e.target.value)} style={{...baseInput}}/>
+
+
setTenant({...tenant,alertThreshold:Number(thresh)})}> Сохранить порог
+
+
+
+ {/* Summary */}
+
+
Итоги
+ {[["Текущий баланс",`${tenant.balance.toLocaleString()} ₽`,c],["Порог уведомления",`${tenant.alertThreshold.toLocaleString()} ₽`,R.yellow],["Комиссия платформы",`${plan.rate}%`,R.muted],["Списано за всё время",`${tenant.totalCommission.toLocaleString()} ₽`,R.red],["Оборот за всё время",`${tenant.totalTurnover.toLocaleString()} ₽`,R.cyan],["Мой заработок",`${(tenant.totalTurnover-tenant.totalCommission).toLocaleString()} ₽`,R.green]].map(([k,v,col])=>(
+
+ {k}
+ {v}
+
+ ))}
+
+
+
+ {/* Transactions */}
+
+ {mobile?(
+
+ {[...txs].reverse().map(tx=>{
+ const cfg={deposit:{l:"Пополнение",c:R.green},commission:{l:"Комиссия",c:R.accent},alert:{l:"Уведомление",c:R.yellow}}[tx.type]||{l:tx.type,c:R.muted};
+ return(
+
+
+
+
{tx.date}
+
{tx.note}
+
+
0?R.green:tx.amount<0?R.red:R.muted,fontWeight:700,fontFamily:"'Syne'"}}>{tx.amount>0?"+":""}{tx.amount!==0?`${tx.amount.toLocaleString()}₽`:"—"}
+
+ );
+ })}
+
+ ):(
+
+
+ {["Дата","Тип","Сумма","Примечание"].map(h=>)}
+ {[...txs].reverse().map(tx=>{
+ const cfg={deposit:{l:"Пополнение",c:R.green},commission:{l:"Комиссия",c:R.accent},alert:{l:"Уведомление",c:R.yellow}}[tx.type]||{l:tx.type,c:R.muted};
+ return(
+ {tx.date}
+
+ 0?R.green:tx.amount<0?R.red:R.muted}>{tx.amount>0?"+":""}{tx.amount!==0?`${tx.amount.toLocaleString()} ₽`:"—"}
+ {tx.note}
+ );
+ })}
+
+
+ )}
+
+ );
+};
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// PLATFORM VIEWS
+// ═══════════════════════════════════════════════════════════════════════════════
+const PlatformView = ({tenants,setTenants,toast,mobile})=>(
+
+
+
+
+ a+b.totalTurnover,0)/1000).toFixed(0)}K₽`} accent={R.cyan} icon="revenue" mobile={mobile}/>
+ a+b.totalCommission,0).toLocaleString()}₽`} accent={R.green} icon="percent" mobile={mobile}/>
+ t.balance<=t.alertThreshold).length} accent={R.red} icon="alert" mobile={mobile}/>
+
+
+ {/* Problems */}
+ {tenants.filter(t=>t.balance<=t.alertThreshold).length>0&&(
+
+
⚡ Требуют внимания
+
+ {tenants.filter(t=>t.balance<=t.alertThreshold).map(t=>{
+ const empty=t.balance<=0;const c=empty?R.red:R.yellow;
+ return(
+
+
+
{t.name}
+
{empty?"Баланс 0 — боты заблокированы":`Низкий баланс: ${t.balance}₽`}
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Users table / cards */}
+ {mobile?(
+
+ {tenants.map(t=>{
+ const empty=t.balance<=0,low=t.balance>0&&t.balance<=t.alertThreshold;
+ const c=empty?R.red:low?R.yellow:R.green;
+ return(
+
+
+
+
{t.balance.toLocaleString()}₽
+
+{t.totalCommission.toLocaleString()}₽
+
+
+ );
+ })}
+
+ ):(
+
+
+
+ {["Пользователь","Тариф","Баланс","Порог","Статус","Оборот","Комиссия нам",""].map(h=>)}
+ {tenants.map(t=>{
+ const empty=t.balance<=0,low=t.balance>0&&t.balance<=t.alertThreshold;
+ const c=empty?R.red:low?R.yellow:R.green;
+ return(
+ {t.name}{t.email}
+
+ {t.balance.toLocaleString()}₽
+ {t.alertThreshold.toLocaleString()}₽
+ {empty?:low?:}
+ {t.totalTurnover.toLocaleString()}₽
+ +{t.totalCommission.toLocaleString()}₽
+
+ {setTenants(tenants.map(x=>x.id===t.id?{...x,status:x.status==="active"?"blocked":"active"}:x));toast(t.status==="active"?"Заблокирован":"Разблокирован");}}>
+ {t.status==="active"?"Блок":"Разблок"}
+
+
+ );
+ })}
+
+
+
+ )}
+
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// ROOT APP
+// ═══════════════════════════════════════════════════════════════════════════════
+const NAV = {
+ platform: [
+ {id:"dashboard",n:"dashboard",l:"Платформа"},
+ {id:"settings",n:"settings",l:"Настройки"},
+ ],
+ tenant: [
+ {id:"dashboard",n:"dashboard",l:"Кабинет"},
+ {id:"shops",n:"shops",l:"Магазины"},
+ {id:"orders",n:"orders",l:"Заказы"},
+ {id:"cards",n:"cards",l:"Карты"},
+ {id:"team",n:"team",l:"Команда"},
+ {id:"balance",n:"wallet",l:"Баланс"},
+ ],
+ moderator:[
+ {id:"orders",n:"orders",l:"Заказы"},
+ ],
+};
+
+export default function App() {
+ const mobile = useMobile();
+ const [mode,setMode] = useState("platform");
+ const [view,setView] = useState("dashboard");
+ const [sideOpen,setSideOpen] = useState(false);
+ const [toast,setToast] = useState(null);
+ const [topupOpen,setTopupOpen] = useState(false);
+
+ const [tenant,setTenant] = useState(EMPTY_TENANT);
+ const [shops,setShops] = useState([]);
+ const [cards,setCards] = useState([]);
+ const [orders,setOrders] = useState([]);
+ const [txs,setTxs] = useState([]);
+ const [platformTenants,setPlatformTenants] = useState([]);
+ const [loading,setLoading] = useState(true);
+ const [loadError,setLoadError] = useState("");
+
+ const showToast = (msg,ok=true) => setToast({msg,ok});
+
+ const loadOverview = useCallback(async () => {
try {
- await api(path, { method: "POST", body: JSON.stringify(payload) });
- setToast("Готово");
- setModal(null);
- await load();
+ const data = await adminApi("/overview");
+ setLoadError("");
+ const mapped = mapOverview(data, tenant?.id);
+ setTenant(mapped.tenant);
+ setShops(mapped.shops);
+ setCards(mapped.cards);
+ setOrders(mapped.orders);
+ setTxs(mapped.txs);
+ setPlatformTenants(mapped.platformTenants);
} catch (e) {
- setToast(e.message);
- } finally { setBusy(false); }
+ setLoadError(e.message || "Не удалось загрузить данные");
+ } finally {
+ setLoading(false);
+ }
+ }, [tenant?.id]);
+
+ useEffect(() => { loadOverview(); }, []);
+ useEffect(() => { window.bfReload = loadOverview; return () => { delete window.bfReload; }; }, [loadOverview]);
+
+ const switchMode = m => {
+ setMode(m);
+ setView(m==="moderator"?"orders":"dashboard");
+ setSideOpen(false);
};
- const action = async (path) => {
- setBusy(true);
- try { await api(path, { method: "POST" }); setToast("Готово"); await load(); }
- catch (e) { setToast(e.message); }
- finally { setBusy(false); }
+ const handleTopup = async amt => {
+ if(amt<100 || !tenant.id) return;
+ try {
+ await adminApi(`/tenants/${tenant.id}/topup`, { method:"POST", body:JSON.stringify({ amount:amt, note:"Пополнение комиссионного баланса через панель" }) });
+ await loadOverview();
+ showToast(`Комиссионный баланс пополнен на ${amt.toLocaleString()} ₽`);
+ } catch(e) {
+ showToast(e.message || "Ошибка пополнения", false);
+ }
};
- return
-
+ const modeColor = {platform:R.accent, tenant:R.cyan, moderator:R.orange}[mode];
+ const modeLabel = {platform:"Платформа", tenant:tenant.name, moderator:"Кассир"}[mode];
+ const navItems = NAV[mode]||[];
-
- Система без демо-данных
{title(tab)} setModal(tab)}>{buttonTitle(tab)}
- {loading && }
- {error && }
- {!loading && !error && data && <>
- {tab === "dashboard" && }
- {tab === "tenants" && setModal({type:"topup", id})} onPlan={(id)=>setModal({type:"plan", id})} onBlock={(id)=>action(`/tenants/${id}/toggle-block`)} />}
- {tab === "shops" && }
- {tab === "products" && }
- {tab === "cards" && }
- {tab === "tokens" && action(`/tokens/${id}/activate`)} />}
- {tab === "members" && }
- {tab === "orders" && action(`/orders/${id}/confirm`)} onReject={(id)=>action(`/orders/${id}/reject`)} />}
- {tab === "postpaid" && t.plan==="postpaid_custom")} />}
- >}
-
+ const pendingCount = orders.filter(o=>o.status==="pending"||o.status==="confirming").length;
+ const navWithBadge = navItems.map(n=>n.id==="orders"?{...n,badge:pendingCount}:n);
- {modal &&
setModal(null)} onSubmit={submit} tenantsById={tenantsById} />}
- {toast && setToast("")}>{toast}
}
- ;
-}
+ const isLow = tenant.balance>0&&tenant.balance<=tenant.alertThreshold;
+ const isEmpty = tenant.balance<=0;
+ const balColor = isEmpty?R.red:isLow?R.yellow:R.green;
-function title(tab) {
- return ({dashboard:"Дашборд", tenants:"Пользователи", shops:"Магазины", products:"Товары", cards:"Карты", tokens:"Токены бота магазина", members:"Кассиры", orders:"Заказы", postpaid:"Индивидуальная постоплата"})[tab] || "BotFactory";
-}
-function buttonTitle(tab) {
- return ({tenants:"+ Пользователь", shops:"+ Магазин", products:"+ Товар", cards:"+ Карта", tokens:"+ Токен", members:"+ Кассир"})[tab] || "Обновить";
-}
+ // ── SIDEBAR content ──────────────────────────────────────────────────────────
+ const SidebarContent = () => (
+
+ {/* Logo */}
+
+
+
+ BF
+
+
+
BotFactory
+
SaaS Platform
+
+
+
-function Dashboard({ data }) {
- const s = data.stats;
- return <>
-
-
-
+ {/* Mode switcher */}
+
+
Режим
+ {[["platform","🏢 Платформа"],["tenant","🛒 Владелец магазина"],["moderator","💼 Кассир"]].map(([m,l])=>(
+
switchMode(m)} style={{width:"100%",display:"flex",alignItems:"center",gap:8,padding:"7px 10px",marginBottom:2,background:mode===m?`${modeColor}15`:"transparent",border:`1px solid ${mode===m?modeColor+"40":"transparent"}`,borderRadius:7,color:mode===m?modeColor:R.muted,cursor:"pointer",fontSize:12,fontFamily:"'JetBrains Mono'",textAlign:"left"}}>{l}
+ ))}
+
+
+ {/* Context */}
+
+
{modeLabel}
+ {mode==="tenant"&&
Тариф: {(PLANS[tenant.plan]||PLANS.trial_week).label} · {(PLANS[tenant.plan]||PLANS.trial_week).rate}%
}
+
+
+ {/* Balance widget (tenant) */}
+ {mode==="tenant"&&(
+
{setTopupOpen(true);setSideOpen(false);}} style={{margin:"8px 10px",background:`${balColor}12`,border:`1px solid ${balColor}35`,borderRadius:9,padding:"9px 12px",cursor:"pointer",flexShrink:0}}>
+
Комиссионный баланс
+
{tenant.balance.toLocaleString()} ₽
+ {isEmpty&&
⛔ Боты остановлены
}
+ {isLow&&!isEmpty&&
⚠️ Низкий баланс
}
+
+ )}
+
+ {/* Nav */}
+
+ {navWithBadge.map(n=>(
+ {setView(n.id);setSideOpen(false);}} style={{width:"100%",display:"flex",alignItems:"center",gap:10,padding:"10px 16px",background:"transparent",border:"none",borderLeft:`3px solid ${view===n.id?modeColor:"transparent"}`,color:view===n.id?modeColor:R.muted,cursor:"pointer",textAlign:"left",fontSize:13,fontFamily:"'JetBrains Mono'"}}>
+
+ {n.l}
+ {n.badge>0&&{n.badge} }
+
+ ))}
+
+
+ {/* Status */}
+
-
Состояние {!data.tenants.length ? : }
- >;
+ );
+
+ return(<>
+
+
+
+ {/* ── DESKTOP SIDEBAR ──────────────────────────────── */}
+ {!mobile&&(
+
+
+
+ )}
+
+ {/* ── MOBILE: top bar + drawer ─────────────────────── */}
+ {mobile&&(<>
+
+
setSideOpen(true)} style={{background:"none",border:"none",color:R.muted,cursor:"pointer",padding:6}}>
+
BotFactory
+ {mode==="tenant"?(
+
setTopupOpen(true)} style={{background:`${balColor}18`,border:`1px solid ${balColor}40`,borderRadius:8,padding:"4px 10px",cursor:"pointer"}}>
+
{tenant.balance.toLocaleString()}₽
+
+ ):
}
+
+
+ {/* Drawer overlay */}
+ {sideOpen&&(
+
setSideOpen(false)}>
+
+
e.stopPropagation()}>
+
+ setSideOpen(false)} style={{background:"none",border:"none",color:R.muted,cursor:"pointer"}}>
+
+
+
+
+
+
+ )}
+
+ {/* Mobile bottom nav */}
+
+ {navWithBadge.map(n=>(
+ setView(n.id)} style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",padding:"8px 4px",background:"transparent",border:"none",color:view===n.id?modeColor:R.muted,cursor:"pointer",position:"relative"}}>
+
+ {n.l}
+ {n.badge>0&&{n.badge} }
+
+ ))}
+
+ >)}
+
+ {/* ── MAIN CONTENT ─────────────────────────────────── */}
+
+
+ {loading&&(
Загрузка данных...
)}
+ {loadError&&(
Ошибка API: {loadError}
)}
+
+ {/* Moderator banner */}
+ {mode==="moderator"&&(
+
+
+ Режим кассира — доступны только заказы
+
+ )}
+
+ {/* PLATFORM */}
+ {mode==="platform"&&
}
+
+ {/* TENANT */}
+ {mode==="tenant"&&view==="dashboard"&&setTopupOpen(true)} mobile={mobile}/>}
+ {mode==="tenant"&&view==="shops"&&setTopupOpen(true)} toast={showToast} mobile={mobile}/>}
+ {mode==="tenant"&&view==="orders"&&setTopupOpen(true)} role="admin" mobile={mobile}/>}
+ {mode==="tenant"&&view==="cards"&&}
+ {mode==="tenant"&&view==="team"&&}
+ {mode==="tenant"&&view==="balance"&&setTopupOpen(true)} mobile={mobile}/>}
+
+ {/* MODERATOR */}
+ {mode==="moderator"&&setTopupOpen(true)} role="moderator" mobile={mobile}/>}
+
+
+
+ {topupOpen&&
setTopupOpen(false)} onDone={handleTopup}/>}
+ {toast&&setToast(null)}/>}
+ >);
}
-function Card({ k, v, warn }) { return {k} {v}
; }
-
-function Table({ head, children }) { return {head.map(h => {h} )} {children}
; }
-function Badge({ children, tone="" }) { return {children} ; }
-
-function Tenants({ rows, onTopup, onPlan, onBlock, compact }) {
- if (!rows.length) return ;
- return {rows.map(t =>
- #{t.id} {t.name} {t.email} TG: {t.telegram_id || "—"}
- {plans.find(p=>p[0]===t.plan)?.[1] || t.plan} {t.trial_ends_at && до {date(t.trial_ends_at)} }
- {fmt(t.balance)}порог {fmt(t.alert_threshold)} {fmt(t.total_revenue)}комиссия {fmt(t.total_commission)}
- {t.is_blocked ? блок : активен }
- {!compact && onTopup(t.id)}>Пополнить onPlan(t.id)}>Тариф onBlock(t.id)}>{t.is_blocked ? "Разблок" : "Блок"} }
- )}
;
-}
-function Shops({ rows, tenantsById }) {
- if (!rows.length) return ;
- return {rows.map(s =>
- #{s.id} {s.name} {s.is_active ? "активен" : "выключен"} {tenantsById[s.tenant_id]?.name || s.tenant_id} {s.ctrl_bot_username || "токен задан"} {s.active_shop_bot || "—"} {s.products_count} товаров · {s.orders_count} заказов · {fmt(s.revenue)}
- )}
;
-}
-function Products({ rows, shopsById }) { if (!rows.length) return ; return {rows.map(p=>#{p.id} {p.name} {p.category} {p.description} {shopsById[p.shop_id]?.name || p.shop_id} {fmt(p.price)} {p.stock}продано {p.sold} {p.is_active?активен :архив } )}
; }
-function Cards({ rows, shopsById }) { if (!rows.length) return ; return {rows.map(c=>#{c.id} {c.bank} {c.number} {c.holder}{c.phone && ` · ${c.phone}`} {shopsById[c.shop_id]?.name || c.shop_id} {c.orders_count} заказов {fmt(c.received_total)} {c.is_active?активна :выкл } )}
; }
-function Tokens({ rows, shopsById, onActivate }) { if (!rows.length) return ; return {rows.map(t=>#{t.id} {t.username || t.token_masked} {t.token_masked} {shopsById[t.shop_id]?.name || t.shop_id} {t.note} {t.is_active?активный :резерв } {!t.is_active && onActivate(t.id)}>Переключить } )}
; }
-function Members({ rows, shopsById }) { if (!rows.length) return ; return {rows.map(m=>#{m.id} {m.name} @{m.username || "—"} · TG {m.telegram_id} {shopsById[m.shop_id]?.name || m.shop_id} {m.role} )}
; }
-function Orders({ rows, shopsById, onConfirm, onReject }) { if (!rows.length) return ; return {rows.map(o=>#{o.id} {shopsById[o.shop_id]?.name || o.shop_id} {o.buyer_username || o.buyer_telegram_id} {fmt(o.amount)}комиссия {fmt(o.commission)} {o.status} {date(o.created_at)} {["pending","confirming"].includes(o.status)&&<>onConfirm(o.id)}>Подтвердить onReject(o.id)}>Отклонить >} )}
; }
-function Postpaid({ rows }) { if (!rows.length) return ; return {rows.map(t=>{t.name} {t.email} {t.postpaid_commission_percent}% до {t.postpaid_due_day} числа{date(t.postpaid_due_date)} {fmt(t.postpaid_previous_month_revenue)} {fmt(t.postpaid_previous_month_due)} )}
; }
-
-function CreateModal({ modal, data, busy, onClose, onSubmit, tenantsById }) {
- const type = typeof modal === "string" ? modal : modal.type;
- const id = typeof modal === "object" ? modal.id : null;
- const [form, setForm] = useState({});
- const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
- const shops = data?.shops || [];
- const tenants = data?.tenants || [];
- const post = (path, payload) => onSubmit(path, payload);
-
- if (type === "dashboard" || type === "orders" || type === "postpaid") return Используйте кнопку «Обновить» в меню.
;
- if (type === "topup") return ;
- if (type === "plan") return ;
- if (type === "tenants") return ;
- if (type === "shops") return ;
- if (type === "products") return post("/products", { shop_id:Number(form.shop_id), name:form.name, price:Number(form.price), category:form.category || "Общее", description:form.description || "", content:form.content || "", photo_url:form.photo_url || null, stock:Number(form.stock || 1) })}> set("name",e.target.value)} /> set("price",e.target.value)} /> set("category",e.target.value)} />set("description",e.target.value)} /> set("content",e.target.value)} /> set("photo_url",e.target.value)} /> set("stock",e.target.value)} placeholder="1"/> ;
- if (type === "cards") return post("/cards", { shop_id:Number(form.shop_id), bank:form.bank, number:form.number, holder:form.holder, phone:form.phone || "" })}> set("bank",e.target.value)} /> set("number",e.target.value)} /> set("holder",e.target.value)} /> set("phone",e.target.value)} /> ;
- if (type === "tokens") return post("/tokens", { shop_id:Number(form.shop_id), token:form.token, username:form.username || "", note:form.note || "Основной", is_active:form.is_active !== "false" })}> set("token",e.target.value)} /> set("username",e.target.value)} placeholder="@shop_bot"/> set("note",e.target.value)} />set("is_active",e.target.value)}>Да Нет, резерв ;
- if (type === "members") return post("/members", { shop_id:Number(form.shop_id), telegram_id:Number(form.telegram_id), username:form.username || "", name:form.name, role:form.role || "moderator" })}> set("name",e.target.value)} /> set("telegram_id",e.target.value)} /> set("username",e.target.value)} />set("role",e.target.value)}>moderator / кассир admin ;
- return null;
-}
-function num(v){ return v ? Number(v) : null; }
-function SelectTenant({ tenants, set }) { return set("tenant_id", e.target.value)} defaultValue="">Выберите пользователя {tenants.map(t=>{t.name} · {t.email} )} ; }
-function SelectShop({ shops, set }) { return set("shop_id", e.target.value)} defaultValue="">Выберите магазин {shops.map(s=>{s.name} )} ; }
-function Form({ onSubmit, busy, children }) { return {e.preventDefault(); onSubmit();}}>{children}{busy ? "Сохраняю..." : "Сохранить"} ; }
-
-export default App;
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index ce286a9..363941a 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,5 +1,9 @@
-import React from "react";
-import { createRoot } from "react-dom/client";
-import App from "./App.jsx";
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.jsx'
-createRoot(document.getElementById("root")).render( );
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+)