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:
Ник (Claude)
2026-06-13 09:37:19 +03:00
parent e5f6662aed
commit b620927c25
6 changed files with 353 additions and 1 deletions
+4 -1
View File
@@ -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>
+222
View File
@@ -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>
);
}