forked from admin/zeropost-tool
feat: promo codes UI + apply on /billing
AdminPromos.js: создание/список/toggle/удаление промокодов
auto-generated code, type (credits/%), max_uses, expires, description
AdminPanel: раздел Промокоды между Тарифами и Пользователями
/billing page: кнопка '🎁 Есть промокод?' → форма ввода → apply-promo API
API routes: /api/admin/promos, /api/admin/promos/[id], /api/billing/apply-promo
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap } from 'lucide-react';
|
||||
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import AdminBilling from './admin/AdminBilling';
|
||||
import AdminUsers from './admin/AdminUsers';
|
||||
import AdminPromos from './admin/AdminPromos';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Sidebar navigation
|
||||
@@ -15,6 +16,7 @@ const SECTIONS = [
|
||||
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
|
||||
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
|
||||
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
|
||||
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
|
||||
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
|
||||
];
|
||||
|
||||
@@ -64,6 +66,7 @@ export default function AdminPanel({ initialSection = 'settings' }) {
|
||||
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
||||
{section === 'spending' && <SpendingSection />}
|
||||
{section === 'plans' && <PlansSection />}
|
||||
{section === 'promos' && <AdminPromos />}
|
||||
{section === 'billing' && <AdminUsers />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, RefreshCw, Loader2, Check, Copy, Tag, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||
|
||||
function randomCode() {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
return Array.from({length:8}, () => chars[Math.floor(Math.random()*chars.length)]).join('');
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '∞';
|
||||
return new Date(s).toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
export default function AdminPromos() {
|
||||
const [promos, setPromos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [showForm,setShowForm]= useState(false);
|
||||
|
||||
// Form
|
||||
const [code, setCode] = useState(randomCode());
|
||||
const [type, setType] = useState('credits');
|
||||
const [value, setValue] = useState('100');
|
||||
const [maxUses, setMaxUses] = useState('1');
|
||||
const [expiresAt, setExpiresAt]= useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/promos').then(r => r.json());
|
||||
setPromos(Array.isArray(res) ? res : []);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function create() {
|
||||
if (!code || !value) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/promos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code, type, value: parseInt(value),
|
||||
max_uses: parseInt(maxUses) || 1,
|
||||
expires_at: expiresAt || null,
|
||||
description: desc || null,
|
||||
}),
|
||||
}).then(r => r.json());
|
||||
if (res.error) { setMsg('Ошибка: ' + res.error); }
|
||||
else {
|
||||
setMsg('Промокод создан ✓');
|
||||
setShowForm(false);
|
||||
setCode(randomCode());
|
||||
setValue('100'); setMaxUses('1'); setExpiresAt(''); setDesc('');
|
||||
load();
|
||||
}
|
||||
} catch { setMsg('Ошибка соединения'); }
|
||||
setSaving(false);
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
}
|
||||
|
||||
async function toggleActive(promo) {
|
||||
await fetch(`/api/admin/promos/${promo.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: !promo.is_active }),
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
async function deletePromo(id) {
|
||||
if (!confirm('Удалить промокод?')) return;
|
||||
await fetch(`/api/admin/promos/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
}
|
||||
|
||||
function copyCode(code) {
|
||||
navigator.clipboard.writeText(code).catch(() => {});
|
||||
setMsg('Скопировано: ' + code);
|
||||
setTimeout(() => setMsg(''), 2000);
|
||||
}
|
||||
|
||||
const TYPE_LABELS = { credits: '🎁 Кредиты', discount_pct: '% Скидка' };
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Промокоды</h2>
|
||||
<div className="flex gap-2">
|
||||
{msg && <span className="text-sm text-green-400 self-center">{msg}</span>}
|
||||
<button onClick={() => load()} className="btn-ghost p-2"><RefreshCw className="w-4 h-4" /></button>
|
||||
<button onClick={() => setShowForm(v => !v)} className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
|
||||
<Plus className="w-4 h-4" /> Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Форма создания */}
|
||||
{showForm && (
|
||||
<div className="card p-5 border-accent/30 bg-accent/5 space-y-4">
|
||||
<h3 className="font-medium text-sm">Новый промокод</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Код</label>
|
||||
<div className="flex gap-1">
|
||||
<input value={code} onChange={e => setCode(e.target.value.toUpperCase())}
|
||||
className="input flex-1 font-mono text-sm py-1.5" maxLength={32} />
|
||||
<button onClick={() => setCode(randomCode())} className="btn-ghost p-2 text-xs" title="Генерировать">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Тип</label>
|
||||
<select value={type} onChange={e => setType(e.target.value)} className="input w-full text-sm py-1.5">
|
||||
<option value="credits">🎁 Бонусные кредиты</option>
|
||||
<option value="discount_pct">% Скидка на подписку</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-xs mb-1">{type === 'credits' ? 'Кредитов' : 'Скидка %'}</label>
|
||||
<input type="number" value={value} onChange={e => setValue(e.target.value)}
|
||||
className="input w-full text-sm py-1.5" min={1} max={type === 'discount_pct' ? 100 : 10000} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Макс. использований (-1 = ∞)</label>
|
||||
<input type="number" value={maxUses} onChange={e => setMaxUses(e.target.value)}
|
||||
className="input w-full text-sm py-1.5" min={-1} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Истекает (необязательно)</label>
|
||||
<input type="date" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
|
||||
className="input w-full text-sm py-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Описание</label>
|
||||
<input value={desc} onChange={e => setDesc(e.target.value)}
|
||||
placeholder="Для партнёров..." className="input w-full text-sm py-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={create} disabled={saving || !code || !value}
|
||||
className="btn-primary px-4 py-2 text-sm flex items-center gap-1.5">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Создать
|
||||
</button>
|
||||
<button onClick={() => setShowForm(false)} className="btn-ghost px-4 py-2 text-sm">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список */}
|
||||
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
|
||||
|
||||
{!loading && promos.length === 0 && (
|
||||
<div className="py-12 text-center text-gray-500 text-sm">
|
||||
<Tag className="w-10 h-10 mx-auto mb-3 opacity-20" />
|
||||
Промокодов пока нет
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && promos.length > 0 && (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-surface2 text-xs text-gray-400">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left">Код</th>
|
||||
<th className="px-4 py-2.5 text-center">Тип / Ценность</th>
|
||||
<th className="px-4 py-2.5 text-center">Использован</th>
|
||||
<th className="px-4 py-2.5 text-center">Истекает</th>
|
||||
<th className="px-4 py-2.5 text-center">Статус</th>
|
||||
<th className="px-4 py-2.5 text-center">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{promos.map(p => (
|
||||
<tr key={p.id} className={`border-t border-border ${!p.is_active ? 'opacity-50' : ''} hover:bg-surface2/50`}>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono font-bold text-accent">{p.code}</code>
|
||||
<button onClick={() => copyCode(p.code)} className="btn-ghost p-1" title="Скопировать">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{p.description && <div className="text-xs text-gray-500 mt-0.5">{p.description}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<div className="text-xs text-gray-400">{TYPE_LABELS[p.type]}</div>
|
||||
<div className="font-bold">{p.type === 'credits' ? `+${p.value} кр` : `${p.value}%`}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<span className={p.uses_real >= p.max_uses && p.max_uses !== -1 ? 'text-red-400' : ''}>
|
||||
{p.uses_real} / {p.max_uses === -1 ? '∞' : p.max_uses}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-center text-xs text-gray-400">{fmtDate(p.expires_at)}</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<button onClick={() => toggleActive(p)}>
|
||||
{p.is_active
|
||||
? <ToggleRight className="w-5 h-5 text-green-400 mx-auto" />
|
||||
: <ToggleLeft className="w-5 h-5 text-gray-500 mx-auto" />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<button onClick={() => deletePromo(p.id)}
|
||||
className="btn-ghost p-1.5 text-gray-500 hover:text-red-400 mx-auto">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user