From 130c5382cdcc85100033f49fe1bea773d238fe32 Mon Sep 17 00:00:00 2001 From: ogrechko Date: Tue, 25 Aug 2026 23:12:16 +0300 Subject: [PATCH] Initial commit: TopSysOps site + admin panel Multi-page public site (Next.js), Node/Express backend with Postgres, and Vite admin/account app, restructured from a single-page layout into dedicated service, promotions, cases, about, FAQ, and blog pages for SEO. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013otXCiTZsxdZ4SJKZ9wUky --- .env.example | 76 +++ .gitignore | 4 + README.md | 390 +++++++++++++++ admin-app/.dockerignore | 3 + admin-app/Caddyfile | 28 ++ admin-app/Dockerfile | 14 + admin-app/index.html | 18 + admin-app/package.json | 26 + admin-app/postcss.config.js | 6 + admin-app/src/App.jsx | 57 +++ admin-app/src/account/AccountLayout.jsx | 96 ++++ admin-app/src/account/AiChat.jsx | 128 +++++ admin-app/src/account/Login.jsx | 75 +++ admin-app/src/account/Messages.jsx | 87 ++++ admin-app/src/account/Overview.jsx | 58 +++ admin-app/src/account/Profile.jsx | 128 +++++ admin-app/src/account/Register.jsx | 100 ++++ admin-app/src/admin/AdminLayout.jsx | 98 ++++ admin-app/src/admin/AiAssistant.jsx | 135 +++++ admin-app/src/admin/BlogEditor.jsx | 198 ++++++++ admin-app/src/admin/CasesEditor.jsx | 159 ++++++ admin-app/src/admin/ContactEditor.jsx | 92 ++++ admin-app/src/admin/CustomersManager.jsx | 134 +++++ admin-app/src/admin/Dashboard.jsx | 97 ++++ admin-app/src/admin/FaqEditor.jsx | 132 +++++ admin-app/src/admin/Login.jsx | 65 +++ admin-app/src/admin/PromotionsEditor.jsx | 181 +++++++ admin-app/src/admin/ReviewsManager.jsx | 116 +++++ admin-app/src/admin/ServicesEditor.jsx | 325 ++++++++++++ admin-app/src/admin/Settings.jsx | 213 ++++++++ admin-app/src/components/StatusDot.jsx | 23 + admin-app/src/context/ThemeContext.jsx | 32 ++ admin-app/src/index.css | 20 + admin-app/src/lib/api.js | 124 +++++ admin-app/src/lib/icons.js | 7 + admin-app/src/main.jsx | 16 + admin-app/tailwind.config.js | 15 + admin-app/vite.config.js | 14 + backend/.dockerignore | 3 + backend/Dockerfile | 12 + backend/migrations/001_init.sql | 55 +++ backend/migrations/002_cases_faq.sql | 18 + backend/migrations/003_customers.sql | 19 + backend/migrations/004_contact.sql | 9 + backend/migrations/005_blog.sql | 13 + .../006_update_services_2026_07.sql | 55 +++ backend/migrations/007_landing_tier.sql | 20 + ...008_disambiguate_mail_and_case_metrics.sql | 38 ++ backend/migrations/009_seed_blog_articles.sql | 140 ++++++ backend/package.json | 20 + backend/scripts/2026-07-update-services.sql | 55 +++ backend/src/auth.js | 38 ++ backend/src/customerAuth.js | 34 ++ backend/src/db.js | 11 + backend/src/index.js | 106 ++++ backend/src/migrate.js | 37 ++ backend/src/routes/account.js | 105 ++++ backend/src/routes/admin.js | 465 ++++++++++++++++++ backend/src/routes/auth.js | 59 +++ backend/src/routes/customerAuth.js | 100 ++++ backend/src/routes/public.js | 109 ++++ backend/src/sanitizeHtml.js | 17 + backend/src/seed.js | 167 +++++++ docker-compose.yml | 79 +++ public-site/.dockerignore | 3 + public-site/Dockerfile | 41 ++ public-site/app/aktsii/page.jsx | 22 + public-site/app/blog/[slug]/page.jsx | 60 +++ public-site/app/blog/page.jsx | 51 ++ public-site/app/faq/page.jsx | 38 ++ public-site/app/globals.css | 16 + public-site/app/icon.svg | 5 + public-site/app/keysy/page.jsx | 22 + public-site/app/kontakty/page.jsx | 21 + public-site/app/layout.jsx | 44 ++ public-site/app/o-kompanii/page.jsx | 141 ++++++ public-site/app/otzyvy/page.jsx | 21 + public-site/app/page.jsx | 98 ++++ public-site/app/robots.js | 12 + public-site/app/sitemap.js | 30 ++ public-site/app/uslugi/[slug]/page.jsx | 91 ++++ public-site/app/uslugi/page.jsx | 22 + public-site/components/Footer.jsx | 32 ++ public-site/components/Header.jsx | 88 ++++ public-site/components/Reveal.jsx | 56 +++ public-site/components/SectionHeading.jsx | 11 + public-site/components/StatusDot.jsx | 23 + public-site/components/SupportChat.jsx | 53 ++ public-site/components/ThemeProvider.jsx | 35 ++ public-site/components/VisitTracker.jsx | 11 + .../components/sections/AccountPromo.jsx | 45 ++ public-site/components/sections/Cases.jsx | 50 ++ public-site/components/sections/Contact.jsx | 73 +++ public-site/components/sections/Faq.jsx | 49 ++ public-site/components/sections/Hero.jsx | 77 +++ .../components/sections/Promotions.jsx | 68 +++ .../components/sections/ReviewForm.jsx | 97 ++++ public-site/components/sections/Reviews.jsx | 57 +++ public-site/components/sections/Services.jsx | 88 ++++ public-site/lib/clientApi.js | 26 + public-site/lib/icons.js | 7 + public-site/lib/serverApi.js | 26 + public-site/lib/serviceLandingCopy.js | 112 +++++ public-site/next.config.mjs | 7 + public-site/package.json | 22 + public-site/postcss.config.js | 6 + public-site/public/.gitkeep | 0 public-site/tailwind.config.js | 15 + 108 files changed, 7146 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 admin-app/.dockerignore create mode 100644 admin-app/Caddyfile create mode 100644 admin-app/Dockerfile create mode 100644 admin-app/index.html create mode 100644 admin-app/package.json create mode 100644 admin-app/postcss.config.js create mode 100644 admin-app/src/App.jsx create mode 100644 admin-app/src/account/AccountLayout.jsx create mode 100644 admin-app/src/account/AiChat.jsx create mode 100644 admin-app/src/account/Login.jsx create mode 100644 admin-app/src/account/Messages.jsx create mode 100644 admin-app/src/account/Overview.jsx create mode 100644 admin-app/src/account/Profile.jsx create mode 100644 admin-app/src/account/Register.jsx create mode 100644 admin-app/src/admin/AdminLayout.jsx create mode 100644 admin-app/src/admin/AiAssistant.jsx create mode 100644 admin-app/src/admin/BlogEditor.jsx create mode 100644 admin-app/src/admin/CasesEditor.jsx create mode 100644 admin-app/src/admin/ContactEditor.jsx create mode 100644 admin-app/src/admin/CustomersManager.jsx create mode 100644 admin-app/src/admin/Dashboard.jsx create mode 100644 admin-app/src/admin/FaqEditor.jsx create mode 100644 admin-app/src/admin/Login.jsx create mode 100644 admin-app/src/admin/PromotionsEditor.jsx create mode 100644 admin-app/src/admin/ReviewsManager.jsx create mode 100644 admin-app/src/admin/ServicesEditor.jsx create mode 100644 admin-app/src/admin/Settings.jsx create mode 100644 admin-app/src/components/StatusDot.jsx create mode 100644 admin-app/src/context/ThemeContext.jsx create mode 100644 admin-app/src/index.css create mode 100644 admin-app/src/lib/api.js create mode 100644 admin-app/src/lib/icons.js create mode 100644 admin-app/src/main.jsx create mode 100644 admin-app/tailwind.config.js create mode 100644 admin-app/vite.config.js create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/migrations/001_init.sql create mode 100644 backend/migrations/002_cases_faq.sql create mode 100644 backend/migrations/003_customers.sql create mode 100644 backend/migrations/004_contact.sql create mode 100644 backend/migrations/005_blog.sql create mode 100644 backend/migrations/006_update_services_2026_07.sql create mode 100644 backend/migrations/007_landing_tier.sql create mode 100644 backend/migrations/008_disambiguate_mail_and_case_metrics.sql create mode 100644 backend/migrations/009_seed_blog_articles.sql create mode 100644 backend/package.json create mode 100644 backend/scripts/2026-07-update-services.sql create mode 100644 backend/src/auth.js create mode 100644 backend/src/customerAuth.js create mode 100644 backend/src/db.js create mode 100644 backend/src/index.js create mode 100644 backend/src/migrate.js create mode 100644 backend/src/routes/account.js create mode 100644 backend/src/routes/admin.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/customerAuth.js create mode 100644 backend/src/routes/public.js create mode 100644 backend/src/sanitizeHtml.js create mode 100644 backend/src/seed.js create mode 100644 docker-compose.yml create mode 100644 public-site/.dockerignore create mode 100644 public-site/Dockerfile create mode 100644 public-site/app/aktsii/page.jsx create mode 100644 public-site/app/blog/[slug]/page.jsx create mode 100644 public-site/app/blog/page.jsx create mode 100644 public-site/app/faq/page.jsx create mode 100644 public-site/app/globals.css create mode 100644 public-site/app/icon.svg create mode 100644 public-site/app/keysy/page.jsx create mode 100644 public-site/app/kontakty/page.jsx create mode 100644 public-site/app/layout.jsx create mode 100644 public-site/app/o-kompanii/page.jsx create mode 100644 public-site/app/otzyvy/page.jsx create mode 100644 public-site/app/page.jsx create mode 100644 public-site/app/robots.js create mode 100644 public-site/app/sitemap.js create mode 100644 public-site/app/uslugi/[slug]/page.jsx create mode 100644 public-site/app/uslugi/page.jsx create mode 100644 public-site/components/Footer.jsx create mode 100644 public-site/components/Header.jsx create mode 100644 public-site/components/Reveal.jsx create mode 100644 public-site/components/SectionHeading.jsx create mode 100644 public-site/components/StatusDot.jsx create mode 100644 public-site/components/SupportChat.jsx create mode 100644 public-site/components/ThemeProvider.jsx create mode 100644 public-site/components/VisitTracker.jsx create mode 100644 public-site/components/sections/AccountPromo.jsx create mode 100644 public-site/components/sections/Cases.jsx create mode 100644 public-site/components/sections/Contact.jsx create mode 100644 public-site/components/sections/Faq.jsx create mode 100644 public-site/components/sections/Hero.jsx create mode 100644 public-site/components/sections/Promotions.jsx create mode 100644 public-site/components/sections/ReviewForm.jsx create mode 100644 public-site/components/sections/Reviews.jsx create mode 100644 public-site/components/sections/Services.jsx create mode 100644 public-site/lib/clientApi.js create mode 100644 public-site/lib/icons.js create mode 100644 public-site/lib/serverApi.js create mode 100644 public-site/lib/serviceLandingCopy.js create mode 100644 public-site/next.config.mjs create mode 100644 public-site/package.json create mode 100644 public-site/postcss.config.js create mode 100644 public-site/public/.gitkeep create mode 100644 public-site/tailwind.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9f4110e --- /dev/null +++ b/.env.example @@ -0,0 +1,76 @@ +# Скопируйте этот файл в .env и заполните реальными значениями: +# cp .env.example .env + +# --- PostgreSQL --- +POSTGRES_DB=topsysops +POSTGRES_USER=topsysops +POSTGRES_PASSWORD=change-me-to-a-long-random-password + +# --- Бэкенд --- +# Длинная случайная строка для подписи сессионных JWT-токенов. +# Сгенерировать: openssl rand -hex 32 +JWT_SECRET=change-me-to-a-long-random-string + +# Логин и пароль первого администратора. Создаются один раз при первом +# запуске (если таблица admin_users пуста) - после этого меняются только +# из самой админ-панели (Настройки -> Сменить пароль). +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-to-a-strong-password + +# Оставьте true в проде (сайт работает по HTTPS через Caddy). +# Поставьте false, только если временно открываете сайт по http:// без домена +# (например, тестируете по IP-адресу сервера) - иначе браузер не примет cookie. +COOKIE_SECURE=true + +# --- Домен / Caddy --- +# Вариант А - Caddy сам получает и продлевает сертификат Let's Encrypt (нет +# внешнего реверс-прокси перед контейнером): +# DOMAIN=top-sysops.ru +# Вариант Б - перед контейнером уже стоит свой реверс-прокси со своим +# сертификатом (Nginx Proxy Manager, обычный nginx и т.п.), который проксирует +# сюда по обычному http. В этом случае ОБЯЗАТЕЛЬНО добавьте схему http:// - +# иначе Caddy сам попытается сделать редирект на https и получится +# бесконечный редирект ("too many redirects"): +# DOMAIN=http://top-sysops.ru +# Вариант В - тестируете по IP сервера, без домена и без HTTPS вообще: +# DOMAIN=:80 +DOMAIN=http://top-sysops.ru + +# Порт, на котором Caddy отдаёт сайт наружу (обычный http, без TLS - шифрование +# на внешнем реверс-прокси). Настройте этот же порт как target в Nginx Proxy +# Manager (Proxy Host -> Forward Hostname/IP: адрес сервера, Forward Port: это +# значение). +HTTP_PORT=7778 +# Порт для HTTPS - нужен только в варианте А (Caddy сам управляет сертификатом). +# В варианте Б (ваш случай, внешний реверс-прокси) он не используется вообще - +# в docker-compose.yml проброс "443:443" уже закомментирован по умолчанию. +HTTPS_PORT=443 + +# --- Чат поддержки на публичном сайте --- +# Выберите один провайдер: topticket, chatwoot или none (чат выключен). +# Смена значения требует пересборки: docker compose up -d --build nextjs +CHAT_PROVIDER=topticket + +# Если CHAT_PROVIDER=topticket - embed-сниппет вашей собственной разработки +# TopTicket: + + diff --git a/admin-app/package.json b/admin-app/package.json new file mode 100644 index 0000000..b168620 --- /dev/null +++ b/admin-app/package.json @@ -0,0 +1,26 @@ +{ + "name": "topsysops-admin-app", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.383.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-quill": "^2.0.0", + "react-router-dom": "^6.24.1", + "recharts": "^2.12.7" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "vite": "^5.3.1" + } +} diff --git a/admin-app/postcss.config.js b/admin-app/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/admin-app/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/admin-app/src/App.jsx b/admin-app/src/App.jsx new file mode 100644 index 0000000..993631f --- /dev/null +++ b/admin-app/src/App.jsx @@ -0,0 +1,57 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import Login from './admin/Login.jsx'; +import AdminLayout from './admin/AdminLayout.jsx'; +import Dashboard from './admin/Dashboard.jsx'; +import ReviewsManager from './admin/ReviewsManager.jsx'; +import ServicesEditor from './admin/ServicesEditor.jsx'; +import PromotionsEditor from './admin/PromotionsEditor.jsx'; +import CasesEditor from './admin/CasesEditor.jsx'; +import FaqEditor from './admin/FaqEditor.jsx'; +import ContactEditor from './admin/ContactEditor.jsx'; +import BlogEditor from './admin/BlogEditor.jsx'; +import AiAssistant from './admin/AiAssistant.jsx'; +import CustomersManager from './admin/CustomersManager.jsx'; +import Settings from './admin/Settings.jsx'; +import AccountLogin from './account/Login.jsx'; +import AccountRegister from './account/Register.jsx'; +import AccountLayout from './account/AccountLayout.jsx'; +import AccountOverview from './account/Overview.jsx'; +import AccountMessages from './account/Messages.jsx'; +import AccountAiChat from './account/AiChat.jsx'; +import AccountProfile from './account/Profile.jsx'; + +// Это приложение обслуживает только /admin* и /account* - Caddy направляет сюда +// именно эти пути, а всё остальное (публичный сайт и блог) идёт в Next.js. +export default function App() { + return ( + + {/* staff admin - login lives at the bare /admin path */} + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* customer cabinet - login/register live at bare /account paths */} + } /> + } /> + }> + } /> + } /> + } /> + } /> + + + } /> + + ); +} diff --git a/admin-app/src/account/AccountLayout.jsx b/admin-app/src/account/AccountLayout.jsx new file mode 100644 index 0000000..c6f16c0 --- /dev/null +++ b/admin-app/src/account/AccountLayout.jsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react'; +import { NavLink, Navigate, Outlet, useNavigate, Link } from 'react-router-dom'; +import { LayoutDashboard, MessageCircle, Bot, UserCog, LogOut, Loader2, Terminal } from 'lucide-react'; +import { api } from '../lib/api.js'; + +function cx(...args) { + return args.filter(Boolean).join(' '); +} + +const NAV = [ + { to: '/account/dashboard', label: 'Обзор', icon: LayoutDashboard }, + { to: '/account/messages', label: 'Написать нам', icon: MessageCircle }, + { to: '/account/ai', label: 'ИИ-ассистент', icon: Bot }, + { to: '/account/profile', label: 'Профиль', icon: UserCog }, +]; + +export default function AccountLayout() { + const navigate = useNavigate(); + const [status, setStatus] = useState('checking'); // checking | authed | anon + const [customer, setCustomer] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .customerMe() + .then((data) => { + if (cancelled) return; + setCustomer(data); + setStatus('authed'); + }) + .catch(() => !cancelled && setStatus('anon')); + return () => { + cancelled = true; + }; + }, []); + + async function handleLogout() { + try { + await api.customerLogout(); + } finally { + navigate('/account'); + } + } + + if (status === 'checking') { + return ( +
+ +
+ ); + } + + if (status === 'anon') { + return ; + } + + return ( +
+ +
+ +
+
+ ); +} diff --git a/admin-app/src/account/AiChat.jsx b/admin-app/src/account/AiChat.jsx new file mode 100644 index 0000000..db3abf2 --- /dev/null +++ b/admin-app/src/account/AiChat.jsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from 'react'; +import { Send, Bot, Loader2, Trash2 } from 'lucide-react'; +import { api } from '../lib/api.js'; + +const STORAGE_KEY = 'ts-account-ai-chat'; + +function cx(...args) { + return args.filter(Boolean).join(' '); +} + +export default function AiChat() { + const [status, setStatus] = useState(null); + const [messages, setMessages] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + return saved ? JSON.parse(saved) : []; + } catch (e) { + return []; + } + }); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(''); + const bottomRef = useRef(null); + + useEffect(() => { + api.accountAiStatus().then(setStatus).catch(() => setStatus({ configured: false })); + }, []); + + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); + } catch (e) { + // not critical if history can't be persisted + } + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + async function handleSend(e) { + e.preventDefault(); + const text = input.trim(); + if (!text || sending) return; + + const next = [...messages, { role: 'user', content: text }]; + setMessages(next); + setInput(''); + setSending(true); + setError(''); + + try { + const { reply } = await api.accountAiChat(next); + setMessages([...next, { role: 'assistant', content: reply || '(пустой ответ)' }]); + } catch (err) { + setError('Не получилось получить ответ, попробуйте ещё раз чуть позже.'); + } finally { + setSending(false); + } + } + + function clearHistory() { + if (!window.confirm('Очистить историю чата?')) return; + setMessages([]); + } + + return ( +
+
+
+

+ ИИ-ассистент +

+

Отвечает на вопросы об услугах и ценах, доступно 24/7.

+
+ +
+ + {status && !status.configured && ( +
+ Ассистент временно недоступен. Напишите нам в разделе «Написать нам» - ответим лично. +
+ )} + +
+ {messages.length === 0 && ( +

Спросите что-нибудь про наши услуги, цены или сроки.

+ )} + {messages.map((m, i) => ( +
+ {m.content} +
+ ))} + {sending && ( +
+ Ассистент печатает... +
+ )} +
+
+ + {error &&

{error}

} + +
+ setInput(e.target.value)} + placeholder="Напишите сообщение..." + disabled={status && !status.configured} + className="flex-1 rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700 disabled:opacity-50" + /> + +
+
+ ); +} diff --git a/admin-app/src/account/Login.jsx b/admin-app/src/account/Login.jsx new file mode 100644 index 0000000..931d342 --- /dev/null +++ b/admin-app/src/account/Login.jsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { LogIn } from 'lucide-react'; +import { api } from '../lib/api.js'; + +export default function Login() { + const navigate = useNavigate(); + const [form, setForm] = useState({ email: '', password: '' }); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setBusy(true); + setError(''); + try { + await api.customerLogin(form.email, form.password); + navigate('/account/dashboard'); + } catch (err) { + setError(err.message === 'invalid credentials' ? 'Неверный email или пароль' : 'Не удалось войти, попробуйте ещё раз'); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ +

Личный кабинет

+
+

+ Войдите, чтобы написать нам или задать вопрос ИИ-ассистенту. +

+ + + setForm({ ...form, email: e.target.value })} + autoComplete="email" + className="w-full rounded-md border px-3 py-2 text-sm bg-transparent mb-3 border-slate-300 dark:border-slate-700" + /> + + + setForm({ ...form, password: e.target.value })} + autoComplete="current-password" + className="w-full rounded-md border px-3 py-2 text-sm bg-transparent mb-4 border-slate-300 dark:border-slate-700" + /> + + {error &&

{error}

} + + +

