From 1cce478f27b17c0dd44a25963824cce366199c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=20=28Claude=29?= Date: Thu, 11 Jun 2026 18:28:56 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20billing=20UI=20=E2=80=94=20balance=20in?= =?UTF-8?q?=20header=20+=20/billing=20transactions=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header: Coins badge с кредитами, ссылка на /billing - app/billing/page.js: баланс, план, стоимость операций, история транзакций - app/api/billing/balance/route.js, transactions/route.js — прокси к engine - lib/engine.js: getBillingBalance, getTransactions, getBillingPlans, adminCreditUser --- app/api/billing/balance/route.js | 14 +++ app/api/billing/transactions/route.js | 15 +++ app/billing/page.js | 155 ++++++++++++++++++++++++++ components/Header.js | 26 ++++- lib/engine.js | 11 ++ 5 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 app/api/billing/balance/route.js create mode 100644 app/api/billing/transactions/route.js create mode 100644 app/billing/page.js diff --git a/app/api/billing/balance/route.js b/app/api/billing/balance/route.js new file mode 100644 index 0000000..83cefe0 --- /dev/null +++ b/app/api/billing/balance/route.js @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function GET(req) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + try { + const data = await engine.getBillingBalance(user.id); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} diff --git a/app/api/billing/transactions/route.js b/app/api/billing/transactions/route.js new file mode 100644 index 0000000..20cce0d --- /dev/null +++ b/app/api/billing/transactions/route.js @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function GET(req) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { searchParams } = new URL(req.url); + try { + const data = await engine.getTransactions(Object.fromEntries(searchParams)); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} diff --git a/app/billing/page.js b/app/billing/page.js new file mode 100644 index 0000000..56bed8a --- /dev/null +++ b/app/billing/page.js @@ -0,0 +1,155 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { Coins, RefreshCw, TrendingDown, TrendingUp, Loader2, ArrowRight } from 'lucide-react'; +import Link from 'next/link'; + +const TYPE_LABELS = { + spend_image: { label: 'Генерация картинки', sign: '-', color: 'text-red-400' }, + spend_text_post: { label: 'Генерация поста', sign: '-', color: 'text-red-400' }, + spend_article: { label: 'Генерация статьи', sign: '-', color: 'text-red-400' }, + spend_autopublish:{ label: 'Публикация', sign: '-', color: 'text-gray-400' }, + plan_credit: { label: 'Начисление по тарифу',sign: '+', color: 'text-green-400' }, + topup: { label: 'Пополнение', sign: '+', color: 'text-green-400' }, + bonus: { label: 'Бонус', sign: '+', color: 'text-blue-400' }, + refund: { label: 'Возврат', sign: '+', color: 'text-blue-400' }, +}; + +function fmtDate(s) { + const d = new Date(s); + return d.toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' }); +} + +export default function BillingPage() { + const [balance, setBalance] = useState(null); + const [txs, setTxs] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(0); + const PER_PAGE = 30; + + async function load(p = 0) { + setLoading(true); + try { + const [balRes, txRes] = await Promise.all([ + fetch('/api/billing/balance').then(r => r.json()), + fetch(`/api/billing/transactions?limit=${PER_PAGE}&offset=${p * PER_PAGE}`).then(r => r.json()), + ]); + setBalance(balRes); + setTxs(txRes.transactions || []); + setTotal(txRes.total || 0); + } catch {} + setLoading(false); + } + + useEffect(() => { load(0); }, []); + + const PLAN_COLORS = { free: 'text-gray-400', starter: 'text-blue-400', pro: 'text-purple-400', business: 'text-yellow-400' }; + + return ( +
+
+

+ Баланс и кредиты +

+ +
+ + {/* Баланс */} + {balance && ( +
+
+
+ {balance.isUnlimited ? '∞' : balance.credits} +
+
кредитов осталось
+
+
+
+ {balance.planName} +
+
текущий тариф
+
+
+
+ {balance.resetAt ? new Date(balance.resetAt).toLocaleDateString('ru-RU') : '—'} +
+
сброс кредитов
+
+
+ )} + + {/* CTA апгрейд */} + {balance?.plan === 'free' && ( +
+
+
Хотите больше кредитов?
+
Starter — 500 кредитов за ₽490/мес
+
+ + Тарифы + +
+ )} + + {/* Стоимость операций */} +
+
Стоимость операций
+
+ {[ + { label: 'Картинка', credits: 5, icon: '🖼' }, + { label: 'Пост', credits: 2, icon: '✍️' }, + { label: 'Статья', credits: 5, icon: '📝' }, + { label: 'Публикация', credits: 0, icon: '📤' }, + ].map(op => ( +
+
{op.icon}
+
{op.label}
+
+ {op.credits === 0 ? 'бесплатно' : `${op.credits} кр`} +
+
+ ))} +
+
+ + {/* История транзакций */} +

История

+ {loading &&
} + {!loading && ( +
+ {txs.length === 0 && ( +
Транзакций пока нет
+ )} + {txs.map(tx => { + const meta = TYPE_LABELS[tx.type] || { label: tx.type, sign: tx.amount > 0 ? '+' : '-', color: 'text-gray-400' }; + return ( +
+
+
{tx.description || meta.label}
+
{fmtDate(tx.created_at)}
+
+
+
+ {meta.sign}{Math.abs(tx.amount)} кр +
+
= {tx.balance_after === -1 ? '∞' : tx.balance_after} кр
+
+
+ ); + })} +
+ )} + + {/* Пагинация */} + {total > PER_PAGE && ( +
+ + {page+1} / {Math.ceil(total/PER_PAGE)} + +
+ )} +
+ ); +} diff --git a/components/Header.js b/components/Header.js index 07fe50c..fa8d62a 100644 --- a/components/Header.js +++ b/components/Header.js @@ -1,11 +1,22 @@ 'use client'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { Sparkles, LogOut, Settings2, CalendarDays, TrendingUp } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Sparkles, LogOut, Settings2, CalendarDays, TrendingUp, Coins } from 'lucide-react'; import ThemeToggle from './ThemeToggle'; export default function Header({ user }) { const router = useRouter(); + const [credits, setCredits] = useState(null); + + useEffect(() => { + if (!user) return; + fetch('/api/billing/balance') + .then(r => r.json()) + .then(d => setCredits(d.isUnlimited ? '∞' : d.credits)) + .catch(() => {}); + }, [user?.id]); + async function logout() { await fetch('/api/auth/logout', { method: 'POST' }); router.push('/login'); @@ -30,12 +41,15 @@ export default function Header({ user }) { )}
+ {/* Баланс кредитов */} + {credits !== null && ( + + + {credits} кр + + )} {user?.isAdmin && ( - + Система diff --git a/lib/engine.js b/lib/engine.js index d2ca8c7..dc5e145 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -76,6 +76,16 @@ export const engine = { }, usageRecent: (limit = 20) => call(`/api/usage/recent?limit=${limit}`), + // Billing + getBillingBalance: (userId) => call('/api/billing/balance', { userId }), + getBillingPlans: () => fetch('/api/billing/plans', { cache: 'no-store' }).then(r => r.json()), + getTransactions: (params = {}) => { + const qs = new URLSearchParams(params).toString(); + return call(`/api/billing/transactions?${qs}`); + }, + adminCreditUser: (data) => call('/api/billing/admin/credit', { method: 'POST', body: data }), + adminGetBalances: () => call('/api/billing/admin/users'), + // Editor notes listNotes: () => call('/api/notes?limit=100'), createNote: (data) => call('/api/notes', { method: 'POST', body: data }), @@ -106,3 +116,4 @@ export const engine = { updateUserPostSchedule: (userId, id, scheduledAt) => call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: { scheduled_at: scheduledAt } }), }; +// Добавляем в конец файла перед module.exports или в общий объект