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:
commit
130c5382cd
108 files changed
+7146
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
@@ -0,0 +1,28 @@
|
||||
{$DOMAIN} {
|
||||
encode gzip
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy backend:4000
|
||||
}
|
||||
|
||||
# /assets* - собранные JS/CSS файлы admin-app (Vite кладёт их сюда
|
||||
# независимо от того, с /admin или /account их запросили).
|
||||
@app path /admin* /account* /assets*
|
||||
handle @app {
|
||||
root * /srv
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
# Всё остальное (главная страница, /blog и т.д.) обслуживает Next.js -
|
||||
# он же сам отдаёт /sitemap.xml и /robots.txt.
|
||||
handle {
|
||||
reverse_proxy nextjs:3000
|
||||
}
|
||||
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options SAMEORIGIN
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM caddy:2-alpine
|
||||
|
||||
COPY --from=build /app/dist /srv
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<title>TopSysOps — панель управления</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Админ-панель и личный кабинет TopSysOps."
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "topsysops-admin-app",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.383.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"recharts": "^2.12.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }) }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: ['Unbounded', 'sans-serif'],
|
||||
body: ['Manrope', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user