+ Ещё нет аккаунта?{' '} + + Зарегистрироваться + +

+
+
+ ); +} diff --git a/admin-app/src/account/Messages.jsx b/admin-app/src/account/Messages.jsx new file mode 100644 index 0000000..7505746 --- /dev/null +++ b/admin-app/src/account/Messages.jsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from 'react'; +import { Send } from 'lucide-react'; +import { api } from '../lib/api.js'; + +function cx(...args) { + return args.filter(Boolean).join(' '); +} + +export default function Messages() { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const bottomRef = useRef(null); + + async function load() { + setLoading(true); + setMessages(await api.accountGetMessages()); + setLoading(false); + } + + useEffect(() => { + load(); + }, []); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + async function handleSend(e) { + e.preventDefault(); + const text = input.trim(); + if (!text || sending) return; + setSending(true); + try { + const msg = await api.accountSendMessage(text); + setMessages((m) => [...m, msg]); + setInput(''); + } catch (err) { + // the user can just retry + } finally { + setSending(false); + } + } + + return ( +
+

Написать нам

+

Сообщения видит команда TopSysOps и ответит вам здесь же.

+ +
+ {loading &&

Загружаем переписку...

} + {!loading && messages.length === 0 && ( +

Пока нет сообщений - напишите нам, если возникли вопросы.

+ )} + {messages.map((m) => ( +
+ {m.body} +
+ ))} +
+
+ +
+ setInput(e.target.value)} + placeholder="Напишите сообщение..." + className="flex-1 rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700" + /> + +
+
+ ); +} diff --git a/admin-app/src/account/Overview.jsx b/admin-app/src/account/Overview.jsx new file mode 100644 index 0000000..508fd42 --- /dev/null +++ b/admin-app/src/account/Overview.jsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import { useOutletContext, Link } from 'react-router-dom'; +import { Bot, MessageCircle, Tag } from 'lucide-react'; +import { api } from '../lib/api.js'; + +export default function Overview() { + const { customer } = useOutletContext(); + const [promotions, setPromotions] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api + .getPromotions() + .then(setPromotions) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + return ( +
+

Здравствуйте, {customer?.name}!

+

Рады видеть вас в личном кабинете TopSysOps.

+ +
+ + +
Написать нам
+
Личная переписка с командой TopSysOps
+ + + +
ИИ-ассистент
+
Быстрые ответы на вопросы, доступно 24/7
+ +
+ +

+ Акции специально для вас +

+ {loading &&

Загружаем...

} +
+ {promotions.slice(0, 4).map((p) => ( +
+
{p.title}
+
{p.price_text}
+
+ ))} + {!loading && promotions.length === 0 &&

Сейчас нет активных акций.

} +
+
+ ); +} diff --git a/admin-app/src/account/Profile.jsx b/admin-app/src/account/Profile.jsx new file mode 100644 index 0000000..0a3b663 --- /dev/null +++ b/admin-app/src/account/Profile.jsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { api } from '../lib/api.js'; + +const fieldCls = 'w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700'; + +export default function Profile() { + const { customer } = useOutletContext(); + const [form, setForm] = useState({ name: '', company: '', phone: '' }); + const [savedMsg, setSavedMsg] = useState(''); + const [busy, setBusy] = useState(false); + + const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirm: '' }); + const [pwMsg, setPwMsg] = useState(null); + + useEffect(() => { + if (customer) setForm({ name: customer.name || '', company: customer.company || '', phone: customer.phone || '' }); + }, [customer]); + + async function saveProfile(e) { + e.preventDefault(); + setBusy(true); + setSavedMsg(''); + try { + await api.customerUpdateMe(form); + setSavedMsg('Сохранено'); + } catch (err) { + setSavedMsg('Не удалось сохранить'); + } finally { + setBusy(false); + } + } + + async function changePassword(e) { + e.preventDefault(); + setPwMsg(null); + if (pwForm.newPassword !== pwForm.confirm) { + setPwMsg({ type: 'error', text: 'Пароли не совпадают' }); + return; + } + if (pwForm.newPassword.length < 8) { + setPwMsg({ type: 'error', text: 'Новый пароль должен быть не короче 8 символов' }); + return; + } + try { + await api.customerChangePassword(pwForm.currentPassword, pwForm.newPassword); + setPwMsg({ type: 'ok', text: 'Пароль изменён' }); + setPwForm({ currentPassword: '', newPassword: '', confirm: '' }); + } catch (err) { + setPwMsg({ + type: 'error', + text: err.message === 'current password is incorrect' ? 'Текущий пароль указан неверно' : 'Не удалось изменить пароль', + }); + } + } + + return ( +
+

Профиль

+ +
+

Данные аккаунта

+
+ + +
+
+ + setForm({ ...form, name: e.target.value })} className={fieldCls} /> +
+
+ + setForm({ ...form, company: e.target.value })} className={fieldCls} /> +
+
+ + setForm({ ...form, phone: e.target.value })} className={fieldCls} /> +
+ {savedMsg &&

{savedMsg}

} + +
+ +
+

