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>
);
}