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

+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Save, X, EyeOff, Eye, ChevronUp, ChevronDown } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const inputCls = 'w-full rounded-md border px-2.5 py-1.5 text-sm bg-transparent border-slate-300 dark:border-slate-700';
function PromoCard({ promo, onSave, onDelete, onMoveUp, onMoveDown, isFirst, isLast }) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(promo);
useEffect(() => setForm(promo), [promo]);
if (editing) {
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Название акции" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<div className="grid sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Цена по акции, напр. 35 000 ₽" value={form.price_text} onChange={(e) => setForm({ ...form, price_text: e.target.value })} />
<input className={inputCls} placeholder="Старая цена (необязательно)" value={form.old_price_text || ''} onChange={(e) => setForm({ ...form, old_price_text: e.target.value })} />
</div>
<textarea className={inputCls} rows={2} placeholder="Условия акции" value={form.description || ''} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="flex gap-2">
<button onClick={() => { onSave(form); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={() => { setForm(promo); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
return (
<div className={cx('rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800', !promo.active && 'opacity-50')}>
<div className="flex items-start justify-between gap-3 mb-2">
<h3 className="font-bold font-display text-[15px]">{promo.title}</h3>
<div className="flex gap-1 flex-shrink-0">
<button
title="Переместить раньше"
onClick={onMoveUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
title="Переместить позже"
onClick={onMoveDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
title={promo.active ? 'Скрыть с сайта' : 'Показать на сайте'}
onClick={() => onSave({ ...promo, active: !promo.active })}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800"
>
{promo.active ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm(`Удалить акцию «${promo.title}»?`) && onDelete(promo.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex items-baseline gap-2 mb-2">
<span className="text-lg font-bold text-emerald-500 font-mono">{promo.price_text}</span>
{promo.old_price_text && <span className="text-sm line-through text-slate-400 dark:text-slate-600 font-mono">{promo.old_price_text}</span>}
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">{promo.description}</p>
</div>
);
}
function NewPromoForm({ onCreate }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ title: '', price_text: '', old_price_text: '', description: '' });
async function submit() {
if (!form.title.trim() || !form.price_text.trim()) return;
await onCreate({ ...form, old_price_text: form.old_price_text || null });
setForm({ title: '', price_text: '', old_price_text: '', description: '' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500">
<Plus className="h-4 w-4" /> Новая акция
</button>
);
}
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Название акции" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<div className="grid sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Цена по акции, напр. 35 000 ₽" value={form.price_text} onChange={(e) => setForm({ ...form, price_text: e.target.value })} />
<input className={inputCls} placeholder="Старая цена (необязательно)" value={form.old_price_text} onChange={(e) => setForm({ ...form, old_price_text: e.target.value })} />
</div>
<textarea className={inputCls} rows={2} placeholder="Условия акции" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Создать</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
export default function PromotionsEditor() {
const [promotions, setPromotions] = useState([]);
const [loading, setLoading] = useState(true);
const [reordering, setReordering] = useState(false);
async function load() {
setLoading(true);
setPromotions(await api.adminGetPromotions());
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function move(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= promotions.length || reordering) return;
const a = promotions[index];
const b = promotions[targetIndex];
setReordering(true);
try {
await Promise.all([
api.adminUpdatePromotion(a.id, { sort_order: b.sort_order }),
api.adminUpdatePromotion(b.id, { sort_order: a.sort_order }),
]);
await load();
} finally {
setReordering(false);
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем акции...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Акции</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Когда акция заканчивается, просто скройте её кнопкой с глазом - не обязательно удалять.
Стрелками можно менять порядок, в котором акции идут на сайте.
</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-5">
{promotions.map((p, i) => (
<PromoCard
key={p.id}
promo={p}
isFirst={i === 0}
isLast={i === promotions.length - 1}
onMoveUp={() => move(i, -1)}
onMoveDown={() => move(i, 1)}
onSave={async (data) => { await api.adminUpdatePromotion(p.id, data); load(); }}
onDelete={async (id) => { await api.adminDeletePromotion(id); load(); }}
/>
))}
</div>
<NewPromoForm onCreate={async (data) => { await api.adminCreatePromotion(data); load(); }} />
</div>
);
}