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:
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -95,6 +95,9 @@ export default function BillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Промокод */}
|
||||||
|
<PromoForm onApplied={() => load(0)} />
|
||||||
|
|
||||||
{/* Стоимость операций */}
|
{/* Стоимость операций */}
|
||||||
<div className="card p-4 mb-6">
|
<div className="card p-4 mb-6">
|
||||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-3">Стоимость операций</div>
|
<div className="text-xs text-gray-400 uppercase tracking-wide mb-3">Стоимость операций</div>
|
||||||
@@ -155,3 +158,59 @@ export default function BillingPage() {
|
|||||||
</main>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
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 Link from 'next/link';
|
||||||
import AdminBilling from './admin/AdminBilling';
|
import AdminBilling from './admin/AdminBilling';
|
||||||
import AdminUsers from './admin/AdminUsers';
|
import AdminUsers from './admin/AdminUsers';
|
||||||
|
import AdminPromos from './admin/AdminPromos';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
// Sidebar navigation
|
// Sidebar navigation
|
||||||
@@ -15,6 +16,7 @@ const SECTIONS = [
|
|||||||
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
|
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
|
||||||
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
|
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
|
||||||
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
|
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
|
||||||
|
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
|
||||||
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
|
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ export default function AdminPanel({ initialSection = 'settings' }) {
|
|||||||
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
||||||
{section === 'spending' && <SpendingSection />}
|
{section === 'spending' && <SpendingSection />}
|
||||||
{section === 'plans' && <PlansSection />}
|
{section === 'plans' && <PlansSection />}
|
||||||
|
{section === 'promos' && <AdminPromos />}
|
||||||
{section === 'billing' && <AdminUsers />}
|
{section === 'billing' && <AdminUsers />}
|
||||||
</div>
|
</div>
|
||||||
</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