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