add topup

This commit is contained in:
songtianlun 2025-08-26 22:35:26 +08:00
parent a6a227597f
commit b3a3e2b456
4 changed files with 309 additions and 55 deletions

View File

@ -141,7 +141,14 @@
"balance": "Balance",
"showing": "Showing",
"of": "of",
"transactions": "transactions"
"transactions": "transactions",
"topUp": "Top Up",
"enterAmount": "Enter amount to top up",
"quickAmounts": "Quick amounts",
"topUpNote": "Note: This is a development environment. No real payment will be processed.",
"processing": "Processing...",
"topUpNow": "Top Up Now",
"cancel": "Cancel"
},
"studio": {
"title": "AI Prompt Studio",

View File

@ -141,7 +141,14 @@
"balance": "余额",
"showing": "显示",
"of": "的",
"transactions": "条记录"
"transactions": "条记录",
"topUp": "充值",
"enterAmount": "请输入充值金额",
"quickAmounts": "快捷金额",
"topUpNote": "注意:这是开发环境,不会进行真实的支付处理。",
"processing": "处理中...",
"topUpNow": "立即充值",
"cancel": "取消"
},
"studio": {
"title": "AI 提示词工作室",

View File

@ -0,0 +1,71 @@
import { NextResponse } from 'next/server'
import { createServerSupabaseClient } from '@/lib/supabase-server'
import { addCredit } from '@/lib/services/credit'
export async function POST(request: Request) {
try {
const { amount } = await request.json()
// 验证金额
if (!amount || amount <= 0) {
return NextResponse.json(
{ error: 'Invalid amount. Amount must be greater than 0.' },
{ status: 400 }
)
}
if (amount > 1000) {
return NextResponse.json(
{ error: 'Amount too large. Maximum top-up amount is $1000.' },
{ status: 400 }
)
}
const supabase = await createServerSupabaseClient()
// 获取当前用户
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 检查是否是开发环境
const isDevelopment = process.env.NODE_ENV === 'development'
if (isDevelopment) {
// 开发环境:模拟充值成功
console.log(`🧪 Development mode: Simulating top-up of $${amount} for user ${user.id}`)
const creditTransaction = await addCredit(
user.id,
amount,
'user_purchase',
'development',
null,
null,
`Development top-up: $${amount}`
)
return NextResponse.json({
success: true,
message: `Successfully topped up $${amount}`,
transaction: creditTransaction
})
} else {
// 生产环境:集成真实的支付网关
// 这里可以集成 Stripe, PayPal 等支付服务
return NextResponse.json(
{ error: 'Payment integration not implemented yet. Please contact support.' },
{ status: 501 }
)
}
} catch (error) {
console.error('Top-up failed:', error)
return NextResponse.json(
{ error: 'Failed to process top-up' },
{ status: 500 }
)
}
}

View File

@ -19,7 +19,9 @@ import {
ChevronRight,
Plus,
Minus,
Zap
Zap,
DollarSign,
X
} from 'lucide-react'
interface CreditTransaction {
@ -65,6 +67,11 @@ export default function CreditsPage() {
const [sortBy, setSortBy] = useState<'createdAt' | 'amount' | 'balance'>('createdAt')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
// Top-up modal state
const [showTopUpModal, setShowTopUpModal] = useState(false)
const [topUpAmount, setTopUpAmount] = useState('')
const [isTopUpLoading, setIsTopUpLoading] = useState(false)
const totalPages = Math.ceil(total / pageSize)
const loadCreditData = useCallback(async () => {
@ -173,6 +180,50 @@ export default function CreditsPage() {
return new Date(dateString).toLocaleString()
}
const handleTopUp = async () => {
const amount = parseFloat(topUpAmount)
if (!amount || amount <= 0) {
alert('Please enter a valid amount greater than 0')
return
}
if (amount > 1000) {
alert('Maximum top-up amount is $1000')
return
}
setIsTopUpLoading(true)
try {
const response = await fetch('/api/credits/topup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount }),
})
const data = await response.json()
if (response.ok) {
alert(`Successfully topped up $${amount}!`)
setShowTopUpModal(false)
setTopUpAmount('')
// Reload credit data to reflect the new balance
loadCreditData()
} else {
alert(data.error || 'Failed to process top-up')
}
} catch (error) {
console.error('Top-up error:', error)
alert('Failed to process top-up. Please try again.')
} finally {
setIsTopUpLoading(false)
}
}
const predefinedAmounts = [5, 10, 25, 50, 100]
if (loading || !user) {
return (
<div className="min-h-screen">
@ -199,68 +250,96 @@ export default function CreditsPage() {
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<Card className="p-6 bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200/50 dark:border-green-800/50">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-green-500">
<CreditCard className="w-5 h-5 text-white" />
<div className="space-y-4 mb-8">
{/* Balance Card with Top-up Button */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-end">
<Card className="flex-1 p-6 bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200/50 dark:border-green-800/50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-green-500">
<CreditCard className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-medium text-green-700 dark:text-green-300">{t('currentBalance')}</p>
<p className="text-2xl font-bold text-green-800 dark:text-green-200">
${stats.currentBalance.toFixed(2)}
</p>
</div>
</div>
</div>
<div>
<p className="text-sm font-medium text-green-700 dark:text-green-300">{t('currentBalance')}</p>
<p className="text-2xl font-bold text-green-800 dark:text-green-200">
${stats.currentBalance.toFixed(2)}
</p>
</div>
</div>
</Card>
</Card>
<Button
onClick={() => setShowTopUpModal(true)}
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-2 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
>
<Plus className="w-4 h-4 mr-2" />
{t('topUp')}
</Button>
</div>
<Card className="p-6">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-blue-500">
<TrendingUp className="w-5 h-5 text-white" />
{/* Other Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-6">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-blue-500">
<TrendingUp className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('totalEarned')}</p>
<p className="text-xl font-bold text-foreground">${stats.totalEarned.toFixed(2)}</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('totalEarned')}</p>
<p className="text-xl font-bold text-foreground">${stats.totalEarned.toFixed(2)}</p>
</div>
</div>
</Card>
</Card>
<Card className="p-6">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-red-500">
<TrendingDown className="w-5 h-5 text-white" />
<Card className="p-6">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-blue-500">
<TrendingUp className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('totalEarned')}</p>
<p className="text-xl font-bold text-foreground">${stats.totalEarned.toFixed(2)}</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('totalSpent')}</p>
<p className="text-xl font-bold text-foreground">${stats.totalSpent.toFixed(2)}</p>
</div>
</div>
</Card>
</Card>
<Card className="p-6">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-emerald-500">
<Calendar className="w-5 h-5 text-white" />
<Card className="p-6">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-red-500">
<TrendingDown className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('totalSpent')}</p>
<p className="text-xl font-bold text-foreground">${stats.totalSpent.toFixed(2)}</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('thisMonthEarned')}</p>
<p className="text-xl font-bold text-foreground">${stats.thisMonthEarned.toFixed(2)}</p>
</div>
</div>
</Card>
</Card>
<Card className="p-6">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-orange-500">
<Calendar className="w-5 h-5 text-white" />
<Card className="p-6">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-emerald-500">
<Calendar className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('thisMonthEarned')}</p>
<p className="text-xl font-bold text-foreground">${stats.thisMonthEarned.toFixed(2)}</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('thisMonthSpent')}</p>
<p className="text-xl font-bold text-foreground">${stats.thisMonthSpent.toFixed(2)}</p>
</Card>
<Card className="p-6">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-orange-500">
<Calendar className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('thisMonthSpent')}</p>
<p className="text-xl font-bold text-foreground">${stats.thisMonthSpent.toFixed(2)}</p>
</div>
</div>
</div>
</Card>
</Card>
</div>
</div>
)}
@ -442,6 +521,96 @@ export default function CreditsPage() {
)}
</div>
</Card>
{/* Top-up Modal */}
{showTopUpModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-background rounded-lg shadow-xl max-w-md w-full">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-xl font-semibold text-foreground">{t('topUp')}</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setShowTopUpModal(false)}
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t('enterAmount')}
</label>
<div className="relative">
<DollarSign className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
<input
type="number"
value={topUpAmount}
onChange={(e) => setTopUpAmount(e.target.value)}
placeholder="0.00"
min="0.01"
max="1000"
step="0.01"
className="w-full pl-10 pr-4 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<p className="text-sm font-medium text-foreground mb-2">{t('quickAmounts')}</p>
<div className="grid grid-cols-5 gap-2">
{predefinedAmounts.map((amount) => (
<Button
key={amount}
variant="outline"
size="sm"
onClick={() => setTopUpAmount(amount.toString())}
className="text-xs"
>
${amount}
</Button>
))}
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-950/20 p-3 rounded-md">
<p className="text-xs text-blue-700 dark:text-blue-300">
{t('topUpNote')}
</p>
</div>
</div>
<div className="flex gap-3 p-6 border-t border-border">
<Button
variant="outline"
onClick={() => setShowTopUpModal(false)}
disabled={isTopUpLoading}
className="flex-1"
>
{t('cancel')}
</Button>
<Button
onClick={handleTopUp}
disabled={isTopUpLoading || !topUpAmount || parseFloat(topUpAmount) <= 0}
className="flex-1 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700"
>
{isTopUpLoading ? (
<>
<LoadingSpinner className="w-4 h-4 mr-2" />
{t('processing')}
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
{t('topUpNow')}
</>
)}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
)