Сменить пароль

+
+ + setPwForm({ ...pwForm, currentPassword: e.target.value })} + className={fieldCls} + /> +
+
+ + setPwForm({ ...pwForm, newPassword: e.target.value })} + className={fieldCls} + /> +
+
+ + setPwForm({ ...pwForm, confirm: e.target.value })} + className={fieldCls} + /> +
+ {pwMsg &&

{pwMsg.text}

} + +
+
+ ); +} diff --git a/admin-app/src/account/Register.jsx b/admin-app/src/account/Register.jsx new file mode 100644 index 0000000..b675e8b --- /dev/null +++ b/admin-app/src/account/Register.jsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { UserPlus } from 'lucide-react'; +import { api } from '../lib/api.js'; + +const fieldCls = 'w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700'; + +export default function Register() { + const navigate = useNavigate(); + const [form, setForm] = useState({ name: '', email: '', password: '', company: '', phone: '' }); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + if (form.password.length < 8) { + setError('Пароль должен быть не короче 8 символов'); + return; + } + setBusy(true); + try { + await api.customerRegister(form); + navigate('/account/dashboard'); + } catch (err) { + setError( + err.message === 'this email is already registered' + ? 'Этот email уже зарегистрирован' + : 'Не удалось зарегистрироваться, попробуйте ещё раз' + ); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ +

Регистрация

+
+

Доступ к ИИ-ассистенту и личной переписке с нами.

+ +
+
+ + setForm({ ...form, name: e.target.value })} className={fieldCls} /> +
+
+ + setForm({ ...form, email: e.target.value })} + autoComplete="email" + className={fieldCls} + /> +
+
+ + setForm({ ...form, password: e.target.value })} + autoComplete="new-password" + className={fieldCls} + /> +
+
+ + setForm({ ...form, company: e.target.value })} className={fieldCls} /> +
+
+ + setForm({ ...form, phone: e.target.value })} className={fieldCls} /> +
+
+ + {error &&

{error}

} + + +

