Files
topsysops-app/admin-app/src/admin/PromotionsEditor.jsx
T
ogrechkoandClaude Sonnet 5 130c5382cd 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
2026-08-25 23:12:16 +03:00

182 lines
8.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}