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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013otXCiTZsxdZ4SJKZ9wUky
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-25 23:12:16 +03:00
commit 130c5382cd
108 files changed
+7146

No files matched your search

+57
View File
@@ -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 (
<Routes>
{/* staff admin - login lives at the bare /admin path */}
<Route path="/admin" element={<Login />} />
<Route element={<AdminLayout />}>
<Route path="/admin/dashboard" element={<Dashboard />} />
<Route path="/admin/reviews" element={<ReviewsManager />} />
<Route path="/admin/customers" element={<CustomersManager />} />
<Route path="/admin/services" element={<ServicesEditor />} />
<Route path="/admin/promotions" element={<PromotionsEditor />} />
<Route path="/admin/cases" element={<CasesEditor />} />
<Route path="/admin/faqs" element={<FaqEditor />} />
<Route path="/admin/blog" element={<BlogEditor />} />
<Route path="/admin/contact" element={<ContactEditor />} />
<Route path="/admin/ai" element={<AiAssistant />} />
<Route path="/admin/settings" element={<Settings />} />
</Route>
{/* customer cabinet - login/register live at bare /account paths */}
<Route path="/account" element={<AccountLogin />} />
<Route path="/account/register" element={<AccountRegister />} />
<Route element={<AccountLayout />}>
<Route path="/account/dashboard" element={<AccountOverview />} />
<Route path="/account/messages" element={<AccountMessages />} />
<Route path="/account/ai" element={<AccountAiChat />} />
<Route path="/account/profile" element={<AccountProfile />} />
</Route>
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
);
}
+96
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
</div>
);
}
if (status === 'anon') {
return <Navigate to="/account" replace />;
}
return (
<div className="min-h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-slate-950">
<aside className="md:w-56 border-b md:border-b-0 md:border-r p-4 flex md:flex-col gap-2 overflow-x-auto md:overflow-x-visible border-slate-200 dark:border-slate-900">
<Link to="/" className="hidden md:flex items-center gap-2 mb-6 px-2">
<Terminal className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-bold font-display">TopSysOps</span>
</Link>
{customer && <p className="hidden md:block text-xs text-slate-400 dark:text-slate-500 px-2 mb-2 truncate">{customer.name}</p>}
{NAV.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors flex-shrink-0 whitespace-nowrap',
isActive
? 'bg-emerald-500 text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-900'
)
}
>
<Icon className="h-4 w-4" /> {item.label}
</NavLink>
);
})}
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium flex-shrink-0 whitespace-nowrap md:mt-auto text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900"
>
<LogOut className="h-4 w-4" /> Выйти
</button>
</aside>
<main className="flex-1 p-5 sm:p-8 max-w-3xl">
<Outlet context={{ customer }} />
</main>
</div>
);
}
+128
View File
@@ -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 (
<div className="flex flex-col h-[calc(100vh-4rem)] md:h-[calc(100vh-2rem)] max-w-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="font-bold font-display text-lg flex items-center gap-2">
<Bot className="h-5 w-5 text-emerald-500" /> ИИ-ассистент
</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">Отвечает на вопросы об услугах и ценах, доступно 24/7.</p>
</div>
<button onClick={clearHistory} className="p-2 rounded-md text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-900" title="Очистить историю">
<Trash2 className="h-4 w-4" />
</button>
</div>
{status && !status.configured && (
<div className="rounded-lg border border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300 p-4 text-sm mb-4">
Ассистент временно недоступен. Напишите нам в разделе «Написать нам» - ответим лично.
</div>
)}
<div className="flex-1 overflow-y-auto rounded-lg border p-4 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-3">
{messages.length === 0 && (
<p className="text-sm text-slate-400">Спросите что-нибудь про наши услуги, цены или сроки.</p>
)}
{messages.map((m, i) => (
<div
key={i}
className={cx(
'max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
m.role === 'user' ? 'ml-auto bg-emerald-500 text-white' : 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
)}
>
{m.content}
</div>
))}
{sending && (
<div className="flex items-center gap-2 text-xs text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Ассистент печатает...
</div>
)}
<div ref={bottomRef} />
</div>
{error && <p className="text-xs text-rose-500 mb-2">{error}</p>}
<form onSubmit={handleSend} className="flex gap-2">
<input
value={input}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={sending || (status && !status.configured)}
className="px-4 py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
);
}
+75
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center px-5 bg-slate-50 dark:bg-slate-950">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border p-6 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center gap-2 mb-1">
<LogIn className="h-5 w-5 text-emerald-500" />
<h1 className="font-bold font-display text-base">Личный кабинет</h1>
</div>
<p className="text-xs mb-5 text-slate-400 dark:text-slate-500">
Войдите, чтобы написать нам или задать вопрос ИИ-ассистенту.
</p>
<label className="text-xs block mb-1">Email</label>
<input
type="email"
required
value={form.email}
onChange={(e) => 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"
/>
<label className="text-xs block mb-1">Пароль</label>
<input
type="password"
required
value={form.password}
onChange={(e) => 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 && <p className="text-xs text-rose-500 mb-4">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60 mb-3"
>
{busy ? 'Входим...' : 'Войти'}
</button>
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
Ещё нет аккаунта?{' '}
<Link to="/account/register" className="text-emerald-500 hover:underline">
Зарегистрироваться
</Link>
</p>
</form>
</div>
);
}
+87
View File
@@ -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 (
<div className="flex flex-col h-[calc(100vh-4rem)] md:h-[calc(100vh-2rem)] max-w-2xl">
<h1 className="font-bold mb-1 font-display text-lg">Написать нам</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Сообщения видит команда TopSysOps и ответит вам здесь же.</p>
<div className="flex-1 overflow-y-auto rounded-lg border p-4 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-3">
{loading && <p className="text-sm text-slate-400">Загружаем переписку...</p>}
{!loading && messages.length === 0 && (
<p className="text-sm text-slate-400">Пока нет сообщений - напишите нам, если возникли вопросы.</p>
)}
{messages.map((m) => (
<div
key={m.id}
className={cx(
'max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
m.sender === 'customer' ? 'ml-auto bg-emerald-500 text-white' : 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
)}
>
{m.body}
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSend} className="flex gap-2">
<input
value={input}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={sending}
className="px-4 py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
);
}
+58
View File
@@ -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 (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Здравствуйте, {customer?.name}!</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">Рады видеть вас в личном кабинете TopSysOps.</p>
<div className="grid sm:grid-cols-2 gap-4 mb-8">
<Link
to="/account/messages"
className="rounded-lg border p-4 hover:border-emerald-500 transition-colors bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800"
>
<MessageCircle className="h-5 w-5 text-emerald-500 mb-2" />
<div className="text-sm font-medium">Написать нам</div>
<div className="text-xs text-slate-400 dark:text-slate-500">Личная переписка с командой TopSysOps</div>
</Link>
<Link
to="/account/ai"
className="rounded-lg border p-4 hover:border-emerald-500 transition-colors bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800"
>
<Bot className="h-5 w-5 text-emerald-500 mb-2" />
<div className="text-sm font-medium">ИИ-ассистент</div>
<div className="text-xs text-slate-400 dark:text-slate-500">Быстрые ответы на вопросы, доступно 24/7</div>
</Link>
</div>
<h2 className="text-sm font-medium mb-3 flex items-center gap-2">
<Tag className="h-4 w-4 text-emerald-500" /> Акции специально для вас
</h2>
{loading && <p className="text-sm text-slate-400">Загружаем...</p>}
<div className="grid sm:grid-cols-2 gap-4">
{promotions.slice(0, 4).map((p) => (
<div key={p.id} className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="text-sm font-medium mb-1">{p.title}</div>
<div className="text-emerald-500 font-mono text-sm">{p.price_text}</div>
</div>
))}
{!loading && promotions.length === 0 && <p className="text-sm text-slate-400">Сейчас нет активных акций.</p>}
</div>
</div>
);
}
+128
View File
@@ -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 (
<div>
<h1 className="font-bold mb-5 font-display text-lg">Профиль</h1>
<form onSubmit={saveProfile} className="max-w-sm rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-6">
<h2 className="text-sm font-medium mb-1">Данные аккаунта</h2>
<div>
<label className="text-xs block mb-1">Email</label>
<input value={customer?.email || ''} disabled className={`${fieldCls} opacity-60`} />
</div>
<div>
<label className="text-xs block mb-1">Имя</label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Компания</label>
<input value={form.company} onChange={(e) => setForm({ ...form, company: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Телефон</label>
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={fieldCls} />
</div>
{savedMsg && <p className="text-xs text-emerald-500">{savedMsg}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-60"
>
{busy ? 'Сохраняем...' : 'Сохранить'}
</button>
</form>
<form onSubmit={changePassword} className="max-w-sm rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium mb-1">Сменить пароль</h2>
<div>
<label className="text-xs block mb-1">Текущий пароль</label>
<input
type="password"
required
value={pwForm.currentPassword}
onChange={(e) => setPwForm({ ...pwForm, currentPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Новый пароль</label>
<input
type="password"
required
value={pwForm.newPassword}
onChange={(e) => setPwForm({ ...pwForm, newPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Повторите новый пароль</label>
<input
type="password"
required
value={pwForm.confirm}
onChange={(e) => setPwForm({ ...pwForm, confirm: e.target.value })}
className={fieldCls}
/>
</div>
{pwMsg && <p className={pwMsg.type === 'ok' ? 'text-xs text-emerald-500' : 'text-xs text-rose-500'}>{pwMsg.text}</p>}
<button type="submit" className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600">
Сохранить пароль
</button>
</form>
</div>
);
}
+100
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center px-5 py-10 bg-slate-50 dark:bg-slate-950">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border p-6 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center gap-2 mb-1">
<UserPlus className="h-5 w-5 text-emerald-500" />
<h1 className="font-bold font-display text-base">Регистрация</h1>
</div>
<p className="text-xs mb-5 text-slate-400 dark:text-slate-500">Доступ к ИИ-ассистенту и личной переписке с нами.</p>
<div className="space-y-3 mb-4">
<div>
<label className="text-xs block mb-1">Имя*</label>
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Email*</label>
<input
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
autoComplete="email"
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Пароль* (от 8 символов)</label>
<input
type="password"
required
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
autoComplete="new-password"
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Компания</label>
<input value={form.company} onChange={(e) => setForm({ ...form, company: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Телефон</label>
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={fieldCls} />
</div>
</div>
{error && <p className="text-xs text-rose-500 mb-4">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60 mb-3"
>
{busy ? 'Регистрируем...' : 'Зарегистрироваться'}
</button>
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
Уже есть аккаунт?{' '}
<Link to="/account" className="text-emerald-500 hover:underline">
Войти
</Link>
</p>
</form>
</div>
);
}
+98
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
</div>
);
}
if (status === 'anon') {
return <Navigate to="/admin" replace />;
}
return (
<div className="min-h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-slate-950">
<aside className="md:w-56 border-b md:border-b-0 md:border-r p-4 flex md:flex-col gap-2 overflow-x-auto md:overflow-x-visible border-slate-200 dark:border-slate-900">
<a href="/" className="hidden md:flex items-center gap-2 mb-6 px-2">
<Terminal className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-bold font-display">TopSysOps</span>
<span className="text-xs text-slate-400 dark:text-slate-500 font-mono">admin</span>
</a>
{NAV.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors flex-shrink-0 whitespace-nowrap',
isActive
? 'bg-emerald-500 text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-900'
)
}
>
<Icon className="h-4 w-4" /> {item.label}
</NavLink>
);
})}
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium flex-shrink-0 whitespace-nowrap md:mt-auto text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900"
>
<LogOut className="h-4 w-4" /> Выйти
</button>
</aside>
<main className="flex-1 p-5 sm:p-8 max-w-5xl">
<Outlet />
</main>
</div>
);
}
+135
View File
@@ -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 (
<div className="flex flex-col h-[calc(100vh-4rem)] md:h-[calc(100vh-2rem)] max-w-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="font-bold font-display text-lg flex items-center gap-2">
<Bot className="h-5 w-5 text-emerald-500" /> ИИ-ассистент
</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
Внутренний инструмент для сотрудников - через OpenRouter, модель {status?.model || '...'}.
</p>
</div>
<button onClick={clearHistory} className="p-2 rounded-md text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-900" title="Очистить историю">
<Trash2 className="h-4 w-4" />
</button>
</div>
{status && !status.configured && (
<div className="rounded-lg border border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300 p-4 text-sm mb-4">
Ассистент не настроен: задайте <code>OPENROUTER_API_KEY</code> в <code>.env</code> и перезапустите
backend (<code>docker compose up -d backend</code>).
</div>
)}
<div className="flex-1 overflow-y-auto rounded-lg border p-4 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-3">
{messages.length === 0 && (
<p className="text-sm text-slate-400">
Спросите что-нибудь: «помоги ответить на негативный отзыв», «сколько стоит настройка сервера» и т.д.
</p>
)}
{messages.map((m, i) => (
<div
key={i}
className={cx(
'max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
m.role === 'user'
? 'ml-auto bg-emerald-500 text-white'
: 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
)}
>
{m.content}
</div>
))}
{sending && (
<div className="flex items-center gap-2 text-xs text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Ассистент печатает...
</div>
)}
<div ref={bottomRef} />
</div>
{error && <p className="text-xs text-rose-500 mb-2">{error}</p>}
<form onSubmit={handleSend} className="flex gap-2">
<input
value={input}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={sending || (status && !status.configured)}
className="px-4 py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
);
}
+198
View File
@@ -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 (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Заголовок статьи" value={form.title} onChange={(e) => handleTitleChange(e.target.value)} />
<input
className={inputCls}
placeholder="url-slug (латиницей)"
value={form.slug}
onChange={(e) => {
setSlugTouched(true);
setForm({ ...form, slug: e.target.value });
}}
/>
<textarea className={inputCls} rows={2} placeholder="Краткое описание (для списка статей)" value={form.excerpt} onChange={(e) => setForm({ ...form, excerpt: e.target.value })} />
<div className="bg-white rounded-md overflow-hidden">
<ReactQuill
theme="snow"
modules={QUILL_MODULES}
value={form.content}
onChange={(html) => setForm({ ...form, content: html })}
placeholder="Текст статьи..."
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.published} onChange={(e) => setForm({ ...form, published: e.target.checked })} />
Опубликовать на сайте
</label>
<div className="flex gap-2">
<button
onClick={() => form.title.trim() && form.slug.trim() && onSave(form)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600"
>
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={onCancel} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
function PostRow({ post, onEdit, onDelete, onTogglePublish }) {
return (
<div className={cx('rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800', !post.published && 'opacity-60')}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className={cx('text-xs px-2 py-0.5 rounded-full', post.published ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-300')}>
{post.published ? 'опубликовано' : 'черновик'}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500 font-mono">/blog/{post.slug}</span>
</div>
<div className="text-sm font-medium">{post.title}</div>
{post.excerpt && <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">{post.excerpt}</p>}
</div>
<div className="flex gap-1 flex-shrink-0">
<button title={post.published ? 'Снять с публикации' : 'Опубликовать'} onClick={() => onTogglePublish(post)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
{post.published ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => onEdit(post)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm(`Удалить статью «${post.title}»?`) && onDelete(post.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
export default function BlogEditor() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | post object
const [saving, setSaving] = useState(false);
async function load() {
setLoading(true);
setPosts(await api.adminGetBlogPosts());
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function handleSave(form) {
setSaving(true);
try {
if (form.id) {
await api.adminUpdateBlogPost(form.id, form);
} else {
await api.adminCreateBlogPost(form);
}
setEditing(null);
load();
} catch (err) {
window.alert(err.message === 'a post with this slug already exists' ? 'Статья с таким slug уже существует' : 'Не удалось сохранить статью');
} finally {
setSaving(false);
}
}
async function togglePublish(post) {
await api.adminUpdateBlogPost(post.id, { published: !post.published });
load();
}
async function remove(id) {
await api.adminDeleteBlogPost(id);
load();
}
if (loading) return <p className="text-sm text-slate-400">Загружаем статьи...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Блог</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Опубликованные статьи попадают на сайт (/blog) и в sitemap.xml сразу после сохранения.
</p>
{editing === 'new' && <PostForm onSave={handleSave} onCancel={() => setEditing(null)} />}
{editing && editing !== 'new' && <PostForm initial={editing} onSave={handleSave} onCancel={() => setEditing(null)} />}
{!editing && (
<>
<div className="space-y-3 mb-5">
{posts.length === 0 && <p className="text-sm text-slate-400">Пока нет статей.</p>}
{posts.map((p) => (
<PostRow key={p.id} post={p} onEdit={setEditing} onDelete={remove} onTogglePublish={togglePublish} />
))}
</div>
<button
onClick={() => setEditing('new')}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500"
>
<Plus className="h-4 w-4" /> Новая статья
</button>
</>
)}
{saving && <p className="text-xs text-slate-400 mt-2">Сохраняем...</p>}
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Save, X, EyeOff, Eye } from 'lucide-react';
import { api } from '../lib/api.js';
import StatusDot from '../components/StatusDot.jsx';
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 TONES = [
{ value: 'ok', label: 'завершён (зелёный)' },
{ value: 'warn', label: 'в работе (жёлтый)' },
{ value: 'danger', label: 'проблема (красный)' },
{ value: 'neutral', label: 'нейтральный (серый)' },
];
function CaseCard({ item, onSave, onDelete }) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(item);
useEffect(() => setForm(item), [item]);
if (editing) {
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Название проекта" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<div className="grid sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Тег, напр. Кибербезопасность" value={form.tag || ''} onChange={(e) => setForm({ ...form, tag: e.target.value })} />
<input className={inputCls} placeholder="Текст статуса, напр. завершён" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })} />
</div>
<select className={inputCls} value={form.tone} onChange={(e) => setForm({ ...form, tone: e.target.value })}>
{TONES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<textarea className={inputCls} rows={2} placeholder="Описание проекта" value={form.description || ''} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="flex gap-2">
<button onClick={() => { onSave(form); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={() => { setForm(item); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
return (
<div className={cx('rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800', !item.active && 'opacity-50')}>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 flex-wrap">
{item.tag && <span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">{item.tag}</span>}
<StatusDot tone={item.tone} label={item.status} mono />
</div>
<div className="flex gap-1 flex-shrink-0">
<button
title={item.active ? 'Скрыть с сайта' : 'Показать на сайте'}
onClick={() => onSave({ ...item, active: !item.active })}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800"
>
{item.active ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm(`Удалить кейс «${item.title}»?`) && onDelete(item.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<h3 className="font-bold mb-1 font-display text-[15px]">{item.title}</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{item.description}</p>
</div>
);
}
function NewCaseForm({ onCreate }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ title: '', tag: '', status: 'в работе', tone: 'warn', description: '' });
async function submit() {
if (!form.title.trim()) return;
await onCreate(form);
setForm({ title: '', tag: '', status: 'в работе', tone: 'warn', description: '' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500">
<Plus className="h-4 w-4" /> Новый кейс
</button>
);
}
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Название проекта" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<div className="grid sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Тег, напр. Кибербезопасность" value={form.tag} onChange={(e) => setForm({ ...form, tag: e.target.value })} />
<input className={inputCls} placeholder="Текст статуса, напр. завершён" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })} />
</div>
<select className={inputCls} value={form.tone} onChange={(e) => setForm({ ...form, tone: e.target.value })}>
{TONES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<textarea className={inputCls} rows={2} placeholder="Описание проекта" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Создать</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
export default function CasesEditor() {
const [cases, setCases] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setCases(await api.adminGetCases());
setLoading(false);
}
useEffect(() => {
load();
}, []);
if (loading) return <p className="text-sm text-slate-400">Загружаем кейсы...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Кейсы и портфолио</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Замените демонстрационные кейсы на реальные проекты вашей команды.
</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-5">
{cases.map((item) => (
<CaseCard
key={item.id}
item={item}
onSave={async (data) => { await api.adminUpdateCase(item.id, data); load(); }}
onDelete={async (id) => { await api.adminDeleteCase(id); load(); }}
/>
))}
</div>
<NewCaseForm onCreate={async (data) => { await api.adminCreateCase(data); load(); }} />
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { MapPin } 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 ContactEditor() {
const [form, setForm] = useState({ address: '', phone: '', email: '', hours: '', map_embed: '' });
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [savedMsg, setSavedMsg] = useState('');
useEffect(() => {
api
.adminGetContact()
.then(setForm)
.finally(() => setLoading(false));
}, []);
async function handleSubmit(e) {
e.preventDefault();
setBusy(true);
setSavedMsg('');
try {
const updated = await api.adminUpdateContact(form);
setForm(updated);
setSavedMsg('Сохранено - изменения уже видны на сайте');
} catch (err) {
setSavedMsg('Не удалось сохранить, попробуйте ещё раз');
} finally {
setBusy(false);
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем контакты...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg flex items-center gap-2">
<MapPin className="h-5 w-5 text-emerald-500" /> Контакты
</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Эти данные показываются в разделе «Контакты» на сайте. Оставьте поле пустым, чтобы строка не отображалась.
</p>
<form onSubmit={handleSubmit} className="max-w-lg rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div>
<label className="text-xs block mb-1">Адрес</label>
<input
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Телефон</label>
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Email</label>
<input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Режим работы</label>
<input value={form.hours} onChange={(e) => setForm({ ...form, hours: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Ссылка на карту (iframe src из Яндекс.Карт или Google Maps)</label>
<textarea
rows={3}
value={form.map_embed}
onChange={(e) => setForm({ ...form, map_embed: e.target.value })}
className={fieldCls}
/>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
Оставьте пустым, чтобы блок с картой не показывался вовсе.
</p>
</div>
{savedMsg && <p className="text-xs text-emerald-500">{savedMsg}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60"
>
{busy ? 'Сохраняем...' : 'Сохранить'}
</button>
</form>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { Send, Users } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
export default function CustomersManager() {
const [customers, setCustomers] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState(null);
const [thread, setThread] = useState([]);
const [threadLoading, setThreadLoading] = useState(false);
const [reply, setReply] = useState('');
const [sending, setSending] = useState(false);
async function loadCustomers() {
setLoading(true);
setCustomers(await api.adminGetCustomers());
setLoading(false);
}
useEffect(() => {
loadCustomers();
}, []);
async function openCustomer(id) {
setSelectedId(id);
setThreadLoading(true);
setThread(await api.adminGetCustomerMessages(id));
setThreadLoading(false);
}
async function sendReply(e) {
e.preventDefault();
const text = reply.trim();
if (!text || sending || !selectedId) return;
setSending(true);
try {
const msg = await api.adminSendCustomerMessage(selectedId, text);
setThread((t) => [...t, msg]);
setReply('');
loadCustomers();
} finally {
setSending(false);
}
}
const selected = customers.find((c) => c.id === selectedId);
if (loading) return <p className="text-sm text-slate-400">Загружаем клиентов...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-emerald-500" /> Клиенты
</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Переписка из личных кабинетов зарегистрированных клиентов.
</p>
<div className="grid md:grid-cols-[260px_1fr] gap-5">
<div className="space-y-2">
{customers.length === 0 && <p className="text-sm text-slate-400">Пока нет зарегистрированных клиентов.</p>}
{customers.map((c) => (
<button
key={c.id}
onClick={() => openCustomer(c.id)}
className={cx(
'w-full text-left rounded-lg border p-3 transition-colors bg-white dark:bg-slate-900',
selectedId === c.id
? 'border-emerald-500'
: 'border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700'
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{c.name}</span>
{c.last_message_sender === 'customer' && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-amber-500 text-white flex-shrink-0">ждёт ответа</span>
)}
</div>
<div className="text-xs text-slate-400 dark:text-slate-500 truncate">{c.email}</div>
{c.last_message_body && (
<div className="text-xs text-slate-500 dark:text-slate-400 truncate mt-1">{c.last_message_body}</div>
)}
</button>
))}
</div>
<div>
{!selectedId && <p className="text-sm text-slate-400">Выберите клиента слева, чтобы увидеть переписку.</p>}
{selectedId && (
<div className="flex flex-col h-[70vh]">
<h2 className="text-sm font-medium mb-3">
{selected?.name} <span className="text-slate-400 font-normal">· {selected?.email}</span>
</h2>
<div className="flex-1 overflow-y-auto rounded-lg border p-4 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-3">
{threadLoading && <p className="text-sm text-slate-400">Загружаем переписку...</p>}
{!threadLoading && thread.length === 0 && <p className="text-sm text-slate-400">Сообщений пока нет.</p>}
{thread.map((m) => (
<div
key={m.id}
className={cx(
'max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
m.sender === 'staff' ? 'ml-auto bg-emerald-500 text-white' : 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
)}
>
{m.body}
</div>
))}
</div>
<form onSubmit={sendReply} className="flex gap-2">
<input
value={reply}
onChange={(e) => setReply(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"
/>
<button
type="submit"
disabled={sending}
className="px-4 py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
)}
</div>
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { TrendingUp, Users, MessageSquare, Check } from 'lucide-react';
import { useTheme } from '../context/ThemeContext.jsx';
import { api } from '../lib/api.js';
import StatusDot from '../components/StatusDot.jsx';
function formatDay(iso) {
const d = new Date(iso + 'T00:00:00');
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}`;
}
export default function Dashboard() {
const { dark } = useTheme();
const [stats, setStats] = useState({ byDay: [], total: 0 });
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
const results = await Promise.allSettled([api.adminGetStats(), api.adminGetReviews()]);
if (cancelled) return;
if (results[0].status === 'fulfilled') setStats(results[0].value);
if (results[1].status === 'fulfilled') setReviews(results[1].value);
setLoading(false);
})();
return () => {
cancelled = true;
};
}, []);
const today = new Date().toISOString().slice(0, 10);
const todayVisits = stats.byDay.find((d) => d.date === today)?.count || 0;
const pending = reviews.filter((r) => r.status === 'pending').length;
const approved = reviews.filter((r) => r.status === 'approved').length;
const chartData = stats.byDay.slice(-7).map((d) => ({ date: formatDay(d.date), visits: d.count }));
const cards = [
{ label: 'Визитов всего', value: stats.total, icon: TrendingUp, tone: 'ok' },
{ label: 'Визитов сегодня', value: todayVisits, icon: Users, tone: 'ok' },
{ label: 'Отзывов на модерации', value: pending, icon: MessageSquare, tone: pending > 0 ? 'warn' : 'neutral' },
{ label: 'Опубликовано отзывов', value: approved, icon: Check, tone: 'ok' },
];
const axisColor = dark ? '#64748b' : '#94a3b8';
if (loading) return <p className="text-sm text-slate-400">Загружаем статистику...</p>;
return (
<div>
<h1 className="font-bold mb-5 font-display text-lg">Дашборд</h1>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{cards.map((c) => {
const Icon = c.icon;
return (
<div key={c.label} className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center justify-between mb-2">
<Icon className="h-4 w-4 text-emerald-500" />
<StatusDot tone={c.tone} />
</div>
<div className="text-2xl font-bold font-mono">{c.value}</div>
<div className="text-xs mt-1 text-slate-400 dark:text-slate-500">{c.label}</div>
</div>
);
})}
</div>
<div className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium mb-4">Посещаемость за 7 дней</h2>
<div style={{ height: 240 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={dark ? '#1e293b' : '#e2e8f0'} vertical={false} />
<XAxis dataKey="date" stroke={axisColor} fontSize={12} tickLine={false} axisLine={false} />
<YAxis stroke={axisColor} fontSize={12} tickLine={false} axisLine={false} allowDecimals={false} width={28} />
<Tooltip
contentStyle={{
background: dark ? '#0f172a' : '#ffffff',
border: `1px solid ${dark ? '#1e293b' : '#e2e8f0'}`,
borderRadius: 8,
fontSize: 12,
}}
/>
<Bar dataKey="visits" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<p className="text-xs mt-3 text-slate-400 dark:text-slate-600">
Считаются реальные заходы на сайт (таблица <code>visits</code> в базе данных).
</p>
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Save, X, EyeOff, Eye } from 'lucide-react';
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';
function FaqCard({ item, onSave, onDelete }) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(item);
useEffect(() => setForm(item), [item]);
if (editing) {
return (
<div className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Вопрос" value={form.question} onChange={(e) => setForm({ ...form, question: e.target.value })} />
<textarea className={inputCls} rows={3} placeholder="Ответ" value={form.answer} onChange={(e) => setForm({ ...form, answer: e.target.value })} />
<div className="flex gap-2">
<button onClick={() => { onSave(form); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={() => { setForm(item); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
return (
<div className={cx('rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800', !item.active && 'opacity-50')}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium mb-1">{item.question}</div>
<p className="text-sm text-slate-600 dark:text-slate-400">{item.answer}</p>
</div>
<div className="flex gap-1 flex-shrink-0">
<button
title={item.active ? 'Скрыть с сайта' : 'Показать на сайте'}
onClick={() => onSave({ ...item, active: !item.active })}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800"
>
{item.active ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm('Удалить этот вопрос?') && onDelete(item.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
function NewFaqForm({ onCreate }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ question: '', answer: '' });
async function submit() {
if (!form.question.trim() || !form.answer.trim()) return;
await onCreate(form);
setForm({ question: '', answer: '' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500">
<Plus className="h-4 w-4" /> Новый вопрос
</button>
);
}
return (
<div className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Вопрос" value={form.question} onChange={(e) => setForm({ ...form, question: e.target.value })} />
<textarea className={inputCls} rows={3} placeholder="Ответ" value={form.answer} onChange={(e) => setForm({ ...form, answer: e.target.value })} />
<div className="flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Добавить</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
export default function FaqEditor() {
const [faqs, setFaqs] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setFaqs(await api.adminGetFaqs());
setLoading(false);
}
useEffect(() => {
load();
}, []);
if (loading) return <p className="text-sm text-slate-400">Загружаем вопросы...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">FAQ</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Вопросы показываются на сайте в том порядке, в котором были созданы.
</p>
<div className="space-y-3 mb-5">
{faqs.map((item) => (
<FaqCard
key={item.id}
item={item}
onSave={async (data) => { await api.adminUpdateFaq(item.id, data); load(); }}
onDelete={async (id) => { await api.adminDeleteFaq(id); load(); }}
/>
))}
</div>
<NewFaqForm onCreate={async (data) => { await api.adminCreateFaq(data); load(); }} />
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Terminal } from 'lucide-react';
import { api } from '../lib/api.js';
export default function Login() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setBusy(true);
setError('');
try {
await api.login(username, password);
navigate('/admin/dashboard');
} catch (err) {
setError(err.message === 'invalid credentials' ? 'Неверный логин или пароль' : 'Не удалось войти, попробуйте ещё раз');
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-5 bg-slate-50 dark:bg-slate-950">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border p-6 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center gap-2 mb-1">
<Terminal className="h-5 w-5 text-emerald-500" />
<h1 className="font-bold font-display text-base">Админ-панель</h1>
</div>
<p className="text-xs mb-5 text-slate-400 dark:text-slate-500">Доступ только для сотрудников TopSysOps.</p>
<label className="text-xs block mb-1">Логин</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
className="w-full rounded-md border px-3 py-2 text-sm bg-transparent mb-3 border-slate-300 dark:border-slate-700"
/>
<label className="text-xs block mb-1">Пароль</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(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 && <p className="text-xs text-rose-500 mb-4">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60"
>
{busy ? 'Входим...' : 'Войти'}
</button>
</form>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Save, X, EyeOff, Eye, ChevronUp, ChevronDown } from 'lucide-react';
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';
function PromoCard({ promo, onSave, onDelete, onMoveUp, onMoveDown, isFirst, isLast }) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(promo);
useEffect(() => setForm(promo), [promo]);
if (editing) {
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Название акции" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<div className="grid sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Цена по акции, напр. 35 000 ₽" value={form.price_text} onChange={(e) => setForm({ ...form, price_text: e.target.value })} />
<input className={inputCls} placeholder="Старая цена (необязательно)" value={form.old_price_text || ''} onChange={(e) => setForm({ ...form, old_price_text: e.target.value })} />
</div>
<textarea className={inputCls} rows={2} placeholder="Условия акции" value={form.description || ''} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="flex gap-2">
<button onClick={() => { onSave(form); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={() => { setForm(promo); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
return (
<div className={cx('rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800', !promo.active && 'opacity-50')}>
<div className="flex items-start justify-between gap-3 mb-2">
<h3 className="font-bold font-display text-[15px]">{promo.title}</h3>
<div className="flex gap-1 flex-shrink-0">
<button
title="Переместить раньше"
onClick={onMoveUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
title="Переместить позже"
onClick={onMoveDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
title={promo.active ? 'Скрыть с сайта' : 'Показать на сайте'}
onClick={() => onSave({ ...promo, active: !promo.active })}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800"
>
{promo.active ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm(`Удалить акцию «${promo.title}»?`) && onDelete(promo.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex items-baseline gap-2 mb-2">
<span className="text-lg font-bold text-emerald-500 font-mono">{promo.price_text}</span>
{promo.old_price_text && <span className="text-sm line-through text-slate-400 dark:text-slate-600 font-mono">{promo.old_price_text}</span>}
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">{promo.description}</p>
</div>
);
}
function NewPromoForm({ onCreate }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ title: '', price_text: '', old_price_text: '', description: '' });
async function submit() {
if (!form.title.trim() || !form.price_text.trim()) return;
await onCreate({ ...form, old_price_text: form.old_price_text || null });
setForm({ title: '', price_text: '', old_price_text: '', description: '' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500">
<Plus className="h-4 w-4" /> Новая акция
</button>
);
}
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Название акции" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<div className="grid sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Цена по акции, напр. 35 000 ₽" value={form.price_text} onChange={(e) => setForm({ ...form, price_text: e.target.value })} />
<input className={inputCls} placeholder="Старая цена (необязательно)" value={form.old_price_text} onChange={(e) => setForm({ ...form, old_price_text: e.target.value })} />
</div>
<textarea className={inputCls} rows={2} placeholder="Условия акции" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Создать</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
export default function PromotionsEditor() {
const [promotions, setPromotions] = useState([]);
const [loading, setLoading] = useState(true);
const [reordering, setReordering] = useState(false);
async function load() {
setLoading(true);
setPromotions(await api.adminGetPromotions());
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function move(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= promotions.length || reordering) return;
const a = promotions[index];
const b = promotions[targetIndex];
setReordering(true);
try {
await Promise.all([
api.adminUpdatePromotion(a.id, { sort_order: b.sort_order }),
api.adminUpdatePromotion(b.id, { sort_order: a.sort_order }),
]);
await load();
} finally {
setReordering(false);
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем акции...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Акции</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Когда акция заканчивается, просто скройте её кнопкой с глазом - не обязательно удалять.
Стрелками можно менять порядок, в котором акции идут на сайте.
</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-5">
{promotions.map((p, i) => (
<PromoCard
key={p.id}
promo={p}
isFirst={i === 0}
isLast={i === promotions.length - 1}
onMoveUp={() => move(i, -1)}
onMoveDown={() => move(i, 1)}
onSave={async (data) => { await api.adminUpdatePromotion(p.id, data); load(); }}
onDelete={async (id) => { await api.adminDeletePromotion(id); load(); }}
/>
))}
</div>
<NewPromoForm onCreate={async (data) => { await api.adminCreatePromotion(data); load(); }} />
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { Check, X, Star, Trash2 } from 'lucide-react';
import { api } from '../lib/api.js';
import StatusDot from '../components/StatusDot.jsx';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
function ReviewRow({ r, onApprove, onReject, onDelete }) {
return (
<div className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-start justify-between gap-3 mb-2">
<div>
<div className="text-sm font-medium">
{r.name}
{r.role ? ` · ${r.role}` : ''}
</div>
<div className="flex items-center gap-1 mt-1">
{[1, 2, 3, 4, 5].map((n) => (
<Star key={n} className={cx('h-3.5 w-3.5', n <= r.rating ? 'fill-amber-400 text-amber-400' : 'text-slate-300')} />
))}
</div>
</div>
<StatusDot tone={r.status === 'pending' ? 'warn' : r.status === 'approved' ? 'ok' : 'danger'} label={r.status} mono />
</div>
<p className="text-sm mb-3">{r.text}</p>
<div className="flex gap-2">
{r.status !== 'approved' && (
<button
onClick={() => onApprove(r.id)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600"
>
<Check className="h-3.5 w-3.5" /> Одобрить
</button>
)}
{r.status !== 'rejected' && (
<button
onClick={() => onReject(r.id)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:hover:bg-slate-800"
>
<X className="h-3.5 w-3.5" /> Отклонить
</button>
)}
<button
onClick={() => onDelete(r.id)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30 ml-auto"
>
<Trash2 className="h-3.5 w-3.5" /> Удалить
</button>
</div>
</div>
);
}
export default function ReviewsManager() {
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setReviews(await api.adminGetReviews());
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function setStatus(id, status) {
setReviews((rs) => rs.map((r) => (r.id === id ? { ...r, status } : r)));
try {
await api.adminSetReviewStatus(id, status);
} catch (e) {
load();
}
}
async function remove(id) {
if (!window.confirm('Удалить отзыв без возможности восстановления?')) return;
setReviews((rs) => rs.filter((r) => r.id !== id));
try {
await api.adminDeleteReview(id);
} catch (e) {
load();
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем отзывы...</p>;
const pending = reviews.filter((r) => r.status === 'pending');
const rest = reviews.filter((r) => r.status !== 'pending');
return (
<div>
<h1 className="font-bold mb-5 font-display text-lg">Модерация отзывов</h1>
<h2 className="text-sm font-medium text-amber-500 mb-3">На модерации ({pending.length})</h2>
<div className="space-y-3 mb-8">
{pending.length === 0 && (
<p className="text-sm text-slate-400">Новых отзывов нет - здесь появятся отзывы с формы на сайте.</p>
)}
{pending.map((r) => (
<ReviewRow key={r.id} r={r} onApprove={(id) => setStatus(id, 'approved')} onReject={(id) => setStatus(id, 'rejected')} onDelete={remove} />
))}
</div>
<h2 className="text-sm font-medium mb-3 text-slate-400">История ({rest.length})</h2>
<div className="space-y-3">
{rest.map((r) => (
<ReviewRow key={r.id} r={r} onApprove={(id) => setStatus(id, 'approved')} onReject={(id) => setStatus(id, 'rejected')} onDelete={remove} />
))}
</div>
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Save, X, EyeOff, Eye, ChevronUp, ChevronDown } from 'lucide-react';
import { api } from '../lib/api.js';
import { ICONS } from '../lib/icons.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';
function ItemRow({ item, onSave, onDelete, onMoveUp, onMoveDown, isFirst, isLast }) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(item);
useEffect(() => setForm(item), [item]);
if (editing) {
return (
<div className="grid sm:grid-cols-2 gap-2 p-3 rounded-md bg-slate-50 dark:bg-slate-950/50">
<input className={inputCls} placeholder="Название" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input className={inputCls} placeholder="Цена, напр. от 20 000 ₽" value={form.price_text} onChange={(e) => setForm({ ...form, price_text: e.target.value })} />
<input className={inputCls} placeholder="Период, напр. /мес" value={form.period || ''} onChange={(e) => setForm({ ...form, period: e.target.value })} />
<input className={inputCls} placeholder="Примечание" value={form.note || ''} onChange={(e) => setForm({ ...form, note: e.target.value })} />
<div className="sm:col-span-2 flex gap-2">
<button onClick={() => { onSave(form); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={() => { setForm(item); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
return (
<div className={cx('flex items-start justify-between gap-3 p-3 rounded-md', !item.active && 'opacity-50')}>
<div className="min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-sm font-medium">{item.name}</span>
<span className="text-sm text-emerald-500 font-mono whitespace-nowrap">
{item.price_text}
{item.period}
</span>
</div>
{item.note && <p className="text-xs text-slate-400 dark:text-slate-500">{item.note}</p>}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
title="Переместить раньше"
onClick={onMoveUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
title="Переместить позже"
onClick={onMoveDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
title={item.active ? 'Скрыть с сайта' : 'Показать на сайте'}
onClick={() => onSave({ ...item, active: !item.active })}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800"
>
{item.active ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button onClick={() => onDelete(item.id)} className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30">
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
}
function NewItemForm({ categoryId, onCreate }) {
const [form, setForm] = useState({ name: '', price_text: '', period: '', note: '' });
const [open, setOpen] = useState(false);
async function submit() {
if (!form.name.trim() || !form.price_text.trim()) return;
await onCreate({ category_id: categoryId, ...form });
setForm({ name: '', price_text: '', period: '', note: '' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-1 text-xs text-emerald-500 font-medium px-3 py-2">
<Plus className="h-3.5 w-3.5" /> Добавить позицию
</button>
);
}
return (
<div className="grid sm:grid-cols-2 gap-2 p-3 rounded-md bg-slate-50 dark:bg-slate-950/50">
<input className={inputCls} placeholder="Название" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input className={inputCls} placeholder="Цена, напр. от 20 000 ₽" value={form.price_text} onChange={(e) => setForm({ ...form, price_text: e.target.value })} />
<input className={inputCls} placeholder="Период, напр. /мес" value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} />
<input className={inputCls} placeholder="Примечание" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
<div className="sm:col-span-2 flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Добавить</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
function CategoryCard({
category,
onUpdateCategory,
onDeleteCategory,
onCreateItem,
onUpdateItem,
onDeleteItem,
onMoveItem,
onMoveCategoryUp,
onMoveCategoryDown,
isFirst,
isLast,
}) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ title: category.title, description: category.description, icon: category.icon });
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
{editing ? (
<div className="space-y-2 mb-4">
<input className={inputCls} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="Название категории" />
<input className={inputCls} value={form.description || ''} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Описание" />
<select className={inputCls} value={form.icon} onChange={(e) => setForm({ ...form, icon: e.target.value })}>
{Object.keys(ICONS).map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
<div className="flex gap-2">
<button onClick={() => { onUpdateCategory(category.id, form); setEditing(false); }} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Сохранить</button>
<button onClick={() => setEditing(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
) : (
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-bold font-display text-[15px]">{category.title}</h3>
<p className="text-xs text-slate-400 dark:text-slate-500">{category.description}</p>
</div>
<div className="flex gap-1 flex-shrink-0">
<button
title="Переместить категорию раньше"
onClick={onMoveCategoryUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
title="Переместить категорию позже"
onClick={onMoveCategoryDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronDown className="h-4 w-4" />
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm(`Удалить категорию «${category.title}» вместе со всеми её позициями?`) && onDeleteCategory(category.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
)}
<div className="space-y-1 divide-y divide-slate-100 dark:divide-slate-800">
{category.items.map((item, i) => (
<ItemRow
key={item.id}
item={item}
isFirst={i === 0}
isLast={i === category.items.length - 1}
onMoveUp={() => onMoveItem(category, i, -1)}
onMoveDown={() => onMoveItem(category, i, 1)}
onSave={(data) => onUpdateItem(item.id, data)}
onDelete={onDeleteItem}
/>
))}
</div>
<div className="mt-2">
<NewItemForm categoryId={category.id} onCreate={onCreateItem} />
</div>
</div>
);
}
function NewCategoryForm({ onCreate }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ slug: '', title: '', description: '', icon: 'Server' });
async function submit() {
if (!form.slug.trim() || !form.title.trim()) return;
await onCreate(form);
setForm({ slug: '', title: '', description: '', icon: 'Server' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500">
<Plus className="h-4 w-4" /> Новая категория услуг
</button>
);
}
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Слаг (латиницей, напр. hosting)" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} />
<input className={inputCls} placeholder="Название категории" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<input className={inputCls} placeholder="Описание" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<select className={inputCls} value={form.icon} onChange={(e) => setForm({ ...form, icon: e.target.value })}>
{Object.keys(ICONS).map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
<div className="flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Создать</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
export default function ServicesEditor() {
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [reordering, setReordering] = useState(false);
async function load() {
setLoading(true);
setCategories(await api.adminGetServices());
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function moveCategory(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= categories.length || reordering) return;
const a = categories[index];
const b = categories[targetIndex];
setReordering(true);
try {
await Promise.all([
api.adminUpdateCategory(a.id, { sort_order: b.sort_order }),
api.adminUpdateCategory(b.id, { sort_order: a.sort_order }),
]);
await load();
} finally {
setReordering(false);
}
}
async function moveItem(category, index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= category.items.length || reordering) return;
const a = category.items[index];
const b = category.items[targetIndex];
setReordering(true);
try {
await Promise.all([
api.adminUpdateItem(a.id, { sort_order: b.sort_order }),
api.adminUpdateItem(b.id, { sort_order: a.sort_order }),
]);
await load();
} finally {
setReordering(false);
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем прайс-лист...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Прайс-лист</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Изменения появляются на сайте сразу после сохранения, без пересборки и перезапуска.
Стрелками можно менять порядок категорий и позиций внутри них.
</p>
<div className="space-y-5 mb-5">
{categories.map((cat, i) => (
<CategoryCard
key={cat.id}
category={cat}
isFirst={i === 0}
isLast={i === categories.length - 1}
onMoveCategoryUp={() => moveCategory(i, -1)}
onMoveCategoryDown={() => moveCategory(i, 1)}
onMoveItem={moveItem}
onUpdateCategory={async (id, data) => { await api.adminUpdateCategory(id, data); load(); }}
onDeleteCategory={async (id) => { await api.adminDeleteCategory(id); load(); }}
onCreateItem={async (data) => { await api.adminCreateItem(data); load(); }}
onUpdateItem={async (id, data) => { await api.adminUpdateItem(id, data); load(); }}
onDeleteItem={async (id) => { await api.adminDeleteItem(id); load(); }}
/>
))}
</div>
<NewCategoryForm onCreate={async (data) => { await api.adminCreateCategory(data); load(); }} />
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useState } from 'react';
import { Trash2, 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';
function PasswordForm() {
const [form, setForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
const [message, setMessage] = useState(null); // { type: 'ok' | 'error', text }
const [busy, setBusy] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setMessage(null);
if (form.newPassword !== form.confirm) {
setMessage({ type: 'error', text: 'Новые пароли не совпадают' });
return;
}
if (form.newPassword.length < 8) {
setMessage({ type: 'error', text: 'Новый пароль должен быть не короче 8 символов' });
return;
}
setBusy(true);
try {
await api.changePassword(form.currentPassword, form.newPassword);
setMessage({ type: 'ok', text: 'Пароль изменён' });
setForm({ currentPassword: '', newPassword: '', confirm: '' });
} catch (err) {
setMessage({
type: 'error',
text: err.message === 'current password is incorrect' ? 'Текущий пароль указан неверно' : 'Не удалось изменить пароль',
});
} finally {
setBusy(false);
}
}
return (
<form onSubmit={handleSubmit} className="max-w-sm rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium mb-1">Сменить пароль</h2>
<div>
<label className="text-xs block mb-1">Текущий пароль</label>
<input
type="password"
required
value={form.currentPassword}
onChange={(e) => setForm({ ...form, currentPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Новый пароль</label>
<input
type="password"
required
value={form.newPassword}
onChange={(e) => setForm({ ...form, newPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Повторите новый пароль</label>
<input
type="password"
required
value={form.confirm}
onChange={(e) => setForm({ ...form, confirm: e.target.value })}
className={fieldCls}
/>
</div>
{message && (
<p className={message.type === 'ok' ? 'text-xs text-emerald-500' : 'text-xs text-rose-500'}>{message.text}</p>
)}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60"
>
{busy ? 'Сохраняем...' : 'Сохранить'}
</button>
</form>
);
}
function AdminsSection() {
const [admins, setAdmins] = useState([]);
const [me, setMe] = useState(null);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({ username: '', password: '' });
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function load() {
setLoading(true);
const [list, meData] = await Promise.all([api.adminGetAdmins(), api.me()]);
setAdmins(list);
setMe(meData);
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function handleCreate(e) {
e.preventDefault();
setError('');
if (!form.username.trim()) {
setError('Укажите логин');
return;
}
if (form.password.length < 8) {
setError('Пароль должен быть не короче 8 символов');
return;
}
setBusy(true);
try {
await api.adminCreateAdminUser(form.username.trim(), form.password);
setForm({ username: '', password: '' });
load();
} catch (err) {
setError(err.message === 'this username is already taken' ? 'Такой логин уже занят' : 'Не удалось создать администратора');
} finally {
setBusy(false);
}
}
async function handleDelete(id) {
if (!window.confirm('Удалить этого администратора без возможности восстановления?')) return;
try {
await api.adminDeleteAdminUser(id);
load();
} catch (err) {
window.alert(
err.message === 'you cannot delete your own account while logged in'
? 'Нельзя удалить самого себя, пока вы вошли под этим аккаунтом'
: err.message === 'cannot delete the last remaining admin'
? 'Нельзя удалить последнего администратора - должен остаться хотя бы один'
: 'Не удалось удалить'
);
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем список администраторов...</p>;
return (
<div className="max-w-sm rounded-lg border p-5 space-y-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium">Администраторы</h2>
<div className="space-y-2">
{admins.map((a) => (
<div key={a.id} className="flex items-center justify-between gap-2 text-sm">
<span>
{a.username}
{me?.id === a.id && <span className="text-slate-400 dark:text-slate-500"> (вы)</span>}
</span>
{me?.id !== a.id && (
<button
onClick={() => handleDelete(a.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
title="Удалить администратора"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
<form onSubmit={handleCreate} className="space-y-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<label className="text-xs block mb-1">Новый администратор</label>
<input
placeholder="Логин"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className={fieldCls}
/>
<input
type="password"
placeholder="Пароль (от 8 символов)"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className={fieldCls}
/>
{error && <p className="text-xs text-rose-500">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-60 inline-flex items-center justify-center gap-1.5"
>
<UserPlus className="h-3.5 w-3.5" /> {busy ? 'Создаём...' : 'Добавить'}
</button>
</form>
</div>
);
}
export default function Settings() {
return (
<div>
<h1 className="font-bold mb-5 font-display text-lg">Настройки</h1>
<div className="grid sm:grid-cols-2 gap-5 items-start">
<PasswordForm />
<AdminsSection />
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const COLORS = {
ok: 'bg-emerald-500',
warn: 'bg-amber-500',
danger: 'bg-rose-500',
neutral: 'bg-slate-400',
};
export default function StatusDot({ tone = 'neutral', pulse, label, mono }) {
const dot = COLORS[tone] || COLORS.neutral;
return (
<span className="inline-flex items-center gap-2">
<span className="relative flex h-2 w-2">
{pulse && <span className={cx('animate-ping absolute inline-flex h-full w-full rounded-full opacity-60', dot)} />}
<span className={cx('relative inline-flex rounded-full h-2 w-2', dot)} />
</span>
{label && <span className={cx('text-xs', mono && 'font-mono')}>{label}</span>}
</span>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext({ dark: false, toggle: () => {} });
export const useTheme = () => useContext(ThemeContext);
export function ThemeProvider({ children }) {
const [dark, setDark] = useState(() => {
try {
const saved = localStorage.getItem('ts-theme');
if (saved) return saved === 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
} catch (e) {
return false;
}
});
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
try {
localStorage.setItem('ts-theme', dark ? 'dark' : 'light');
} catch (e) {
// localStorage unavailable (private mode etc.) - theme just won't persist
}
}, [dark]);
return (
<ThemeContext.Provider value={{ dark, toggle: () => setDark((d) => !d) }}>
{children}
</ThemeContext.Provider>
);
}
+20
View File
@@ -0,0 +1,20 @@
@import url('https://fonts.googleapis.com/css2?family=Unbounded:wght@500;700;900&family=Manrope:wght@400;500;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
overflow-x: hidden;
}
body {
font-family: 'Manrope', sans-serif;
overflow-x: hidden;
max-width: 100vw;
}
.ql-editor {
min-height: 220px;
font-size: 0.9rem;
}
+124
View File
@@ -0,0 +1,124 @@
const BASE = '/api';
async function request(path, options = {}) {
const res = await fetch(BASE + path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
let message = 'Request failed';
try {
const data = await res.json();
message = data.error || message;
} catch (e) {
// response had no JSON body
}
throw new Error(message);
}
if (res.status === 204) return null;
return res.json();
}
export const api = {
// public
getServices: () => request('/services'),
getPromotions: () => request('/promotions'),
getReviews: () => request('/reviews'),
getCases: () => request('/cases'),
getFaqs: () => request('/faqs'),
getBlogPosts: () => request('/blog'),
getBlogPost: (slug) => request(`/blog/${slug}`),
getContact: () => request('/contact'),
submitReview: (data) => request('/reviews', { method: 'POST', body: JSON.stringify(data) }),
trackVisit: () => request('/stats/visit', { method: 'POST' }).catch(() => {}),
// auth
login: (username, password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: () => request('/auth/logout', { method: 'POST' }),
me: () => request('/auth/me'),
changePassword: (currentPassword, newPassword) =>
request('/auth/change-password', { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }) }),
// admin: reviews
adminGetReviews: () => request('/admin/reviews'),
adminSetReviewStatus: (id, status) =>
request(`/admin/reviews/${id}`, { method: 'PATCH', body: JSON.stringify({ status }) }),
adminDeleteReview: (id) => request(`/admin/reviews/${id}`, { method: 'DELETE' }),
// admin: services / price list
adminGetServices: () => request('/admin/services'),
adminCreateCategory: (data) => request('/admin/services/categories', { method: 'POST', body: JSON.stringify(data) }),
adminUpdateCategory: (id, data) =>
request(`/admin/services/categories/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeleteCategory: (id) => request(`/admin/services/categories/${id}`, { method: 'DELETE' }),
adminCreateItem: (data) => request('/admin/services/items', { method: 'POST', body: JSON.stringify(data) }),
adminUpdateItem: (id, data) => request(`/admin/services/items/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeleteItem: (id) => request(`/admin/services/items/${id}`, { method: 'DELETE' }),
// admin: promotions
adminGetPromotions: () => request('/admin/promotions'),
adminCreatePromotion: (data) => request('/admin/promotions', { method: 'POST', body: JSON.stringify(data) }),
adminUpdatePromotion: (id, data) => request(`/admin/promotions/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeletePromotion: (id) => request(`/admin/promotions/${id}`, { method: 'DELETE' }),
// admin: stats
adminGetStats: () => request('/admin/stats'),
// admin: cases
adminGetCases: () => request('/admin/cases'),
adminCreateCase: (data) => request('/admin/cases', { method: 'POST', body: JSON.stringify(data) }),
adminUpdateCase: (id, data) => request(`/admin/cases/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeleteCase: (id) => request(`/admin/cases/${id}`, { method: 'DELETE' }),
// admin: faqs
adminGetFaqs: () => request('/admin/faqs'),
adminCreateFaq: (data) => request('/admin/faqs', { method: 'POST', body: JSON.stringify(data) }),
adminUpdateFaq: (id, data) => request(`/admin/faqs/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeleteFaq: (id) => request(`/admin/faqs/${id}`, { method: 'DELETE' }),
// admin: contact info
adminGetContact: () => request('/admin/contact'),
adminUpdateContact: (data) => request('/admin/contact', { method: 'PUT', body: JSON.stringify(data) }),
// admin: manage admin users
adminGetAdmins: () => request('/admin/admins'),
adminCreateAdminUser: (username, password) =>
request('/admin/admins', { method: 'POST', body: JSON.stringify({ username, password }) }),
adminDeleteAdminUser: (id) => request(`/admin/admins/${id}`, { method: 'DELETE' }),
// admin: ai assistant
aiStatus: () => request('/admin/ai/status'),
aiChat: (messages) => request('/admin/ai/chat', { method: 'POST', body: JSON.stringify({ messages }) }),
// admin: blog
adminGetBlogPosts: () => request('/admin/blog'),
adminCreateBlogPost: (data) => request('/admin/blog', { method: 'POST', body: JSON.stringify(data) }),
adminUpdateBlogPost: (id, data) => request(`/admin/blog/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeleteBlogPost: (id) => request(`/admin/blog/${id}`, { method: 'DELETE' }),
// admin: customers
adminGetCustomers: () => request('/admin/customers'),
adminGetCustomerMessages: (id) => request(`/admin/customers/${id}/messages`),
adminSendCustomerMessage: (id, body) =>
request(`/admin/customers/${id}/messages`, { method: 'POST', body: JSON.stringify({ body }) }),
// customer auth
customerRegister: (data) => request('/customer/register', { method: 'POST', body: JSON.stringify(data) }),
customerLogin: (email, password) =>
request('/customer/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
customerLogout: () => request('/customer/logout', { method: 'POST' }),
customerMe: () => request('/customer/me'),
customerUpdateMe: (data) => request('/customer/me', { method: 'PUT', body: JSON.stringify(data) }),
customerChangePassword: (currentPassword, newPassword) =>
request('/customer/change-password', { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }) }),
// account (protected customer area)
accountGetMessages: () => request('/account/messages'),
accountSendMessage: (body) => request('/account/messages', { method: 'POST', body: JSON.stringify({ body }) }),
accountAiStatus: () => request('/account/ai/status'),
accountAiChat: (messages) => request('/account/ai/chat', { method: 'POST', body: JSON.stringify({ messages }) }),
};
+7
View File
@@ -0,0 +1,7 @@
import { Bot, Headphones, ShieldCheck, Database, Network, Cloud, Globe, Server, Rocket, Handshake } from 'lucide-react';
export const ICONS = { Bot, Headphones, ShieldCheck, Database, Network, Cloud, Globe, Server, Rocket, Handshake };
export function getIcon(name) {
return ICONS[name] || Server;
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import { ThemeProvider } from './context/ThemeContext.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>
);