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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user