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
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/promos/${params.id}`, {
method: 'PATCH', headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
export async function DELETE(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/promos/${params.id}`, {
method: 'DELETE', headers: h(user.id),
});
return NextResponse.json(await res.json());
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function GET(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/promos`, { headers: h(user.id) });
return NextResponse.json(await res.json());
}
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/promos`, {
method: 'POST',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function POST(req) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/billing/apply-promo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
+59
View File
@@ -95,6 +95,9 @@ export default function BillingPage() {
</div>
)}
{/* Промокод */}
<PromoForm onApplied={() => load(0)} />
{/* Стоимость операций */}
<div className="card p-4 mb-6">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-3">Стоимость операций</div>
@@ -155,3 +158,59 @@ export default function BillingPage() {
</main>
);
}
function PromoForm({ onApplied }) {
const [code, setCode] = useState('');
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);
const [show, setShow] = useState(false);
async function apply() {
if (!code.trim()) return;
setBusy(true);
const res = await fetch('/api/billing/apply-promo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code.trim().toUpperCase() }),
}).then(r => r.json());
setBusy(false);
if (res.ok) {
setMsg(res.message);
setCode('');
setShow(false);
onApplied?.();
} else {
setMsg('Ошибка: ' + (res.error || 'неизвестно'));
}
setTimeout(() => setMsg(''), 4000);
}
return (
<div className="mb-6">
{!show ? (
<button onClick={() => setShow(true)} className="text-sm text-gray-500 hover:text-accent transition-colors">
🎁 Есть промокод?
</button>
) : (
<div className="card p-4">
<div className="flex gap-2">
<input
value={code}
onChange={e => setCode(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && apply()}
placeholder="ВВЕДИТЕ КОД"
className="input flex-1 font-mono text-sm py-1.5 tracking-widest"
autoFocus
maxLength={32}
/>
<button onClick={apply} disabled={busy || !code.trim()} className="btn-primary px-4 text-sm">
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Применить'}
</button>
<button onClick={() => { setShow(false); setCode(''); }} className="btn-ghost px-3 text-sm"></button>
</div>
{msg && <p className={`text-xs mt-2 ${msg.startsWith('Ошибка') ? 'text-red-400' : 'text-green-400'}`}>{msg}</p>}
</div>
)}
</div>
);
}
+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>
);
}