Initial commit: TopSysOps site + admin panel

Multi-page public site (Next.js), Node/Express backend with Postgres,
and Vite admin/account app, restructured from a single-page layout
into dedicated service, promotions, cases, about, FAQ, and blog pages
for SEO.

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

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { NavLink, Navigate, Outlet, useNavigate, Link } from 'react-router-dom';
import { LayoutDashboard, MessageCircle, Bot, UserCog, LogOut, Loader2, Terminal } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const NAV = [
{ to: '/account/dashboard', label: 'Обзор', icon: LayoutDashboard },
{ to: '/account/messages', label: 'Написать нам', icon: MessageCircle },
{ to: '/account/ai', label: 'ИИ-ассистент', icon: Bot },
{ to: '/account/profile', label: 'Профиль', icon: UserCog },
];
export default function AccountLayout() {
const navigate = useNavigate();
const [status, setStatus] = useState('checking'); // checking | authed | anon
const [customer, setCustomer] = useState(null);
useEffect(() => {
let cancelled = false;
api
.customerMe()
.then((data) => {
if (cancelled) return;
setCustomer(data);
setStatus('authed');
})
.catch(() => !cancelled && setStatus('anon'));
return () => {
cancelled = true;
};
}, []);
async function handleLogout() {
try {
await api.customerLogout();
} finally {
navigate('/account');
}
}
if (status === 'checking') {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
</div>
);
}
if (status === 'anon') {
return <Navigate to="/account" replace />;
}
return (
<div className="min-h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-slate-950">
<aside className="md:w-56 border-b md:border-b-0 md:border-r p-4 flex md:flex-col gap-2 overflow-x-auto md:overflow-x-visible border-slate-200 dark:border-slate-900">
<Link to="/" className="hidden md:flex items-center gap-2 mb-6 px-2">
<Terminal className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-bold font-display">TopSysOps</span>
</Link>
{customer && <p className="hidden md:block text-xs text-slate-400 dark:text-slate-500 px-2 mb-2 truncate">{customer.name}</p>}
{NAV.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors flex-shrink-0 whitespace-nowrap',
isActive
? 'bg-emerald-500 text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-900'
)
}
>
<Icon className="h-4 w-4" /> {item.label}
</NavLink>
);
})}
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium flex-shrink-0 whitespace-nowrap md:mt-auto text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900"
>
<LogOut className="h-4 w-4" /> Выйти
</button>
</aside>
<main className="flex-1 p-5 sm:p-8 max-w-3xl">
<Outlet context={{ customer }} />
</main>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from 'react';
import { Send, Bot, Loader2, Trash2 } from 'lucide-react';
import { api } from '../lib/api.js';
const STORAGE_KEY = 'ts-account-ai-chat';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
export default function AiChat() {
const [status, setStatus] = useState(null);
const [messages, setMessages] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
});
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const bottomRef = useRef(null);
useEffect(() => {
api.accountAiStatus().then(setStatus).catch(() => setStatus({ configured: false }));
}, []);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
} catch (e) {
// not critical if history can't be persisted
}
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function handleSend(e) {
e.preventDefault();
const text = input.trim();
if (!text || sending) return;
const next = [...messages, { role: 'user', content: text }];
setMessages(next);
setInput('');
setSending(true);
setError('');
try {
const { reply } = await api.accountAiChat(next);
setMessages([...next, { role: 'assistant', content: reply || '(пустой ответ)' }]);
} catch (err) {
setError('Не получилось получить ответ, попробуйте ещё раз чуть позже.');
} finally {
setSending(false);
}
}
function clearHistory() {
if (!window.confirm('Очистить историю чата?')) return;
setMessages([]);
}
return (
<div className="flex flex-col h-[calc(100vh-4rem)] md:h-[calc(100vh-2rem)] max-w-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="font-bold font-display text-lg flex items-center gap-2">
<Bot className="h-5 w-5 text-emerald-500" /> ИИ-ассистент
</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">Отвечает на вопросы об услугах и ценах, доступно 24/7.</p>
</div>
<button onClick={clearHistory} className="p-2 rounded-md text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-900" title="Очистить историю">
<Trash2 className="h-4 w-4" />
</button>
</div>
{status && !status.configured && (
<div className="rounded-lg border border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300 p-4 text-sm mb-4">
Ассистент временно недоступен. Напишите нам в разделе «Написать нам» - ответим лично.
</div>
)}
<div className="flex-1 overflow-y-auto rounded-lg border p-4 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-3">
{messages.length === 0 && (
<p className="text-sm text-slate-400">Спросите что-нибудь про наши услуги, цены или сроки.</p>
)}
{messages.map((m, i) => (
<div
key={i}
className={cx(
'max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
m.role === 'user' ? 'ml-auto bg-emerald-500 text-white' : 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
)}
>
{m.content}
</div>
))}
{sending && (
<div className="flex items-center gap-2 text-xs text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Ассистент печатает...
</div>
)}
<div ref={bottomRef} />
</div>
{error && <p className="text-xs text-rose-500 mb-2">{error}</p>}
<form onSubmit={handleSend} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..."
disabled={status && !status.configured}
className="flex-1 rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700 disabled:opacity-50"
/>
<button
type="submit"
disabled={sending || (status && !status.configured)}
className="px-4 py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { LogIn } from 'lucide-react';
import { api } from '../lib/api.js';
export default function Login() {
const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setBusy(true);
setError('');
try {
await api.customerLogin(form.email, form.password);
navigate('/account/dashboard');
} catch (err) {
setError(err.message === 'invalid credentials' ? 'Неверный email или пароль' : 'Не удалось войти, попробуйте ещё раз');
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-5 bg-slate-50 dark:bg-slate-950">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border p-6 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center gap-2 mb-1">
<LogIn className="h-5 w-5 text-emerald-500" />
<h1 className="font-bold font-display text-base">Личный кабинет</h1>
</div>
<p className="text-xs mb-5 text-slate-400 dark:text-slate-500">
Войдите, чтобы написать нам или задать вопрос ИИ-ассистенту.
</p>
<label className="text-xs block mb-1">Email</label>
<input
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
autoComplete="email"
className="w-full rounded-md border px-3 py-2 text-sm bg-transparent mb-3 border-slate-300 dark:border-slate-700"
/>
<label className="text-xs block mb-1">Пароль</label>
<input
type="password"
required
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
autoComplete="current-password"
className="w-full rounded-md border px-3 py-2 text-sm bg-transparent mb-4 border-slate-300 dark:border-slate-700"
/>
{error && <p className="text-xs text-rose-500 mb-4">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60 mb-3"
>
{busy ? 'Входим...' : 'Войти'}
</button>
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
Ещё нет аккаунта?{' '}
<Link to="/account/register" className="text-emerald-500 hover:underline">
Зарегистрироваться
</Link>
</p>
</form>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from 'react';
import { Send } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
export default function Messages() {
const [messages, setMessages] = useState([]);
const [loading, setLoading] = useState(true);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const bottomRef = useRef(null);
async function load() {
setLoading(true);
setMessages(await api.accountGetMessages());
setLoading(false);
}
useEffect(() => {
load();
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function handleSend(e) {
e.preventDefault();
const text = input.trim();
if (!text || sending) return;
setSending(true);
try {
const msg = await api.accountSendMessage(text);
setMessages((m) => [...m, msg]);
setInput('');
} catch (err) {
// the user can just retry
} finally {
setSending(false);
}
}
return (
<div className="flex flex-col h-[calc(100vh-4rem)] md:h-[calc(100vh-2rem)] max-w-2xl">
<h1 className="font-bold mb-1 font-display text-lg">Написать нам</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Сообщения видит команда TopSysOps и ответит вам здесь же.</p>
<div className="flex-1 overflow-y-auto rounded-lg border p-4 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-3">
{loading && <p className="text-sm text-slate-400">Загружаем переписку...</p>}
{!loading && messages.length === 0 && (
<p className="text-sm text-slate-400">Пока нет сообщений - напишите нам, если возникли вопросы.</p>
)}
{messages.map((m) => (
<div
key={m.id}
className={cx(
'max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
m.sender === 'customer' ? 'ml-auto bg-emerald-500 text-white' : 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
)}
>
{m.body}
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSend} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..."
className="flex-1 rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700"
/>
<button
type="submit"
disabled={sending}
className="px-4 py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import { useOutletContext, Link } from 'react-router-dom';
import { Bot, MessageCircle, Tag } from 'lucide-react';
import { api } from '../lib/api.js';
export default function Overview() {
const { customer } = useOutletContext();
const [promotions, setPromotions] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.getPromotions()
.then(setPromotions)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Здравствуйте, {customer?.name}!</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">Рады видеть вас в личном кабинете TopSysOps.</p>
<div className="grid sm:grid-cols-2 gap-4 mb-8">
<Link
to="/account/messages"
className="rounded-lg border p-4 hover:border-emerald-500 transition-colors bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800"
>
<MessageCircle className="h-5 w-5 text-emerald-500 mb-2" />
<div className="text-sm font-medium">Написать нам</div>
<div className="text-xs text-slate-400 dark:text-slate-500">Личная переписка с командой TopSysOps</div>
</Link>
<Link
to="/account/ai"
className="rounded-lg border p-4 hover:border-emerald-500 transition-colors bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800"
>
<Bot className="h-5 w-5 text-emerald-500 mb-2" />
<div className="text-sm font-medium">ИИ-ассистент</div>
<div className="text-xs text-slate-400 dark:text-slate-500">Быстрые ответы на вопросы, доступно 24/7</div>
</Link>
</div>
<h2 className="text-sm font-medium mb-3 flex items-center gap-2">
<Tag className="h-4 w-4 text-emerald-500" /> Акции специально для вас
</h2>
{loading && <p className="text-sm text-slate-400">Загружаем...</p>}
<div className="grid sm:grid-cols-2 gap-4">
{promotions.slice(0, 4).map((p) => (
<div key={p.id} className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="text-sm font-medium mb-1">{p.title}</div>
<div className="text-emerald-500 font-mono text-sm">{p.price_text}</div>
</div>
))}
{!loading && promotions.length === 0 && <p className="text-sm text-slate-400">Сейчас нет активных акций.</p>}
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { api } from '../lib/api.js';
const fieldCls = 'w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700';
export default function Profile() {
const { customer } = useOutletContext();
const [form, setForm] = useState({ name: '', company: '', phone: '' });
const [savedMsg, setSavedMsg] = useState('');
const [busy, setBusy] = useState(false);
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
const [pwMsg, setPwMsg] = useState(null);
useEffect(() => {
if (customer) setForm({ name: customer.name || '', company: customer.company || '', phone: customer.phone || '' });
}, [customer]);
async function saveProfile(e) {
e.preventDefault();
setBusy(true);
setSavedMsg('');
try {
await api.customerUpdateMe(form);
setSavedMsg('Сохранено');
} catch (err) {
setSavedMsg('Не удалось сохранить');
} finally {
setBusy(false);
}
}
async function changePassword(e) {
e.preventDefault();
setPwMsg(null);
if (pwForm.newPassword !== pwForm.confirm) {
setPwMsg({ type: 'error', text: 'Пароли не совпадают' });
return;
}
if (pwForm.newPassword.length < 8) {
setPwMsg({ type: 'error', text: 'Новый пароль должен быть не короче 8 символов' });
return;
}
try {
await api.customerChangePassword(pwForm.currentPassword, pwForm.newPassword);
setPwMsg({ type: 'ok', text: 'Пароль изменён' });
setPwForm({ currentPassword: '', newPassword: '', confirm: '' });
} catch (err) {
setPwMsg({
type: 'error',
text: err.message === 'current password is incorrect' ? 'Текущий пароль указан неверно' : 'Не удалось изменить пароль',
});
}
}
return (
<div>
<h1 className="font-bold mb-5 font-display text-lg">Профиль</h1>
<form onSubmit={saveProfile} className="max-w-sm rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-6">
<h2 className="text-sm font-medium mb-1">Данные аккаунта</h2>
<div>
<label className="text-xs block mb-1">Email</label>
<input value={customer?.email || ''} disabled className={`${fieldCls} opacity-60`} />
</div>
<div>
<label className="text-xs block mb-1">Имя</label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Компания</label>
<input value={form.company} onChange={(e) => setForm({ ...form, company: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Телефон</label>
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={fieldCls} />
</div>
{savedMsg && <p className="text-xs text-emerald-500">{savedMsg}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-60"
>
{busy ? 'Сохраняем...' : 'Сохранить'}
</button>
</form>
<form onSubmit={changePassword} className="max-w-sm rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium mb-1">Сменить пароль</h2>
<div>
<label className="text-xs block mb-1">Текущий пароль</label>
<input
type="password"
required
value={pwForm.currentPassword}
onChange={(e) => setPwForm({ ...pwForm, currentPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Новый пароль</label>
<input
type="password"
required
value={pwForm.newPassword}
onChange={(e) => setPwForm({ ...pwForm, newPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Повторите новый пароль</label>
<input
type="password"
required
value={pwForm.confirm}
onChange={(e) => setPwForm({ ...pwForm, confirm: e.target.value })}
className={fieldCls}
/>
</div>
{pwMsg && <p className={pwMsg.type === 'ok' ? 'text-xs text-emerald-500' : 'text-xs text-rose-500'}>{pwMsg.text}</p>}
<button type="submit" className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600">
Сохранить пароль
</button>
</form>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserPlus } from 'lucide-react';
import { api } from '../lib/api.js';
const fieldCls = 'w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700';
export default function Register() {
const navigate = useNavigate();
const [form, setForm] = useState({ name: '', email: '', password: '', company: '', phone: '' });
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError('');
if (form.password.length < 8) {
setError('Пароль должен быть не короче 8 символов');
return;
}
setBusy(true);
try {
await api.customerRegister(form);
navigate('/account/dashboard');
} catch (err) {
setError(
err.message === 'this email is already registered'
? 'Этот email уже зарегистрирован'
: 'Не удалось зарегистрироваться, попробуйте ещё раз'
);
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-5 py-10 bg-slate-50 dark:bg-slate-950">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border p-6 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center gap-2 mb-1">
<UserPlus className="h-5 w-5 text-emerald-500" />
<h1 className="font-bold font-display text-base">Регистрация</h1>
</div>
<p className="text-xs mb-5 text-slate-400 dark:text-slate-500">Доступ к ИИ-ассистенту и личной переписке с нами.</p>
<div className="space-y-3 mb-4">
<div>
<label className="text-xs block mb-1">Имя*</label>
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Email*</label>
<input
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
autoComplete="email"
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Пароль* (от 8 символов)</label>
<input
type="password"
required
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
autoComplete="new-password"
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Компания</label>
<input value={form.company} onChange={(e) => setForm({ ...form, company: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Телефон</label>
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={fieldCls} />
</div>
</div>
{error && <p className="text-xs text-rose-500 mb-4">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60 mb-3"
>
{busy ? 'Регистрируем...' : 'Зарегистрироваться'}
</button>
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
Уже есть аккаунт?{' '}
<Link to="/account" className="text-emerald-500 hover:underline">
Войти
</Link>
</p>
</form>
</div>
);
}