From b620927c25353848d755c078a52383be17b8dfa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=20=28Claude=29?= Date: Sat, 13 Jun 2026 09:37:19 +0300 Subject: [PATCH] feat: promo codes UI + apply on /billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/admin/promos/[id]/route.js | 26 ++++ app/api/admin/promos/route.js | 25 +++ app/api/billing/apply-promo/route.js | 17 ++ app/billing/page.js | 59 +++++++ components/AdminPanel.js | 5 +- components/admin/AdminPromos.js | 222 +++++++++++++++++++++++++++ 6 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/promos/[id]/route.js create mode 100644 app/api/admin/promos/route.js create mode 100644 app/api/billing/apply-promo/route.js create mode 100644 components/admin/AdminPromos.js diff --git a/app/api/admin/promos/[id]/route.js b/app/api/admin/promos/[id]/route.js new file mode 100644 index 0000000..0693d61 --- /dev/null +++ b/app/api/admin/promos/[id]/route.js @@ -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()); +} diff --git a/app/api/admin/promos/route.js b/app/api/admin/promos/route.js new file mode 100644 index 0000000..da48543 --- /dev/null +++ b/app/api/admin/promos/route.js @@ -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 }); +} diff --git a/app/api/billing/apply-promo/route.js b/app/api/billing/apply-promo/route.js new file mode 100644 index 0000000..5977f0d --- /dev/null +++ b/app/api/billing/apply-promo/route.js @@ -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 }); +} diff --git a/app/billing/page.js b/app/billing/page.js index db061e6..8bf2d5f 100644 --- a/app/billing/page.js +++ b/app/billing/page.js @@ -95,6 +95,9 @@ export default function BillingPage() { )} + {/* Промокод */} + load(0)} /> + {/* Стоимость операций */}
Стоимость операций
@@ -155,3 +158,59 @@ export default function BillingPage() { ); } + +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 ( +
+ {!show ? ( + + ) : ( +
+
+ 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} + /> + + +
+ {msg &&

{msg}

} +
+ )} +
+ ); +} diff --git a/components/AdminPanel.js b/components/AdminPanel.js index ca20540..5f95893 100644 --- a/components/AdminPanel.js +++ b/components/AdminPanel.js @@ -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' && } {section === 'spending' && } {section === 'plans' && } + {section === 'promos' && } {section === 'billing' && }
diff --git a/components/admin/AdminPromos.js b/components/admin/AdminPromos.js new file mode 100644 index 0000000..576d805 --- /dev/null +++ b/components/admin/AdminPromos.js @@ -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 ( +
+
+

Промокоды

+
+ {msg && {msg}} + + +
+
+ + {/* Форма создания */} + {showForm && ( +
+

Новый промокод

+
+
+ +
+ setCode(e.target.value.toUpperCase())} + className="input flex-1 font-mono text-sm py-1.5" maxLength={32} /> + +
+
+
+ + +
+
+ + setValue(e.target.value)} + className="input w-full text-sm py-1.5" min={1} max={type === 'discount_pct' ? 100 : 10000} /> +
+
+ + setMaxUses(e.target.value)} + className="input w-full text-sm py-1.5" min={-1} /> +
+
+ + setExpiresAt(e.target.value)} + className="input w-full text-sm py-1.5" /> +
+
+ + setDesc(e.target.value)} + placeholder="Для партнёров..." className="input w-full text-sm py-1.5" /> +
+
+
+ + +
+
+ )} + + {/* Список */} + {loading &&
} + + {!loading && promos.length === 0 && ( +
+ + Промокодов пока нет +
+ )} + + {!loading && promos.length > 0 && ( +
+ + + + + + + + + + + + + {promos.map(p => ( + + + + + + + + + ))} + +
КодТип / ЦенностьИспользованИстекаетСтатусДействия
+
+ {p.code} + +
+ {p.description &&
{p.description}
} +
+
{TYPE_LABELS[p.type]}
+
{p.type === 'credits' ? `+${p.value} кр` : `${p.value}%`}
+
+ = p.max_uses && p.max_uses !== -1 ? 'text-red-400' : ''}> + {p.uses_real} / {p.max_uses === -1 ? '∞' : p.max_uses} + + {fmtDate(p.expires_at)} + + + +
+
+ )} +
+ ); +}