Files
juwan-frontend/app/(account)/wallet/page.tsx
T

368 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { requestWithAuth } from "@/lib/api"
import {
getWalletBalance,
listWalletTransactions,
topUpWallet,
withdrawWallet,
} from "@/lib/api/transactions"
import { toApiError } from "@/lib/errors"
import { notifyInfo, notifySuccess } from "@/lib/toast"
import type { WalletTransaction } from "@/lib/types"
import { useAuthStore } from "@/store/auth"
import {
ArrowDownLeft,
ArrowUpRight,
CreditCard,
DollarSign,
RefreshCw,
Wallet,
} from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
const typeLabels: Record<string, string> = {
topup: "充值",
payment: "支付",
income: "收入",
withdrawal: "提现",
refund: "退款",
}
const typeIcons: Record<string, typeof ArrowUpRight> = {
topup: ArrowDownLeft,
payment: ArrowUpRight,
income: ArrowDownLeft,
withdrawal: ArrowUpRight,
refund: ArrowDownLeft,
}
export default function WalletPage() {
const { currentRole } = useAuthStore()
const isConsumer = currentRole === "consumer"
const [balance, setBalance] = useState<number | null>(null)
const [transactions, setTransactions] = useState<WalletTransaction[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isMutating, setIsMutating] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [selectedAmount, setSelectedAmount] = useState<number | null>(null)
const [customAmount, setCustomAmount] = useState("")
const [lastRefreshedAt, setLastRefreshedAt] = useState<string | null>(null)
const filteredTransactions = useMemo(() => {
return transactions.filter((tx) => {
if (isConsumer) {
return ["topup", "payment", "refund"].includes(tx.type)
}
return ["income", "withdrawal"].includes(tx.type)
})
}, [isConsumer, transactions])
const incomeSummary = useMemo(() => {
const income = transactions
.filter((tx) => tx.type === "income")
.reduce((total, tx) => total + Number(tx.amount), 0)
const withdrawn = transactions
.filter((tx) => tx.type === "withdrawal")
.reduce((total, tx) => total + Number(tx.amount), 0)
return {
income,
available: balance ?? 0,
withdrawn,
}
}, [balance, transactions])
const loadWalletData = useCallback(
async (options?: { showToast?: boolean; silent?: boolean }) => {
if (!options?.silent) {
setIsLoading(true)
setLoadError(null)
}
try {
const res = await requestWithAuth(async () => {
const [b, items] = await Promise.all([
getWalletBalance(),
listWalletTransactions({ offset: 0, limit: 1000 }),
])
return { balance: b, transactions: items }
})
if (!res) {
setLoadError("请先登录")
return
}
setBalance(res.balance)
setTransactions(res.transactions)
if (options?.showToast) notifySuccess("交易记录已刷新")
} catch (error) {
setLoadError(toApiError(error).msg)
} finally {
setIsLoading(false)
}
},
[],
)
useEffect(() => {
let cancelled = false
void (async () => {
try {
const res = await requestWithAuth(async () => {
const [b, items] = await Promise.all([
getWalletBalance(),
listWalletTransactions({ offset: 0, limit: 100 }),
])
return { balance: b, transactions: items }
})
if (cancelled) return
if (!res) {
setLoadError("请先登录")
return
}
setBalance(res.balance)
setTransactions(res.transactions)
} catch (error) {
if (cancelled) return
setLoadError(toApiError(error).msg)
}
})()
return () => {
cancelled = true
}
}, [])
const handleTopUp = async (rawAmount?: number) => {
const amount = rawAmount ?? Number(customAmount)
if (!Number.isFinite(amount) || amount <= 0) {
notifyInfo("请输入有效金额")
return
}
setIsMutating(true)
try {
const res = await requestWithAuth(async () => {
await topUpWallet({ amount })
await loadWalletData({ silent: true })
return true
})
if (!res) return
notifySuccess("充值成功")
setCustomAmount("")
setSelectedAmount(null)
} catch (error) {
notifyInfo(toApiError(error).msg)
} finally {
setIsMutating(false)
}
}
const handleWithdraw = async () => {
const amount = Number(customAmount)
if (!Number.isFinite(amount) || amount <= 0) {
notifyInfo("请输入有效金额")
return
}
setIsMutating(true)
try {
const res = await requestWithAuth(async () => {
await withdrawWallet({ amount })
await loadWalletData({ silent: true })
return true
})
if (!res) return
notifySuccess("提现成功")
setCustomAmount("")
} catch (error) {
notifyInfo(toApiError(error).msg)
} finally {
setIsMutating(false)
}
}
return (
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
<h1 className="text-2xl font-bold"></h1>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
{isConsumer ? "账户余额" : "收入余额"}
</p>
<p className="text-3xl font-bold mt-1">
{balance === null ? (
<span className="text-muted-foreground">--</span>
) : (
<>¥{balance.toFixed(2)}</>
)}
</p>
{loadError && <p className="text-xs text-destructive mt-2">{loadError}</p>}
</div>
<Wallet className="h-10 w-10 text-muted-foreground" />
</div>
<div className="flex gap-2 mt-4">
{isConsumer ? (
<Button
disabled={isMutating}
onClick={() => void handleTopUp(selectedAmount ?? undefined)}
>
<DollarSign className="mr-1 h-4 w-4" />
</Button>
) : (
<Button variant="outline" disabled={isMutating} onClick={() => void handleWithdraw()}>
<CreditCard className="mr-1 h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
{isConsumer ? (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-2">
{[50, 100, 200, 500, 1000, 2000].map((amount) => (
<Button
key={`amount-${amount}`}
variant={selectedAmount === amount ? "default" : "outline"}
className="h-12"
onClick={() => {
setSelectedAmount(amount)
setCustomAmount(amount.toString())
}}
>
¥{amount}
</Button>
))}
</div>
<Separator />
<div className="flex gap-2">
<Input
placeholder="自定义金额"
type="number"
value={customAmount}
onChange={(event) => {
setCustomAmount(event.target.value)
setSelectedAmount(null)
}}
/>
<Button disabled={isMutating} onClick={() => void handleTopUp()}>
</Button>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">¥{incomeSummary.income.toFixed(2)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">¥{incomeSummary.available.toFixed(2)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">¥{incomeSummary.withdrawn.toFixed(2)}</p>
</div>
</div>
<Separator className="my-4" />
<div className="flex gap-2">
<Input
placeholder="提现金额"
type="number"
value={customAmount}
onChange={(event) => setCustomAmount(event.target.value)}
/>
<Button variant="outline" disabled={isMutating} onClick={() => void handleWithdraw()}>
</Button>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<Button
variant="ghost"
size="sm"
disabled={isLoading}
onClick={async () => {
await loadWalletData({ showToast: true })
setLastRefreshedAt(new Date().toLocaleTimeString("zh-CN"))
}}
>
<RefreshCw className={`mr-1 h-3.5 w-3.5 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent className="space-y-3">
{lastRefreshedAt && (
<p className="text-xs text-muted-foreground">{lastRefreshedAt}</p>
)}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground text-sm">...</div>
) : filteredTransactions.length > 0 ? (
filteredTransactions.map((tx) => {
const Icon = typeIcons[tx.type]
const isIncome = tx.type === "topup" || tx.type === "income" || tx.type === "refund"
return (
<div key={tx.id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center">
{Icon ? <Icon className="h-4 w-4" /> : <ArrowUpRight className="h-4 w-4" />}
</div>
<div>
<p className="text-sm font-medium">{tx.description}</p>
<p className="text-xs text-muted-foreground">
{new Date(tx.createdAt).toLocaleString("zh-CN")}
</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${isIncome ? "text-green-600" : ""}`}>
{isIncome ? "+" : ""}¥{Math.abs(Number(tx.amount)).toFixed(2)}
</p>
<Badge variant="outline" className="text-[10px]">
{typeLabels[tx.type]}
</Badge>
</div>
</div>
)
})
) : (
<div className="text-center py-8 text-muted-foreground text-sm"></div>
)}
</CardContent>
</Card>
</div>
)
}