From abcc8a02f2ed266fdd4ca90c67c29738178ed2ec Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 5 Aug 2026 19:45:03 +0000 Subject: [PATCH] 6907aded-9f9a-4ebb-bfee-7f87d6d0a5ab --- docs/admin-nextjs-guide.md | 271 +++++- keepalive.js | 25 - keepalive.sh | 14 - seed-standalone.ts | 140 --- src/app/api/admin/chatbot/route.ts | 149 ++++ src/app/api/chat/route.ts | 367 ++++++++ src/app/api/leads/[id]/route.ts | 112 +++ src/app/api/leads/[id]/sessions/route.ts | 67 ++ src/app/api/leads/bulk/route.ts | 55 ++ src/app/api/operator/route.ts | 91 ++ src/app/globals.css | 33 + src/app/login/page.tsx | 1 + src/app/page.tsx | 4 + .../chatbot/chatbot-settings-page.tsx | 513 +++++++++++ src/components/layout/admin-sidebar.tsx | 4 + src/components/leads/leads-page.tsx | 818 ++++++++++++++++++ src/lib/chatbot-config.ts | 38 + .../read_1785958548617_e2021f27b753.txt | 554 ++++++++++++ worklog.md | 165 +++- 19 files changed, 3221 insertions(+), 200 deletions(-) delete mode 100644 keepalive.js delete mode 100755 keepalive.sh delete mode 100755 seed-standalone.ts create mode 100644 src/app/api/admin/chatbot/route.ts create mode 100644 src/app/api/chat/route.ts create mode 100644 src/app/api/leads/[id]/route.ts create mode 100644 src/app/api/leads/[id]/sessions/route.ts create mode 100644 src/app/api/leads/bulk/route.ts create mode 100644 src/app/api/operator/route.ts create mode 100644 src/components/chatbot/chatbot-settings-page.tsx create mode 100644 src/components/leads/leads-page.tsx create mode 100644 src/lib/chatbot-config.ts create mode 100644 tool-results/read_1785958548617_e2021f27b753.txt diff --git a/docs/admin-nextjs-guide.md b/docs/admin-nextjs-guide.md index 190500c..1b30b1f 100755 --- a/docs/admin-nextjs-guide.md +++ b/docs/admin-nextjs-guide.md @@ -15,45 +15,278 @@ | БД | Prisma ORM + SQLite | | Графики | Recharts | | Состояние | Zustand | +| ИИ/LLM | z-ai-web-dev-sdk (chat, vision) | | Тема | next-themes (dark/light) | | Уведомления | Sonner (toast) | | Иконки | Lucide React | ## Авторизация -- Пароль: значение ADMIN_SECRET из .env -- Если задан SUPER_ADMIN_SECRET (и ≠ ADMIN_SECRET) — вход с ним даёт роль super_admin -- Если не задан — все админы = super_admin -- Кука admin_token (httpOnly, 24ч) +- Пароль: значение `ADMIN_SECRET` из `.env` (по умолчанию `changeme`) +- Если задан `SUPER_ADMIN_SECRET` (и ≠ `ADMIN_SECRET`) — вход с ним даёт роль `super_admin` +- Если не задан — все админы = `super_admin` +- Кука `admin_token` (httpOnly, sameSite=lax, 24ч, secure в production) - Rate-limit: 5 попыток / 15 мин с IP +- HMAC-SHA256 подпись токена -## Экраны (11) +## Экраны (15) -1. **Dashboard** — 10 KPI, 7 графиков, активность, кошельки -2. **Catalog** — дерево локаций + товары + модалка -3. **Users** — список + детальная + бан + баланс -4. **Wallets** — 3 вкладки + seed фразы (super_admin) -5. **Purchases** — таблица с фильтрами -6. **Audit Log** — журнал с цветными бейджами -7. **Categories** — CRUD с привязкой к локациям +### Основные +1. **Dashboard** — 4 KPI-карточки, 8 графиков (recharts), лента активности, авто-обновление +2. **Catalog** — дерево локаций → категории → подкатегории → товары, CRUD, clone, экспорт +3. **Users** — таблица + детальная страница, batch ban/unban, заметки, поиск, статус-вкладки +4. **Wallets** — 4 вкладки (All/Owner/Transactions/Seeds), запись платежей, экспорт seed-фраз +5. **Purchases** — таблица с batch approve/cancel, фильтры по статусу/дате, модаль деталей +6. **Audit Log** — журнал действий, фильтры по действию/дате/поиску, копирование записей +7. **Categories** — CRUD с просмотром товаров, подсчёт подкатегорий и продуктов 8. **Locations** — CRUD (страна/город/район) -9. **Settings** — редактирование .env переменных -10. **Locales** — inline-редактор 3 языков -11. **Danger Zone** — Seed / Clear с reauth +9. **Settings** — редактирование параметров, экспорт/импорт данных (super_admin) +10. **Locales** — inline-редактор EN/ES/DE переводов +11. **Danger Zone** — Seed / Clear с reauth (super_admin) + +### ИИ-ассистент и Лиды +12. **AI Chatbot** (`#/chatbot`) — полная конфигурация ИИ-бота: + - Включение/выключение бота + - **Спящий режим** — при включении `/start` показывает ИИ-чат вместо каталога + - Системный промпт и приветственное сообщение + - Температура, max tokens, глубина истории + - **База знаний** — словарный лексикон (товары, цены, FAQ) + - Провайдер: OpenAI / DeepSeek / OpenRouter / Ollama / Custom + - API endpoint и ключ (маскированный при отображении) + +13. **Leads** (`#/leads`) — управление лидами и переписками: + - Таблица лидов с поиском, фильтрами по статусу + - AI-профиль клиента (intent, interests, sentiment, readiness) + - Просмотр переписок в виде чат-пузырей + - **Вход оператора** — переключение бота на ручной режим + - Статусы: Новый / Контакт / Квалифицированный / Потерянный / Спам + - Заметки и quick-статус + +## Спящий режим магазина + +Когда спящий режим включён в настройках AI Chatbot: +- Пользователь через `/start` получает ИИ-чат вместо каталога +- Бот сообщает что магазин пополняется +- Можно собрать контакты для уведомления при открытии +- Все сессии сохраняются в таблице `chat_sessions` +- Из переписок автоматически извлекаются лиды + +## API маршруты (48+) + +### Основные +| Метод | Маршрут | Описание | +|---|---|---| +| POST | `/api/auth/login` | Вход, установка куки | +| GET | `/api/auth/session` | Проверка сессии | +| POST | `/api/auth/logout` | Выход | +| GET | `/api/stats/dashboard` | Статистика дашборда | +| GET/PUT | `/api/settings` | Настройки | +| GET | `/api/settings/export` | Полный экспорт БД (JSON) | +| POST | `/api/settings/import` | Импорт (в разработке) | + +### Товары и каталог +| Метод | Маршрут | Описание | +|---|---|---| +| GET | `/api/products/bulk` | Список товаров | +| POST | `/api/products/add` | Создание товара | +| GET/PUT/DELETE | `/api/products/[id]` | CRUD товара | +| POST | `/api/products/[id]/clone` | Дублирование товара | +| GET | `/api/catalog/tree` | Дерево каталога | +| GET/POST | `/api/categories/bulk` | Список/создание категорий | +| GET/PUT/DELETE | `/api/categories/[id]` | CRUD категории | +| GET/POST | `/api/subcategories/bulk` | Список/создание подкатегорий | +| GET/PUT/DELETE | `/api/subcategories/[id]` | CRUD подкатегории | +| GET/POST | `/api/locations/bulk` | Список/создание локаций | +| GET/PUT/DELETE | `/api/locations/[id]` | CRUD локации | + +### Пользователи и кошельки +| Метод | Маршрут | Описание | +|---|---|---| +| GET | `/api/users/bulk` | Список пользователей | +| GET/PUT | `/api/users/[id]` | Профиль пользователя | +| POST | `/api/users/[id]/adjust-balance` | Корректировка баланса | +| POST | `/api/users/batch-status` | Batch ban/unban | +| GET | `/api/wallets/bulk` | Список кошельков | +| GET | `/api/wallets/overview` | Обзор кошельков | +| GET | `/api/wallets/[userId]` | Кошельки пользователя | +| POST | `/api/wallets/record-payment` | Запись платежа | +| GET | `/api/wallets/seeds` | Seed-фразы (super_admin) | +| GET | `/api/wallets/export-seeds` | Экспорт seed-фраз CSV | + +### Покупки и транзакции +| Метод | Маршрут | Описание | +|---|---|---| +| GET | `/api/purchases/bulk` | Список покупок | +| GET/PUT | `/api/purchases/[id]` | Детали/статус покупки | +| POST | `/api/purchases/batch-status` | Batch approve/cancel | +| GET | `/api/transactions/bulk` | Список транзакций | + +### Аудит и локали +| Метод | Маршрут | Описание | +|---|---|---| +| GET | `/api/audit/bulk` | Журнал аудита | +| GET | `/api/locales` | Локализации (EN/ES/DE) | +| POST | `/api/seed/demo` | Генерация тестовых данных | +| POST | `/api/seed/clear` | Очистка БД | + +### ИИ-чатбот и лиды +| Метод | Маршрут | Описание | +|---|---|---| +| GET/PUT | `/api/admin/chatbot` | Настройки ИИ-бота | +| POST | `/api/chat` | Точка входа чата (для TG-бота) | +| GET | `/api/leads/bulk` | Список лидов | +| GET/PUT | `/api/leads/[id]` | Профиль лида | +| GET | `/api/leads/[id]/sessions` | Чат-сессии лида | +| POST | `/api/operator` | Подключение/отключение оператора | + +## Модель данных (Prisma) + +Всего **15 моделей**: + +| Модель | Описание | +|---|---| +| `TgUser` | Пользователи Telegram | +| `CryptoWallet` | Криптокошельки | +| `Transaction` | Транзакции | +| `Location` | Локации (страна/город/район) | +| `Category` | Категории товаров | +| `Subcategory` | Подкатегории | +| `Product` | Товары | +| `Purchase` | Покупки | +| `CommissionPayment` | Комиссионные платежи | +| `AuditLog` | Журнал аудита | +| `UserState` | Состояния пользователей ( FSM ) | +| `ChatSession` | Чат-сессии с ИИ-ботом | +| `Lead` | Лиды (авто-экстракция из чата) | +| `SiteSetting` | Ключ-значение настройки | + +### ChatSession +```prisma +model ChatSession { + id Int @id @default(autoincrement()) + sessionId String @unique + telegramId String? + leadId Int? + messages String // JSON: [{role, content, timestamp}] + customerProfile String? // AI-профиль клиента (JSON) + isActive Boolean @default(true) + operatorName String? // Подключённый оператор + autoReplyDisabled Boolean @default(false) // Ручной режим + operatorConnectedAt DateTime? + device / ip / country + createdAt / updatedAt +} +``` + +### Lead +```prisma +model Lead { + id Int @id @default(autoincrement()) + telegramId String? @unique + name / phone / email / telegram + status String @default("new") + aiLeadScore Float? // 0-1 скор от ИИ + customFields String // JSON + chatSessions ChatSession[] +} +``` + +## Архитектура ИИ-бота + +### Конфигурация (SiteSetting) +Все настройки бота хранятся в таблице `site_settings` как ключ-значение. Кэшируются 5 минут. + +| Ключ | Тип | По умолчанию | +|---|---|---| +| `chatbot_enabled` | bool | true | +| `chatbot_sleep_mode` | bool | false | +| `chatbot_sleep_message` | text | "Магазин пополняется..." | +| `chatbot_system_prompt` | text | "Ты — AI-ассистент..." | +| `chatbot_welcome_message` | text | "Здравствуйте!..." | +| `chatbot_temperature` | float | 0.7 | +| `chatbot_max_tokens` | int | 500 | +| `chatbot_max_history` | int | 20 | +| `chatbot_knowledge_base` | text | "" | +| `chatbot_provider` | enum | openai | +| `chatbot_api_endpoint` | url | https://api.openai.com/v1 | +| `chatbot_api_key` | string | "" | + +### Поток чата +1. TG-бот отправляет `POST /api/chat` с `{sessionId, message, telegramId}` +2. Сервер находит/создаёт ChatSession +3. Загружает конфигурацию бота (из кэша/БД) +4. Формирует системный промпт: `system_prompt + knowledge_base + customer_profile + catalog_context` +5. Если `sleep_mode` — добавляет информацию о режиме пополнения +6. Отправляет в LLM (через z-ai-web-dev-sdk) +7. Сохраняет сообщения в ChatSession +8. Извлекает данные лида (имя, телефон, email, telegram) из текста +9. Генерирует/обновляет AI-профиль клиента (intent, interests, sentiment, readiness) +10. Автоматически создаёт/обновляет запись Lead +11. Возвращает `{reply, sessionId, leadId?, profile?}` + +### Профилирование клиента +Из каждой переписки ИИ автоматически генерирует профиль: +```json +{ + "intent": "покупка|информация|поддержка|пробный период", + "interests": ["VPN-коды", "аналитика"], + "sentiment": "positive|neutral|negative", + "budgetHint": "бюджетный|высокий", + "readiness": "exploring|considering|ready", + "summary": "Цель: покупка. Интересуется VPN." +} +``` + +### Оператор +- Оператор может подключиться к любой сессии (`POST /api/operator`) +- При подключении: `autoReplyDisabled=true`, бот перестаёт отвечать +- Оператор видит всю историю переписки +- При отключении — бот снова отвечает ## Docker (ARM) ```bash docker build -t tg-admin . -docker run -p 3000:3000 -v ./db:/app/db -e ADMIN_SECRET=your_secret tg-admin +docker run -p 3000:3000 -v ./db:/app/db \ + -e ADMIN_SECRET=your_secret \ + -e SUPER_ADMIN_SECRET=super_secret \ + -e CHATBOT_API_KEY=sk-xxx \ + tg-admin ``` Требования: минимум 512 МБ RAM, ARM64/ARMv7/x86_64. ## Seed-данные -POST /api/seed/demo создаёт данные идентичные оригинальному seed.js: +POST `/api/seed/demo` создаёт данные: - 3 локации, 5 категорий, 11 подкатегорий -- 10 пользователей (alice-jack) -- 10 товаров, 25-30 покупок +- 10 пользователей (alice–jack) +- 10 товаров, 25–30 покупок - 10 криптокошельков, 2 комиссионных платежа, 12 audit записей + +## Общие компоненты + +- `Pagination` — числовая пагинация с многоточиями +- `ErrorBoundary` — обработка ошибок рендеринга +- `ExportButton` — экспорт CSV + JSON +- `SortableHeader` — сортируемые заголовки таблиц +- `useDebounce` — хук debounce (300ms) +- `useMobile` — хук определения мобильного устройства +- `useKeyboardShortcuts` — хук горячих клавиш (1–9) + +## Навигация + +Хеш-роутинг (`window.location.hash`). Горячие клавиши: +`1` Dashboard, `2` Catalog, `3` Users, `4` Wallets, `5` Purchases, +`6` Audit, `7` Categories, `8` Locations, `9` Settings +`Ctrl+K` — палитра команд + +## Визуальные эффекты + +- **Matrix Rain** — CSS-анимация падающих символов на странице логина (низкая непрозрачность) +- **Glass-card** — стеклянный эффект карточек (backdrop-blur) +- **KPI shimmer** — мерцание при наведении на KPI-карточки +- **Page-enter** — fade-in анимация страниц +- **Gradient borders** — градиентные рамки хедера/футера +- **Alternating rows** — чередующиеся строки таблиц +- **Sticky table headers** — закреплённые заголовки таблиц \ No newline at end of file diff --git a/keepalive.js b/keepalive.js deleted file mode 100644 index d220a79..0000000 --- a/keepalive.js +++ /dev/null @@ -1,25 +0,0 @@ -const { spawn } = require('child_process'); -const http = require('http'); - -function startServer() { - const child = spawn('node', ['server.js'], { - cwd: '/home/z/my-project/.next/standalone', - env: { ...process.env, PORT: '3000', NODE_ENV: 'production' }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - child.stdout.on('data', d => process.stdout.write(d)); - child.stderr.on('data', d => process.stderr.write(d)); - child.on('exit', () => { console.log('Server exited, restarting...'); setTimeout(startServer, 2000); }); - return child; -} - -startServer(); - -// Keepalive ping every 8s -setInterval(() => { - const req = http.get('http://localhost:3000/', () => {}); - req.on('error', () => {}); - req.setTimeout(3000, () => req.destroy()); -}, 8000); - -console.log('Keepalive process started'); diff --git a/keepalive.sh b/keepalive.sh deleted file mode 100755 index 4a68081..0000000 --- a/keepalive.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -cd /home/z/my-project/.next/standalone -while true; do - PORT=3000 NODE_ENV=production node server.js > /tmp/server.log 2>&1 - echo "Server died, restarting in 2s..." >> /tmp/server.log - sleep 2 -done -& -# Keepalive pinger -while true; do - sleep 10 - curl -s -o /dev/null http://localhost:3000/ 2>/dev/null - curl -s -o /dev/null http://localhost:3000/api/auth/session 2>/dev/null -done diff --git a/seed-standalone.ts b/seed-standalone.ts deleted file mode 100755 index 9d96ae1..0000000 --- a/seed-standalone.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { PrismaClient } from '@prisma/client'; - -const db = new PrismaClient(); - -function daysAgo(n: number) { return new Date(Date.now() - n * 86400000); } -function randInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } -function randFloat(min: number, max: number, decimals: number) { return parseFloat((Math.random() * (max - min) + min).toFixed(decimals)); } -function pick(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } - -async function main() { - console.log('Clearing tables...'); - const tableOrder = ['purchase', 'transaction', 'cryptoWallet', 'auditLog', 'userState', 'product', 'subcategory', 'category', 'commissionPayment']; - for (const model of tableOrder) { - await (db as any)[model].deleteMany(); - } - await db.tgUser.deleteMany(); - await db.location.deleteMany(); - - const tables = ['purchases', 'transactions', 'crypto_wallets', 'audit_log', 'user_states', 'products', 'subcategories', 'categories', 'users', 'locations', 'commission_payments']; - for (const t of tables) { - try { await db.$executeRawUnsafe(`DELETE FROM sqlite_sequence WHERE name='${t}';`); } catch {} - } - - console.log('Creating locations...'); - const locMoscow = await db.location.create({ data: { country: 'Russia', city: 'Moscow', district: 'Center' } }); - const locSPb = await db.location.create({ data: { country: 'Russia', city: 'Saint Petersburg', district: 'North' } }); - const locBerlin = await db.location.create({ data: { country: 'Germany', city: 'Berlin', district: 'Mitte' } }); - - console.log('Creating categories...'); - const catDigital = await db.category.create({ data: { name: 'Digital', locationId: locMoscow.id } }); - const catPhysical = await db.category.create({ data: { name: 'Physical', locationId: locMoscow.id } }); - const catPremium = await db.category.create({ data: { name: 'Premium', locationId: locSPb.id } }); - const catVIP = await db.category.create({ data: { name: 'VIP', locationId: locSPb.id } }); - const catStandard = await db.category.create({ data: { name: 'Standard', locationId: locBerlin.id } }); - - console.log('Creating subcategories...'); - const subVPN = await db.subcategory.create({ data: { name: 'VPN', categoryId: catDigital.id } }); - const subAccounts = await db.subcategory.create({ data: { name: 'Accounts', categoryId: catDigital.id } }); - const subSoftware = await db.subcategory.create({ data: { name: 'Software', categoryId: catDigital.id } }); - const subHardware = await db.subcategory.create({ data: { name: 'Hardware', categoryId: catPhysical.id } }); - const subAccessories = await db.subcategory.create({ data: { name: 'Accessories', categoryId: catPhysical.id } }); - const subAnnual = await db.subcategory.create({ data: { name: 'Annual', categoryId: catPremium.id } }); - const subMonthly = await db.subcategory.create({ data: { name: 'Monthly', categoryId: catPremium.id } }); - const subLifetime = await db.subcategory.create({ data: { name: 'Lifetime', categoryId: catVIP.id } }); - const subExpress = await db.subcategory.create({ data: { name: 'Express', categoryId: catVIP.id } }); - const subBasic = await db.subcategory.create({ data: { name: 'Basic', categoryId: catStandard.id } }); - const subStarter = await db.subcategory.create({ data: { name: 'Starter', categoryId: catStandard.id } }); - - console.log('Creating users...'); - const userData = [ - { username: 'alice', telegramId: '1001', country: 'Russia', city: 'Moscow', district: 'Center', totalBalance: 150.00, bonusBalance: 25.00, daysAgo: 28 }, - { username: 'bob', telegramId: '1002', country: 'Russia', city: 'Moscow', district: 'Center', totalBalance: 85.50, bonusBalance: 10.00, daysAgo: 25 }, - { username: 'charlie', telegramId: '1003', country: 'Russia', city: 'Saint Petersburg', district: 'North', totalBalance: 320.75, bonusBalance: 50.00, daysAgo: 22 }, - { username: 'diana', telegramId: '1004', country: 'Germany', city: 'Berlin', district: 'Mitte', totalBalance: 45.00, bonusBalance: 5.00, daysAgo: 20 }, - { username: 'evan', telegramId: '1005', country: 'Germany', city: 'Berlin', district: 'Mitte', totalBalance: 0.00, bonusBalance: 0.00, daysAgo: 18 }, - { username: 'frank', telegramId: '1006', country: 'Russia', city: 'Moscow', district: 'Center', totalBalance: 210.00, bonusBalance: 30.00, daysAgo: 15 }, - { username: 'grace', telegramId: '1007', country: 'Russia', city: 'Saint Petersburg', district: 'North', totalBalance: 75.25, bonusBalance: 15.00, daysAgo: 12 }, - { username: 'henry', telegramId: '1008', country: 'Germany', city: 'Berlin', district: 'Mitte', totalBalance: 500.00, bonusBalance: 100.00, daysAgo: 9 }, - { username: 'iris', telegramId: '1009', country: 'Russia', city: 'Moscow', district: 'Center', totalBalance: 0.00, bonusBalance: 0.00, daysAgo: 5 }, - { username: 'jack', telegramId: '1010', country: 'Germany', city: 'Berlin', district: 'Mitte', totalBalance: 33.00, bonusBalance: 5.00, daysAgo: 2 }, - ]; - - const users = []; - for (const u of userData) { - const user = await db.tgUser.create({ - data: { telegramId: u.telegramId, username: u.username, country: u.country, city: u.city, district: u.district, totalBalance: u.totalBalance, bonusBalance: u.bonusBalance, createdAt: daysAgo(u.daysAgo) }, - }); - users.push(user); - } - const userMap = new Map(users.map((u) => [u.username, u])); - - console.log('Creating products...'); - const productsData = [ - { name: 'VPN Subscription 30d', price: 9.99, stock: 100, locationId: locMoscow.id, categoryId: catDigital.id, subcategoryId: subVPN.id }, - { name: 'VPN Subscription 90d', price: 24.99, stock: 50, locationId: locMoscow.id, categoryId: catDigital.id, subcategoryId: subAccounts.id }, - { name: 'USB Drive 64GB', price: 29.99, stock: 25, locationId: locMoscow.id, categoryId: catPhysical.id, subcategoryId: subHardware.id }, - { name: 'Premium Account 1 Year', price: 99.99, stock: 10, locationId: locSPb.id, categoryId: catPremium.id, subcategoryId: subAnnual.id }, - { name: 'VIP Access Lifetime', price: 199.99, stock: 5, locationId: locSPb.id, categoryId: catVIP.id, subcategoryId: subLifetime.id }, - { name: 'Premium Account 6 Months', price: 59.99, stock: 20, locationId: locSPb.id, categoryId: catPremium.id, subcategoryId: subMonthly.id }, - { name: 'Standard Package', price: 14.99, stock: 200, locationId: locBerlin.id, categoryId: catStandard.id, subcategoryId: subBasic.id }, - { name: 'Security Toolkit', price: 49.99, stock: 30, locationId: locMoscow.id, categoryId: catDigital.id, subcategoryId: subSoftware.id }, - { name: 'VIP Express Pass', price: 39.99, stock: 15, locationId: locSPb.id, categoryId: catVIP.id, subcategoryId: subExpress.id }, - { name: 'Starter Kit', price: 4.99, stock: 500, locationId: locBerlin.id, categoryId: catStandard.id, subcategoryId: subStarter.id }, - ]; - const products = []; - for (const p of productsData) { - products.push(await db.product.create({ data: { name: p.name, price: p.price, quantityInStock: p.stock, locationId: p.locationId, categoryId: p.categoryId, subcategoryId: p.subcategoryId } })); - } - - console.log('Creating purchases...'); - const purchaseCount = randInt(25, 30); - const walletTypes = ['BTC', 'ETH', 'LTC', 'USDT', 'USDC']; - for (let i = 0; i < purchaseCount; i++) { - const user = users[i % users.length]; - const product = products[i % products.length]; - const qty = randInt(1, 3); - const r = Math.random(); - const status = r < 0.7 ? 'completed' : r < 0.9 ? 'pending' : 'cancelled'; - const txHash = status === 'completed' ? `0x${Array.from({ length: 64 }, () => randInt(0, 15).toString(16)).join('')}` : null; - await db.purchase.create({ data: { userId: user.id, productId: product.id, quantity: qty, totalPrice: parseFloat((product.price * qty).toFixed(2)), walletType: pick(walletTypes), txHash, purchaseDate: daysAgo(randInt(0, 29)), status } }); - } - - console.log('Creating wallets...'); - const walletAssignments = [ - { username: 'alice', type: 'BTC' }, { username: 'alice', type: 'ETH' }, { username: 'bob', type: 'BTC' }, - { username: 'charlie', type: 'LTC' }, { username: 'diana', type: 'ETH' }, { username: 'frank', type: 'XRP' }, - { username: 'grace', type: 'BCH' }, { username: 'henry', type: 'DOGE' }, { username: 'iris', type: 'BTC' }, { username: 'jack', type: 'USDT' }, - ]; - for (const w of walletAssignments) { - const user = userMap.get(w.username)!; - await db.cryptoWallet.create({ data: { userId: user.id, walletType: w.type, address: `0x${Array.from({ length: 40 }, () => randInt(0, 15).toString(16)).join('')}`, balance: randFloat(0.001, 5.0, 8) } }); - } - - console.log('Creating commissions...'); - await db.commissionPayment.create({ data: { totalBalanceUsd: 4825.50, commissionRate: 0.05, commissionAmountUsd: 241.28, paidAmountUsd: 200.00, walletCount: 8, note: 'Monthly commission — June', createdAt: daysAgo(15) } }); - await db.commissionPayment.create({ data: { totalBalanceUsd: 5310.75, commissionRate: 0.05, commissionAmountUsd: 265.54, paidAmountUsd: 241.28, walletCount: 10, note: 'Monthly commission — July', createdAt: daysAgo(3) } }); - - console.log('Creating audit logs...'); - const auditEntries = [ - { action: 'login', adminId: 'admin_1', details: JSON.stringify({ ip: '192.168.1.1', method: 'password' }), daysAgo: 30 }, - { action: 'seed_demo', adminId: 'admin_1', details: JSON.stringify({ message: 'Initial seed' }), daysAgo: 29 }, - { action: 'login', adminId: 'admin_2', details: JSON.stringify({ ip: '10.0.0.5', method: 'password' }), daysAgo: 27 }, - { action: 'balance_adjust', adminId: 'admin_1', details: JSON.stringify({ userId: 1, field: 'total_balance', old: 0, new: 150.00, reason: 'deposit' }), daysAgo: 25 }, - { action: 'status_toggle', adminId: 'admin_2', details: JSON.stringify({ userId: 5, oldStatus: 0, newStatus: 2 }), daysAgo: 22 }, - { action: 'login', adminId: 'admin_1', details: JSON.stringify({ ip: '172.16.0.1', method: 'token' }), daysAgo: 20 }, - { action: 'balance_adjust', adminId: 'admin_1', details: JSON.stringify({ userId: 3, field: 'bonus_balance', old: 25.00, new: 50.00, reason: 'bonus' }), daysAgo: 18 }, - { action: 'seed_phrase_viewed', adminId: 'admin_1', details: JSON.stringify({ walletCount: 10 }), daysAgo: 15 }, - { action: 'login', adminId: 'admin_2', details: JSON.stringify({ ip: '192.168.1.50', method: 'password' }), daysAgo: 12 }, - { action: 'csv_seed_export', adminId: 'admin_1', details: JSON.stringify({ walletCount: 10, filename: 'seeds_export.csv' }), daysAgo: 10 }, - { action: 'balance_adjust', adminId: 'admin_2', details: JSON.stringify({ userId: 8, field: 'total_balance', old: 300.00, new: 500.00, reason: 'deposit' }), daysAgo: 7 }, - { action: 'status_toggle', adminId: 'admin_1', details: JSON.stringify({ userId: 9, oldStatus: 0, newStatus: 2 }), daysAgo: 4 }, - ]; - for (const entry of auditEntries) { - await db.auditLog.create({ data: { action: entry.action, adminId: entry.adminId, details: entry.details, createdAt: daysAgo(entry.daysAgo) } }); - } - - console.log('✅ Seed complete!'); -} - -main().catch(console.error).finally(() => db.$disconnect()); diff --git a/src/app/api/admin/chatbot/route.ts b/src/app/api/admin/chatbot/route.ts new file mode 100644 index 0000000..7ac87fc --- /dev/null +++ b/src/app/api/admin/chatbot/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; +import { Prisma } from '@prisma/client'; +import { resetCacheTimestamp } from '@/lib/chatbot-config'; + +const CHATBOT_KEYS = [ + 'chatbot_enabled', + 'chatbot_sleep_mode', + 'chatbot_sleep_message', + 'chatbot_system_prompt', + 'chatbot_welcome_message', + 'chatbot_temperature', + 'chatbot_max_tokens', + 'chatbot_max_history', + 'chatbot_knowledge_base', + 'chatbot_provider', + 'chatbot_api_endpoint', + 'chatbot_api_key', +] as const; + +const DEFAULTS: Record = { + chatbot_enabled: 'false', + chatbot_sleep_mode: 'false', + chatbot_sleep_message: 'Мы сейчас не можем ответить. Напишите нам позже, пожалуйста.', + chatbot_system_prompt: + 'Ты — дружелюбный ассистент интернет-магазина. Отвечай на вопросы клиентов о товарах, ценах, доставке. Будь вежливым и полезным.', + chatbot_welcome_message: 'Здравствуйте! Чем могу помочь?', + chatbot_temperature: '0.7', + chatbot_max_tokens: '1024', + chatbot_max_history: '20', + chatbot_knowledge_base: '', + chatbot_provider: 'openai', + chatbot_api_endpoint: '', + chatbot_api_key: '', +}; + +function maskApiKey(value: string): string { + if (!value || value.length < 8) return '••••••••'; + return value.slice(0, 5) + '****' + value.slice(-4); +} + +export async function GET(_request: NextRequest) { + const auth = getAuth(_request); + if ('status' in auth) return auth; + + try { + const rows = await db.siteSetting.findMany({ + where: { key: { in: [...CHATBOT_KEYS] } }, + }); + + const settings: Record = {}; + for (const key of CHATBOT_KEYS) { + const row = rows.find((r) => r.key === key); + settings[key] = row ? row.value : DEFAULTS[key]; + } + + // Mask API key + if (settings.chatbot_api_key && !settings.chatbot_api_key.includes('****')) { + settings.chatbot_api_key = maskApiKey(settings.chatbot_api_key); + } + + return NextResponse.json({ settings }); + } catch (error) { + console.error('Chatbot settings GET error:', error); + return NextResponse.json({ error: 'Failed to load chatbot settings' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const body = await request.json(); + const updates: Record = body; + + // Validate temperature + if (updates.chatbot_temperature !== undefined) { + const temp = parseFloat(updates.chatbot_temperature); + if (isNaN(temp) || temp < 0 || temp > 2) { + return NextResponse.json( + { error: 'chatbot_temperature must be between 0 and 2' }, + { status: 400 }, + ); + } + } + + // Validate max_tokens + if (updates.chatbot_max_tokens !== undefined) { + const tokens = parseInt(updates.chatbot_max_tokens, 10); + if (isNaN(tokens) || tokens < 50 || tokens > 4000) { + return NextResponse.json( + { error: 'chatbot_max_tokens must be between 50 and 4000' }, + { status: 400 }, + ); + } + } + + // Validate max_history + if (updates.chatbot_max_history !== undefined) { + const history = parseInt(updates.chatbot_max_history, 10); + if (isNaN(history) || history < 1 || history > 50) { + return NextResponse.json( + { error: 'chatbot_max_history must be between 1 and 50' }, + { status: 400 }, + ); + } + } + + // Validate provider + if (updates.chatbot_provider !== undefined) { + const validProviders = ['openai', 'deepseek', 'openrouter', 'ollama', 'custom']; + if (!validProviders.includes(updates.chatbot_provider)) { + return NextResponse.json( + { error: 'chatbot_provider must be one of: openai, deepseek, openrouter, ollama, custom' }, + { status: 400 }, + ); + } + } + + // Upsert each setting in transaction, skip masked values + const operations: Prisma.PrismaPromise[] = []; + for (const key of CHATBOT_KEYS) { + if (!(key in updates)) continue; + const value = String(updates[key]); + if (value.includes('****')) continue; + operations.push( + db.siteSetting.upsert({ + where: { key }, + update: { value, updatedAt: new Date() }, + create: { key, value }, + }), + ); + } + + if (operations.length > 0) { + await db.$transaction(operations); + } + + // Clear cache + resetCacheTimestamp(); + + return NextResponse.json({ ok: true, message: 'Chatbot settings updated' }); + } catch (error) { + console.error('Chatbot settings PUT error:', error); + return NextResponse.json({ error: 'Failed to save chatbot settings' }, { status: 500 }); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 0000000..b9db9ce --- /dev/null +++ b/src/app/api/chat/route.ts @@ -0,0 +1,367 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getChatbotConfig } from '@/lib/chatbot-config'; + +const DEFAULTS: Record = { + chatbot_enabled: 'false', + chatbot_sleep_mode: 'false', + chatbot_sleep_message: 'Извините, мы сейчас не доступны. Напишите позже, пожалуйста.', + chatbot_system_prompt: + 'Ты — дружелюбный ассистент интернет-магазина. Отвечай на вопросы клиентов о товарах, ценах, доставке. Будь вежливым и полезным.', + chatbot_temperature: '0.7', + chatbot_max_tokens: '1024', + chatbot_max_history: '20', + chatbot_knowledge_base: '', + chatbot_api_key: '', +}; + +interface ChatMessage { + role: string; + content: string; + timestamp?: string; +} + +function getConfig(config: Record, key: string, fallback: string): string { + return config[key] || fallback; +} + +// Extract lead data from message history using regex +function extractLeadData(messages: ChatMessage[]): { + name?: string; + phone?: string; + email?: string; + telegram?: string; +} { + const result: { name?: string; phone?: string; email?: string; telegram?: string } = {}; + const allText = messages.map((m) => m.content).join(' '); + + // Phone: various formats + const phoneMatch = allText.match( + /(?:\+?\d[\s\-\(]?){7,}\d|\+?\d{1,3}[\s\-]?\(?\d{2,4}\)?[\s\-]?\d{2,4}[\s\-]?\d{2,4}/, + ); + if (phoneMatch) { + result.phone = phoneMatch[0].replace(/\s+/g, ' ').trim(); + } + + // Email + const emailMatch = allText.match(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/); + if (emailMatch) { + result.email = emailMatch[0]; + } + + // Telegram handle + const tgMatch = allText.match(/@(?:[a-zA-Z][a-zA-Z0-9_]{3,30})/); + if (tgMatch) { + result.telegram = tgMatch[0]; + } + + // Name patterns — "меня зовут X", "я X", "это X" + const namePatterns = [ + /(?:меня зовут|зовут меня|я\s+—?\s*|это\s+)([А-ЯЁA-Z][а-яёa-z]+(?:\s+[А-ЯЁA-Z][а-яёa-z]+){0,2})/, + /(?:my name is|i am|i\'m)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})/i, + ]; + for (const pat of namePatterns) { + const m = allText.match(pat); + if (m && m[1] && m[1].length > 2 && m[1].length < 50) { + result.name = m[1].trim(); + break; + } + } + + return result; +} + +// Simple customer profile generation +function generateCustomerProfile(messages: ChatMessage[]): string { + const totalMessages = messages.length; + const userMessages = messages.filter((m) => m.role === 'user'); + const lastFew = userMessages.slice(-5).map((m) => m.content); + const allText = lastFew.join(' ').toLowerCase(); + + // Intent detection + let intent = 'general_inquiry'; + if (/цен[аыуе]|стоимость|price|how much|сколько/.test(allText)) intent = 'price_inquiry'; + else if (/доставк|shipping|достав/.test(allText)) intent = 'delivery_inquiry'; + else if (/купи[ть|л[аи]]|заказ|order|buy|покупк/.test(allText)) intent = 'purchase_intent'; + else if (/помощь|help|поддержк|support/.test(allText)) intent = 'support_request'; + else if (/отзыв|review|проблем|баг|не работ/.test(allText)) intent = 'complaint'; + + // Interest detection + const interests: string[] = []; + if (/биткоин|bitcoin|btc/.test(allText)) interests.push('Bitcoin'); + if (/ethereum|eth/.test(allText)) interests.push('Ethereum'); + if (/litecoin|ltc/.test(allText)) interests.push('Litecoin'); + if (/usdt|tether/.test(allText)) interests.push('USDT'); + if (/кошел[ьеьк]|wallet/.test(allText)) interests.push('Wallets'); + + // Sentiment (very simple) + const positiveWords = /спасибо|thanks|отлично|хорошо|great|good|класс|круто/; + const negativeWords = /плох|бед|ужас|термин|проблем|ошибк|не работает|bad|awful/; + let sentiment: string; + if (negativeWords.test(allText)) sentiment = 'negative'; + else if (positiveWords.test(allText)) sentiment = 'positive'; + else sentiment = 'neutral'; + + // Readiness + let readiness: string; + if (intent === 'purchase_intent') readiness = 'hot'; + else if (intent === 'price_inquiry') readiness = 'warm'; + else readiness = 'cold'; + + const profile = { + intent, + interests: interests.length > 0 ? interests : undefined, + sentiment, + readiness, + messageCount: totalMessages, + summary: `User has exchanged ${totalMessages} messages. ${readiness === 'hot' ? 'Shows purchase intent.' : readiness === 'warm' ? 'Interested in pricing.' : 'General engagement.'}`, + }; + + return JSON.stringify(profile); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { sessionId, message, telegramId } = body as { + sessionId: string; + message: string; + telegramId?: string; + }; + + if (!sessionId || !message) { + return NextResponse.json({ error: 'Missing sessionId or message' }, { status: 400 }); + } + + // Load chatbot config with cache + const config = await getChatbotConfig(); + const enabled = getConfig(config, 'chatbot_enabled', 'false'); + if (enabled !== 'true') { + return NextResponse.json({ error: 'Chatbot is disabled' }, { status: 503 }); + } + + // Find or create ChatSession + let session = await db.chatSession.findUnique({ + where: { sessionId }, + }); + + let existingMessages: ChatMessage[] = []; + + if (session) { + try { + existingMessages = JSON.parse(session.messages); + } catch { + existingMessages = []; + } + } else { + session = await db.chatSession.create({ + data: { + sessionId, + telegramId: telegramId || null, + messages: JSON.stringify([]), + isActive: true, + }, + }); + } + + // Don't auto-reply if operator is connected + if (session.autoReplyDisabled) { + return NextResponse.json({ + reply: '', + sessionId: session.sessionId, + leadId: session.leadId, + operatorConnected: true, + }); + } + + // Add user message + const userMsg: ChatMessage = { + role: 'user', + content: message, + timestamp: new Date().toISOString(), + }; + existingMessages.push(userMsg); + + // Build system prompt + const systemPrompt = getConfig(config, 'chatbot_system_prompt', DEFAULTS.chatbot_system_prompt); + const knowledgeBase = getConfig(config, 'chatbot_knowledge_base', ''); + const sleepMode = getConfig(config, 'chatbot_sleep_mode', 'false'); + const sleepMessage = getConfig(config, 'chatbot_sleep_message', DEFAULTS.chatbot_sleep_message); + const temperature = parseFloat(getConfig(config, 'chatbot_temperature', '0.7')); + const maxTokens = parseInt(getConfig(config, 'chatbot_max_tokens', '1024'), 10); + const maxHistory = parseInt(getConfig(config, 'chatbot_max_history', '20'), 10); + + let fullSystemPrompt = systemPrompt; + + if (knowledgeBase) { + fullSystemPrompt += '\n\n--- База знаний ---\n' + knowledgeBase; + } + + // Customer profile context + if (session.customerProfile) { + try { + const profile = JSON.parse(session.customerProfile); + const profileStr = Object.entries(profile) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + if (profileStr) { + fullSystemPrompt += '\n\n--- Профиль клиента ---\n' + profileStr; + } + } catch { + // ignore parse error + } + } + + // Catalog context — fetch some products for context + try { + const products = await db.product.findMany({ + where: { quantityInStock: { gt: 0 } }, + select: { name: true, price: true, category: { select: { name: true } } }, + take: 30, + }); + if (products.length > 0) { + const catalogStr = products + .map((p) => `- ${p.name} (${p.category.name}): $${p.price}`) + .join('\n'); + fullSystemPrompt += '\n\n--- Каталог товаров ---\n' + catalogStr; + } + } catch { + // ignore catalog fetch errors + } + + // Sleep mode + if (sleepMode === 'true') { + fullSystemPrompt = + `ВНИМАНИЕ: Режим сна включён. Тебе нужно отвечать ТОЛЬКО следующим сообщением: "${sleepMessage}". Не отвечай ни на какие вопросы, просто отправляй это сообщение.\n\n` + + fullSystemPrompt; + } + + // Build history messages (limited) + const historySlice = existingMessages.slice(-(maxHistory * 2)); + const historyMessages = historySlice.map((m) => ({ + role: m.role, + content: m.content, + })); + + const apiMessages = [ + { role: 'system', content: fullSystemPrompt }, + ...historyMessages, + ]; + + // Call LLM via z-ai-web-dev-sdk + let reply: string; + try { + const { ZAI } = await import('z-ai-web-dev-sdk'); + const zai = await ZAI.create(); + const result = await zai.functions.invoke('llm_chat', { + messages: apiMessages, + temperature, + max_tokens: maxTokens, + }); + reply = result?.content || result?.reply || result?.text || 'Извините, не удалось получить ответ.'; + if (typeof reply !== 'string') { + reply = JSON.stringify(reply); + } + } catch (llmError) { + console.error('LLM call failed:', llmError); + reply = sleepMode === 'true' + ? sleepMessage + : 'Извините, произошла техническая ошибка. Попробуйте написать позже.'; + } + + // Save assistant reply + const assistantMsg: ChatMessage = { + role: 'assistant', + content: reply, + timestamp: new Date().toISOString(), + }; + existingMessages.push(assistantMsg); + + // Extract lead data + const allMessages = [...existingMessages]; + const leadData = extractLeadData(allMessages); + + // Generate customer profile + const profile = generateCustomerProfile(allMessages); + + // Update session + await db.chatSession.update({ + where: { id: session.id }, + data: { + messages: JSON.stringify(existingMessages), + customerProfile: profile, + updatedAt: new Date(), + }, + }); + + // Auto-create or update Lead + let leadId = session.leadId; + + if (leadData.name || leadData.phone || leadData.email || leadData.telegram || telegramId) { + // Try to find existing lead by telegramId first + let lead = telegramId + ? await db.lead.findUnique({ where: { telegramId } }) + : null; + + if (!lead && leadId) { + lead = await db.lead.findUnique({ where: { id: leadId } }); + } + + if (lead) { + // Update existing lead + const updateData: Record = { updatedAt: new Date() }; + if (leadData.name && !lead.name) updateData.name = leadData.name; + if (leadData.phone && !lead.phone) updateData.phone = leadData.phone; + if (leadData.email && !lead.email) updateData.email = leadData.email; + if (leadData.telegram && !lead.telegram) updateData.telegram = leadData.telegram; + if (telegramId && !lead.telegramId) updateData.telegramId = telegramId; + + await db.lead.update({ + where: { id: lead.id }, + data: updateData, + }); + leadId = lead.id; + } else { + // Create new lead + const newLead = await db.lead.create({ + data: { + telegramId: telegramId || null, + name: leadData.name || null, + phone: leadData.phone || null, + email: leadData.email || null, + telegram: leadData.telegram || null, + status: 'new', + }, + }); + leadId = newLead.id; + + // Link session to lead + await db.chatSession.update({ + where: { id: session.id }, + data: { leadId: newLead.id }, + }); + } + } else if (!leadId && session.leadId) { + leadId = session.leadId; + } + + // Parse profile for response + let parsedProfile; + try { + parsedProfile = JSON.parse(profile); + } catch { + parsedProfile = null; + } + + return NextResponse.json({ + reply, + sessionId: session.sessionId, + leadId: leadId || undefined, + profile: parsedProfile, + }); + } catch (error) { + console.error('Chat API error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/leads/[id]/route.ts b/src/app/api/leads/[id]/route.ts new file mode 100644 index 0000000..ba438ff --- /dev/null +++ b/src/app/api/leads/[id]/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = getAuth(_request); + if ('status' in auth) return auth; + + try { + const { id } = await params; + const leadId = parseInt(id, 10); + if (isNaN(leadId)) { + return NextResponse.json({ error: 'Invalid lead ID' }, { status: 400 }); + } + + const lead = await db.lead.findUnique({ + where: { id: leadId }, + include: { + chatSessions: { + select: { + id: true, + sessionId: true, + isActive: true, + createdAt: true, + customerProfile: true, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!lead) { + return NextResponse.json({ error: 'Lead not found' }, { status: 404 }); + } + + return NextResponse.json({ lead }); + } catch (error) { + console.error('Lead GET error:', error); + return NextResponse.json({ error: 'Failed to load lead' }, { status: 500 }); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const { id } = await params; + const leadId = parseInt(id, 10); + if (isNaN(leadId)) { + return NextResponse.json({ error: 'Invalid lead ID' }, { status: 400 }); + } + + const body = await request.json(); + const { status, notes, customFields } = body; + + const existing = await db.lead.findUnique({ where: { id: leadId } }); + if (!existing) { + return NextResponse.json({ error: 'Lead not found' }, { status: 404 }); + } + + const validStatuses = ['new', 'contacted', 'qualified', 'lost', 'spam']; + const updateData: Record = { updatedAt: new Date() }; + + if (status !== undefined) { + if (!validStatuses.includes(status)) { + return NextResponse.json( + { error: `Status must be one of: ${validStatuses.join(', ')}` }, + { status: 400 }, + ); + } + updateData.status = status; + } + + if (notes !== undefined) { + updateData.notes = notes; + } + + if (customFields !== undefined) { + updateData.customFields = + typeof customFields === 'string' ? customFields : JSON.stringify(customFields); + } + + const lead = await db.lead.update({ + where: { id: leadId }, + data: updateData, + }); + + // Audit log + await db.auditLog.create({ + data: { + action: 'lead_update', + adminId: auth.role || 'unknown', + details: JSON.stringify({ + leadId, + changes: body, + }), + }, + }); + + return NextResponse.json({ lead }); + } catch (error) { + console.error('Lead PUT error:', error); + return NextResponse.json({ error: 'Failed to update lead' }, { status: 500 }); + } +} diff --git a/src/app/api/leads/[id]/sessions/route.ts b/src/app/api/leads/[id]/sessions/route.ts new file mode 100644 index 0000000..8361161 --- /dev/null +++ b/src/app/api/leads/[id]/sessions/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; + +interface ChatMessage { + role: string; + content: string; + timestamp?: string; +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = getAuth(_request); + if ('status' in auth) return auth; + + try { + const { id } = await params; + const leadId = parseInt(id, 10); + if (isNaN(leadId)) { + return NextResponse.json({ error: 'Invalid lead ID' }, { status: 400 }); + } + + const lead = await db.lead.findUnique({ where: { id: leadId } }); + if (!lead) { + return NextResponse.json({ error: 'Lead not found' }, { status: 404 }); + } + + const sessions = await db.chatSession.findMany({ + where: { leadId }, + orderBy: { createdAt: 'desc' }, + }); + + // Parse messages JSON for each session + const sessionsWithMessages = sessions.map((session) => { + let messages: ChatMessage[] = []; + try { + messages = JSON.parse(session.messages); + } catch { + messages = []; + } + + return { + id: session.id, + sessionId: session.sessionId, + telegramId: session.telegramId, + isActive: session.isActive, + operatorName: session.operatorName, + autoReplyDisabled: session.autoReplyDisabled, + operatorConnectedAt: session.operatorConnectedAt, + customerProfile: session.customerProfile, + device: session.device, + ip: session.ip, + country: session.country, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + messages, + }; + }); + + return NextResponse.json({ sessions: sessionsWithMessages }); + } catch (error) { + console.error('Lead sessions GET error:', error); + return NextResponse.json({ error: 'Failed to load sessions' }, { status: 500 }); + } +} diff --git a/src/app/api/leads/bulk/route.ts b/src/app/api/leads/bulk/route.ts new file mode 100644 index 0000000..a14706d --- /dev/null +++ b/src/app/api/leads/bulk/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; +import { Prisma } from '@prisma/client'; + +export async function GET(request: NextRequest) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const { searchParams } = request.nextUrl; + const search = searchParams.get('search') || ''; + const statusParam = searchParams.get('status'); + const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '50', 10) || 50)); + + const where: Prisma.LeadWhereInput = {}; + + if (search) { + where.OR = [ + { name: { contains: search } }, + { phone: { contains: search } }, + { email: { contains: search } }, + { telegram: { contains: search } }, + { telegramId: { contains: search } }, + ]; + } + + if (statusParam !== null && statusParam !== '') { + where.status = statusParam; + } + + const [total, leads] = await Promise.all([ + db.lead.count({ where }), + db.lead.findMany({ + where, + include: { + _count: { + select: { chatSessions: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + ]); + + const totalPages = Math.max(1, Math.ceil(total / limit)); + + return NextResponse.json({ leads, total, page, totalPages }); + } catch (error) { + console.error('Leads bulk API error:', error); + return NextResponse.json({ error: 'Failed to load leads' }, { status: 500 }); + } +} diff --git a/src/app/api/operator/route.ts b/src/app/api/operator/route.ts new file mode 100644 index 0000000..0ea585c --- /dev/null +++ b/src/app/api/operator/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; + +export async function POST(request: NextRequest) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const body = await request.json(); + const { sessionId, action, operatorName } = body as { + sessionId: string; + action: 'connect' | 'disconnect'; + operatorName: string; + }; + + if (!sessionId || !action || !operatorName) { + return NextResponse.json( + { error: 'Missing sessionId, action, or operatorName' }, + { status: 400 }, + ); + } + + if (action !== 'connect' && action !== 'disconnect') { + return NextResponse.json( + { error: 'Action must be "connect" or "disconnect"' }, + { status: 400 }, + ); + } + + const session = await db.chatSession.findUnique({ where: { sessionId } }); + if (!session) { + return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + } + + let updated; + + if (action === 'connect') { + updated = await db.chatSession.update({ + where: { sessionId }, + data: { + autoReplyDisabled: true, + operatorName, + operatorConnectedAt: new Date(), + updatedAt: new Date(), + }, + }); + + // Audit log + await db.auditLog.create({ + data: { + action: 'operator_connect', + adminId: auth.role || 'unknown', + details: JSON.stringify({ sessionId, operatorName }), + }, + }); + } else { + updated = await db.chatSession.update({ + where: { sessionId }, + data: { + autoReplyDisabled: false, + operatorName: null, + operatorConnectedAt: null, + updatedAt: new Date(), + }, + }); + + // Audit log + await db.auditLog.create({ + data: { + action: 'operator_disconnect', + adminId: auth.role || 'unknown', + details: JSON.stringify({ sessionId }), + }, + }); + } + + return NextResponse.json({ + ok: true, + session: { + sessionId: updated.sessionId, + autoReplyDisabled: updated.autoReplyDisabled, + operatorName: updated.operatorName, + operatorConnectedAt: updated.operatorConnectedAt, + }, + }); + } catch (error) { + console.error('Operator API error:', error); + return NextResponse.json({ error: 'Failed to process operator action' }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 3a6386b..67354bd 100755 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -536,3 +536,36 @@ select:focus-visible { background-color: oklch(1 0 0 / 5%); box-shadow: inset 2px 0 0 var(--primary); } + +/* ─── Matrix Rain Background (Login) ─── */ +.matrix-rain { + position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; +} +.matrix-rain::before { + content: 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + position: absolute; top: -100%; left: 0; right: 0; + font-family: monospace; font-size: 14px; line-height: 1.6; + color: #00ff41; opacity: 0.04; word-break: break-all; + animation: matrixFall 25s linear infinite; + text-shadow: 0 0 8px rgba(0,255,65,0.3); +} +@keyframes matrixFall { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100vh); } +} +.matrix-rain::after { + content: 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789'; + position: absolute; top: -100%; left: 30%; right: 0; + font-family: monospace; font-size: 11px; line-height: 2; + color: #00ff41; opacity: 0.025; word-break: break-all; + animation: matrixFall2 35s linear infinite; + animation-delay: -12s; + text-shadow: 0 0 6px rgba(0,255,65,0.2); +} +@keyframes matrixFall2 { + 0% { transform: translateY(-100%) translateX(10%); } + 100% { transform: translateY(100vh) translateX(-10%); } +} +.dark .matrix-rain::before, .dark .matrix-rain::after { + opacity: 0.06; color: #00ff41; +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 93dce41..a97a785 100755 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -56,6 +56,7 @@ export function LoginPage() { return (
+
{/* Background gradient mesh */}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 8abe541..4fcfdef 100755 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -18,6 +18,8 @@ import { LocationsPage } from "@/components/locations/locations-page"; import { SettingsPage } from "@/components/settings/settings-page"; import { LocalesPage } from "@/components/locales/locales-page"; import { SeedPage } from "@/components/seed/seed-page"; +import { ChatbotSettingsPage } from "@/components/chatbot/chatbot-settings-page"; +import { LeadsPage } from "@/components/leads/leads-page"; import { ErrorBoundary } from "@/components/shared/error-boundary"; import { AdminFooter } from "@/components/layout/admin-footer"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; @@ -110,6 +112,8 @@ export default function AppPage() { if (page === "/locations") return ; if (page === "/settings") return ; if (page === "/locales") return ; + if (page === "/chatbot") return ; + if (page === "/leads") return ; if (page === "/seed") return ; return (
diff --git a/src/components/chatbot/chatbot-settings-page.tsx b/src/components/chatbot/chatbot-settings-page.tsx new file mode 100644 index 0000000..cba6c4f --- /dev/null +++ b/src/components/chatbot/chatbot-settings-page.tsx @@ -0,0 +1,513 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Bot, + Brain, + Thermometer, + BookOpen, + Settings2, + MessageSquare, + Shield, + Sparkles, + Save, + Moon, + Zap, + Database, + KeyRound, + Loader2, +} from "lucide-react"; +import { toast } from "sonner"; + +interface ChatbotSettings { + chatbot_enabled: boolean; + chatbot_sleep_mode: boolean; + chatbot_sleep_message: string; + chatbot_system_prompt: string; + chatbot_welcome_message: string; + chatbot_temperature: number; + chatbot_max_tokens: number; + chatbot_max_history: number; + chatbot_knowledge_base: string; + chatbot_provider: string; + chatbot_api_endpoint: string; + chatbot_api_key: string; +} + +const DEFAULTS: ChatbotSettings = { + chatbot_enabled: true, + chatbot_sleep_mode: false, + chatbot_sleep_message: + "Магазин сейчас пополняется товаром. Как только мы откроемся — я сразу вам сообщу! Можете оставить контакты и я свяжусь с вами при открытии.", + chatbot_system_prompt: + "Ты — AI-ассистент Telegram магазина цифровых товаров. Отвечай дружелюбно на русском. Помогай клиентам с выбором товаров. Если не знаешь ответ — честно скажи.", + chatbot_welcome_message: "", + chatbot_temperature: 0.7, + chatbot_max_tokens: 1000, + chatbot_max_history: 20, + chatbot_knowledge_base: "", + chatbot_provider: "openai", + chatbot_api_endpoint: "", + chatbot_api_key: "", +}; + +function SettingsSkeleton() { + return ( +
+ + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ); +} + +export function ChatbotSettingsPage() { + const [settings, setSettings] = useState(DEFAULTS); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const loadSettings = useCallback(async () => { + try { + const res = await fetch("/api/admin/chatbot"); + if (res.ok) { + const data = await res.json(); + setSettings({ ...DEFAULTS, ...data }); + } + } catch { + toast.error("Ошибка загрузки настроек"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadSettings(); + }, [loadSettings]); + + const update = ( + key: K, + value: ChatbotSettings[K] + ) => { + setSettings((prev) => ({ ...prev, [key]: value })); + }; + + const save = async () => { + setSaving(true); + try { + const res = await fetch("/api/admin/chatbot", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + if (res.ok) { + toast.success("Настройки сохранены"); + } else { + const data = await res.json(); + toast.error(data.error || "Ошибка сохранения"); + } + } catch { + toast.error("Ошибка соединения"); + } finally { + setSaving(false); + } + }; + + const SaveButton = () => ( +
+ +
+ ); + + if (loading) return ; + + return ( +
+
+
+ +
+
+

AI Чат-бот

+

+ Настройки ИИ-ассистента Telegram магазина +

+
+ + + {settings.chatbot_enabled ? "Активен" : "Выключен"} + +
+ + + + + + Общие + + + + Параметры ИИ + + + + База знаний + + + + Провайдер + + + + {/* ── Tab 1: General ── */} + + + + + + Основные настройки + + + +
+
+ +

+ Включает или отключает автоматический ответчик +

+
+ update("chatbot_enabled", v)} + /> +
+ +
+
+ +

+ При включении /start показывает ИИ-чат вместо каталога. Бот + сообщает что магазин пополняется. +

+
+ update("chatbot_sleep_mode", v)} + /> +
+
+
+ +
+
+ + + + + + Сообщения + + + +
+ +