+ Уже есть аккаунт?{' '} + + Войти + +

+
+
+ ); +} diff --git a/admin-app/src/admin/AdminLayout.jsx b/admin-app/src/admin/AdminLayout.jsx new file mode 100644 index 0000000..1c8a140 --- /dev/null +++ b/admin-app/src/admin/AdminLayout.jsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'; +import { LayoutDashboard, MessageSquare, Tag, Terminal, Settings as SettingsIcon, LogOut, Loader2, Briefcase, HelpCircle, Bot, Users, MapPin, Newspaper } from 'lucide-react'; +import { api } from '../lib/api.js'; + +function cx(...args) { + return args.filter(Boolean).join(' '); +} + +const NAV = [ + { to: '/admin/dashboard', label: 'Дашборд', icon: LayoutDashboard }, + { to: '/admin/reviews', label: 'Отзывы', icon: MessageSquare }, + { to: '/admin/customers', label: 'Клиенты', icon: Users }, + { to: '/admin/services', label: 'Прайс-лист', icon: Terminal }, + { to: '/admin/promotions', label: 'Акции', icon: Tag }, + { to: '/admin/cases', label: 'Кейсы', icon: Briefcase }, + { to: '/admin/faqs', label: 'FAQ', icon: HelpCircle }, + { to: '/admin/blog', label: 'Блог', icon: Newspaper }, + { to: '/admin/contact', label: 'Контакты', icon: MapPin }, + { to: '/admin/ai', label: 'ИИ-ассистент', icon: Bot }, + { to: '/admin/settings', label: 'Настройки', icon: SettingsIcon }, +]; + +export default function AdminLayout() { + const navigate = useNavigate(); + const [status, setStatus] = useState('checking'); // checking | authed | anon + + useEffect(() => { + let cancelled = false; + api + .me() + .then(() => !cancelled && setStatus('authed')) + .catch(() => !cancelled && setStatus('anon')); + return () => { + cancelled = true; + }; + }, []); + + async function handleLogout() { + try { + await api.logout(); + } finally { + navigate('/admin'); + } + } + + if (status === 'checking') { + return ( +
+ +
+ ); + } + + if (status === 'anon') { + return ; + } + + return ( +
+ +
+ +
+
+ ); +} diff --git a/admin-app/src/admin/AiAssistant.jsx b/admin-app/src/admin/AiAssistant.jsx new file mode 100644 index 0000000..e2eda37 --- /dev/null +++ b/admin-app/src/admin/AiAssistant.jsx @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState } from 'react'; +import { Send, Bot, Loader2, Trash2 } from 'lucide-react'; +import { api } from '../lib/api.js'; + +const STORAGE_KEY = 'ts-ai-chat-history'; + +function cx(...args) { + return args.filter(Boolean).join(' '); +} + +export default function AiAssistant() { + const [status, setStatus] = useState(null); // { configured, model } + const [messages, setMessages] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + return saved ? JSON.parse(saved) : []; + } catch (e) { + return []; + } + }); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(''); + const bottomRef = useRef(null); + + useEffect(() => { + api.aiStatus().then(setStatus).catch(() => setStatus({ configured: false })); + }, []); + + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); + } catch (e) { + // ignore - not critical if history can't be persisted + } + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + async function handleSend(e) { + e.preventDefault(); + const text = input.trim(); + if (!text || sending) return; + + const next = [...messages, { role: 'user', content: text }]; + setMessages(next); + setInput(''); + setSending(true); + setError(''); + + try { + const { reply } = await api.aiChat(next); + setMessages([...next, { role: 'assistant', content: reply || '(пустой ответ)' }]); + } catch (err) { + setError('Не удалось получить ответ от ассистента. Проверьте OPENROUTER_API_KEY в .env бэкенда.'); + } finally { + setSending(false); + } + } + + function clearHistory() { + if (!window.confirm('Очистить историю чата?')) return; + setMessages([]); + } + + return ( +
+
+
+

+ ИИ-ассистент +

+

+ Внутренний инструмент для сотрудников - через OpenRouter, модель {status?.model || '...'}. +

+
+ +
+ + {status && !status.configured && ( +
+ Ассистент не настроен: задайте OPENROUTER_API_KEY в .env и перезапустите + backend (docker compose up -d backend). +
+ )} + +
+ {messages.length === 0 && ( +

+ Спросите что-нибудь: «помоги ответить на негативный отзыв», «сколько стоит настройка сервера» и т.д. +

+ )} + {messages.map((m, i) => ( +
+ {m.content} +
+ ))} + {sending && ( +
+ Ассистент печатает... +
+ )} +
+
+ + {error &&

{error}

} + +
+ setInput(e.target.value)} + placeholder="Напишите сообщение..." + disabled={status && !status.configured} + className="flex-1 rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700 disabled:opacity-50" + /> + +
+
+ ); +} diff --git a/admin-app/src/admin/BlogEditor.jsx b/admin-app/src/admin/BlogEditor.jsx new file mode 100644 index 0000000..b061a8d --- /dev/null +++ b/admin-app/src/admin/BlogEditor.jsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react'; +import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react'; +import ReactQuill from 'react-quill'; +import 'react-quill/dist/quill.snow.css'; +import { api } from '../lib/api.js'; + +function cx(...args) { + return args.filter(Boolean).join(' '); +} + +const inputCls = 'w-full rounded-md border px-2.5 py-1.5 text-sm bg-transparent border-slate-300 dark:border-slate-700'; + +const QUILL_MODULES = { + toolbar: [ + [{ header: [2, 3, false] }], + ['bold', 'italic', 'underline', 'strike'], + [{ list: 'ordered' }, { list: 'bullet' }], + ['blockquote', 'link'], + ['clean'], + ], +}; + +const TRANSLIT = { + а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', + к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', + х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya', +}; + +function slugify(text) { + return String(text) + .toLowerCase() + .split('') + .map((ch) => (TRANSLIT[ch] !== undefined ? TRANSLIT[ch] : ch)) + .join('') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); +} + +function PostForm({ initial, onSave, onCancel }) { + const [form, setForm] = useState( + initial || { slug: '', title: '', excerpt: '', content: '', published: false } + ); + const [slugTouched, setSlugTouched] = useState(Boolean(initial)); + + function handleTitleChange(title) { + setForm((f) => ({ ...f, title, slug: slugTouched ? f.slug : slugify(title) })); + } + + return ( +
+ handleTitleChange(e.target.value)} /> + { + setSlugTouched(true); + setForm({ ...form, slug: e.target.value }); + }} + /> +