forked from maik/IPFS-portal
570 lines
29 KiB
TypeScript
570 lines
29 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState, useEffect, useCallback } from 'react';
|
||
|
|
import PortalLayout from '@/app/layout-portal';
|
||
|
|
import { paymentService, type TokenInfo, type MAOSDiscountTier } from '@/lib/payment';
|
||
|
|
import {
|
||
|
|
connectWallet, switchToChain270, getInjectedProvider,
|
||
|
|
formatWeiToETH, formatBytes, type WalletProvider,
|
||
|
|
} from '@/lib/wallet';
|
||
|
|
import {
|
||
|
|
Wallet, Coins, Loader2, CheckCircle, XCircle,
|
||
|
|
Settings, PiggyBank, Tags, RefreshCw, ExternalLink,
|
||
|
|
Plus, Trash2, Copy, AlertTriangle,
|
||
|
|
} from 'lucide-react';
|
||
|
|
|
||
|
|
type ViewMode = 'overview' | 'pricing' | 'tokens' | 'tiers';
|
||
|
|
type TxStatus = { ok: boolean; msg: string } | null;
|
||
|
|
|
||
|
|
export default function AdminPaymentPage() {
|
||
|
|
const [address, setAddress] = useState<string | null>(null);
|
||
|
|
const [provider, setProvider] = useState<WalletProvider | null>(null);
|
||
|
|
const [connecting, setConnecting] = useState(false);
|
||
|
|
const [wrongChain, setWrongChain] = useState(false);
|
||
|
|
const [walletType, setWalletType] = useState<string | null>(null);
|
||
|
|
const [view, setView] = useState<ViewMode>('overview');
|
||
|
|
const [txStatus, setTxStatus] = useState<TxStatus>(null);
|
||
|
|
|
||
|
|
// Contract state
|
||
|
|
const [owner, setOwner] = useState<string | null>(null);
|
||
|
|
const [deployed, setDeployed] = useState(false);
|
||
|
|
const [basePrice, setBasePrice] = useState<bigint>(BigInt(0));
|
||
|
|
const [freeTier, setFreeTier] = useState<bigint>(BigInt(0));
|
||
|
|
const [tokens, setTokens] = useState<TokenInfo[]>([]);
|
||
|
|
const [tiers, setTiers] = useState<MAOSDiscountTier[]>([]);
|
||
|
|
const [totalUploads, setTotalUploads] = useState<bigint>(BigInt(0));
|
||
|
|
const [revenues, setRevenues] = useState<Record<string, bigint>>({});
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [contractBalance, setContractBalance] = useState<Record<string, string>>({});
|
||
|
|
|
||
|
|
const isOwner = address && owner && address.toLowerCase() === owner.toLowerCase();
|
||
|
|
|
||
|
|
// ── Load all contract state ──
|
||
|
|
const refresh = useCallback(async () => {
|
||
|
|
setLoading(true);
|
||
|
|
setTxStatus(null);
|
||
|
|
try {
|
||
|
|
paymentService.isDeployed().then(setDeployed);
|
||
|
|
const [own, price, free, t, tiersData, uploads] = await Promise.all([
|
||
|
|
paymentService.getOwner(),
|
||
|
|
paymentService.getBasePricePerMB(),
|
||
|
|
paymentService.getFreeTierBytes(),
|
||
|
|
paymentService.getSupportedTokens(),
|
||
|
|
paymentService.getMAOSDiscountTiers(),
|
||
|
|
paymentService.getTotalUploads(),
|
||
|
|
]);
|
||
|
|
setOwner(own);
|
||
|
|
setBasePrice(price);
|
||
|
|
setFreeTier(free);
|
||
|
|
setTiers(tiersData);
|
||
|
|
|
||
|
|
// Revenue per token
|
||
|
|
// Deduplicate by symbol (contract has a bug where USDC appears twice)
|
||
|
|
const seen = new Set<string>();
|
||
|
|
const uniqueTokens = t.filter(tk => {
|
||
|
|
if (seen.has(tk.symbol)) return false;
|
||
|
|
seen.add(tk.symbol);
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
setTokens(uniqueTokens);
|
||
|
|
|
||
|
|
const symbols = uniqueTokens.map(tk => tk.symbol);
|
||
|
|
const revEntries = await Promise.all(
|
||
|
|
symbols.map(s => paymentService.getTotalRevenue(s))
|
||
|
|
);
|
||
|
|
const revMap: Record<string, bigint> = {};
|
||
|
|
symbols.forEach((s, i) => { revMap[s] = revEntries[i]; });
|
||
|
|
setRevenues(revMap);
|
||
|
|
|
||
|
|
// Balances via RPC
|
||
|
|
const balMap: Record<string, string> = {};
|
||
|
|
for (const tk of t) {
|
||
|
|
try {
|
||
|
|
const client = paymentService['getPublicClient']();
|
||
|
|
if (tk.symbol === 'ETH') {
|
||
|
|
const bal = await client.getBalance({ address: paymentService['contractAddress'] });
|
||
|
|
balMap['ETH'] = bal.toString();
|
||
|
|
} else if (tk.address && tk.address !== '0x0000000000000000000000000000000000000000') {
|
||
|
|
const ERC20_BAL_ABI = [{ type: 'function', name: 'balanceOf', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' }] as const;
|
||
|
|
const bal = await client.readContract({
|
||
|
|
address: tk.address as `0x${string}`,
|
||
|
|
abi: ERC20_BAL_ABI,
|
||
|
|
functionName: 'balanceOf',
|
||
|
|
args: [paymentService['contractAddress']],
|
||
|
|
});
|
||
|
|
balMap[tk.symbol] = (bal as bigint).toString();
|
||
|
|
}
|
||
|
|
} catch { /* skip */ }
|
||
|
|
}
|
||
|
|
setContractBalance(balMap);
|
||
|
|
setTotalUploads(uploads);
|
||
|
|
setTokens(t);
|
||
|
|
} catch (e) {
|
||
|
|
console.warn('Failed to load contract state:', e);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => { refresh(); }, [refresh]);
|
||
|
|
|
||
|
|
// ── Connect wallet ──
|
||
|
|
async function handleConnect() {
|
||
|
|
setConnecting(true);
|
||
|
|
setTxStatus(null);
|
||
|
|
try {
|
||
|
|
const result = await connectWallet();
|
||
|
|
const prov = getInjectedProvider();
|
||
|
|
setAddress(result.address);
|
||
|
|
setProvider(prov);
|
||
|
|
if (result.chainId !== 270) {
|
||
|
|
setWrongChain(true);
|
||
|
|
const switched = await switchToChain270(prov || undefined);
|
||
|
|
if (switched) setWrongChain(false);
|
||
|
|
}
|
||
|
|
// @ts-expect-error
|
||
|
|
if (window.maosv6) setWalletType('MAOS Wallet');
|
||
|
|
// @ts-expect-error
|
||
|
|
else if (window.ethereum?.isRabby) setWalletType('Rabby');
|
||
|
|
// @ts-expect-error
|
||
|
|
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
|
||
|
|
else setWalletType('Wallet');
|
||
|
|
} catch (e: any) {
|
||
|
|
setTxStatus({ ok: false, msg: e.message || 'Connect failed' });
|
||
|
|
} finally {
|
||
|
|
setConnecting(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Admin action helper ──
|
||
|
|
async function doTx(label: string, fn: () => Promise<string>) {
|
||
|
|
if (!provider) return;
|
||
|
|
setTxStatus(null);
|
||
|
|
try {
|
||
|
|
const hash = await fn();
|
||
|
|
setTxStatus({ ok: true, msg: `${label} success: ${hash.slice(0, 10)}...` });
|
||
|
|
await refresh();
|
||
|
|
} catch (e: any) {
|
||
|
|
setTxStatus({ ok: false, msg: `${label} failed: ${e.message || e}` });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Form state ──
|
||
|
|
const [priceInput, setPriceInput] = useState('');
|
||
|
|
const [freeInput, setFreeInput] = useState('');
|
||
|
|
const [tierBalance, setTierBalance] = useState('');
|
||
|
|
const [tierBps, setTierBps] = useState('');
|
||
|
|
const [tierRemoveIdx, setTierRemoveIdx] = useState<number>(-1);
|
||
|
|
const [tokenSymbol, setTokenSymbol] = useState('');
|
||
|
|
const [tokenAddr, setTokenAddr] = useState('');
|
||
|
|
const [tokenDec, setTokenDec] = useState('18');
|
||
|
|
const [tokenWei, setTokenWei] = useState('');
|
||
|
|
const [tokenEnabled, setTokenEnabled] = useState(true);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<PortalLayout>
|
||
|
|
{/* ── Header ── */}
|
||
|
|
<div className="flex items-center justify-between mb-6">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-2xl font-bold text-white">Payment Admin</h1>
|
||
|
|
<p className="text-sm text-surface-400 mt-1">Manage IPFS Portal payment contract</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
{address && (
|
||
|
|
<span className="text-xs text-surface-400 flex items-center gap-1.5">
|
||
|
|
<span className={`w-1.5 h-1.5 rounded-full ${isOwner ? 'bg-accent-green' : 'bg-surface-500'}`} />
|
||
|
|
{isOwner ? 'Owner' : 'Viewer'}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<button onClick={refresh} disabled={loading} className="p-2 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-400 transition-colors">
|
||
|
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* ── Connect wallet ── */}
|
||
|
|
{!address && (
|
||
|
|
<div className="glass rounded-xl p-6 text-center space-y-4">
|
||
|
|
<Wallet className="w-8 h-8 text-surface-500 mx-auto" />
|
||
|
|
<p className="text-sm text-surface-400">Connect wallet to view admin panel</p>
|
||
|
|
<button onClick={handleConnect} disabled={connecting}
|
||
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||
|
|
>
|
||
|
|
{connecting ? <><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</> : <><Wallet className="w-4 h-4" /> Connect Wallet</>}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{wrongChain && (
|
||
|
|
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-sm mb-4">
|
||
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||
|
|
Please switch to zkSync Local (chain 270)
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{address && (
|
||
|
|
<>
|
||
|
|
{/* ── Nav tabs ── */}
|
||
|
|
<div className="flex gap-1 mb-6 p-1 rounded-lg bg-surface-800/50 w-fit">
|
||
|
|
{(['overview', 'pricing', 'tokens', 'tiers'] as ViewMode[]).map(v => (
|
||
|
|
<button key={v} onClick={() => setView(v)}
|
||
|
|
className={`px-3 py-1.5 rounded-md text-xs font-medium capitalize transition-colors ${
|
||
|
|
view === v ? 'bg-surface-700 text-white' : 'text-surface-400 hover:text-surface-200'
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{v === 'overview' ? 'Overview' : v === 'pricing' ? 'Pricing' : v === 'tokens' ? 'Tokens' : 'Discount Tiers'}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* ── Loading ── */}
|
||
|
|
{loading ? (
|
||
|
|
<div className="flex items-center gap-2 text-surface-400 text-sm py-12 justify-center">
|
||
|
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading contract state...
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{/* ── Overview ── */}
|
||
|
|
{view === 'overview' && (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* Stats grid */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
|
|
<div className="glass rounded-xl p-4">
|
||
|
|
<div className="text-xs text-surface-400 mb-1">Total Uploads</div>
|
||
|
|
<div className="text-2xl font-bold text-white">{totalUploads.toString()}</div>
|
||
|
|
</div>
|
||
|
|
{tokens.filter(t => t.enabled).map(tk => {
|
||
|
|
const rev = revenues[tk.symbol] || BigInt(0);
|
||
|
|
const bal = contractBalance[tk.symbol];
|
||
|
|
return (
|
||
|
|
<div key={tk.symbol} className="glass rounded-xl p-4">
|
||
|
|
<div className="text-xs text-surface-400 mb-1">{tk.symbol} Revenue</div>
|
||
|
|
<div className="text-2xl font-bold text-white">
|
||
|
|
{tk.symbol === 'ETH' ? formatWeiToETH(rev, 6) : rev.toString()}
|
||
|
|
</div>
|
||
|
|
{bal && (
|
||
|
|
<div className="text-[10px] text-surface-500 mt-1">
|
||
|
|
Balance: {tk.symbol === 'ETH' ? formatWeiToETH(bal, 6) : bal} {tk.symbol}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Contract info */}
|
||
|
|
<div className="glass rounded-xl p-5 space-y-3">
|
||
|
|
<h3 className="text-sm font-semibold text-white">Contract</h3>
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||
|
|
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
|
||
|
|
<span className="text-surface-400">Address</span>
|
||
|
|
<span className="font-mono text-surface-200">
|
||
|
|
{paymentService['contractAddress'].slice(0, 10)}...
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
|
||
|
|
<span className="text-surface-400">Owner</span>
|
||
|
|
<span className="font-mono text-surface-200">
|
||
|
|
{owner ? `${owner.slice(0, 8)}...${owner.slice(-6)}` : '-'}
|
||
|
|
{isOwner && <span className="ml-1 text-accent-green">✓</span>}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
|
||
|
|
<span className="text-surface-400">Deployed</span>
|
||
|
|
<span className={deployed ? 'text-accent-green' : 'text-accent-rose'}>
|
||
|
|
{deployed ? 'Yes' : 'No'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
|
||
|
|
<span className="text-surface-400">Connected</span>
|
||
|
|
<span className="text-surface-200 font-mono">
|
||
|
|
{address.slice(0, 6)}...{address.slice(-4)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* If owner — quick actions */}
|
||
|
|
{isOwner && (
|
||
|
|
<div className="glass rounded-xl p-5 space-y-3">
|
||
|
|
<h3 className="text-sm font-semibold text-white">Quick Actions</h3>
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
<button onClick={() => setView('pricing')}
|
||
|
|
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
|
||
|
|
Update Pricing
|
||
|
|
</button>
|
||
|
|
<button onClick={() => setView('tokens')}
|
||
|
|
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
|
||
|
|
Manage Tokens
|
||
|
|
</button>
|
||
|
|
<button onClick={() => setView('tiers')}
|
||
|
|
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
|
||
|
|
Discount Tiers
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ── Pricing ── */}
|
||
|
|
{view === 'pricing' && (
|
||
|
|
<div className="max-w-2xl space-y-4">
|
||
|
|
<div className="glass rounded-xl p-5 space-y-4">
|
||
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||
|
|
<Settings className="w-4 h-4 text-brand-400" /> Pricing Config
|
||
|
|
</h3>
|
||
|
|
|
||
|
|
{/* Current values */}
|
||
|
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||
|
|
<div className="px-3 py-2 rounded bg-surface-800/30">
|
||
|
|
<div className="text-surface-400">Base Price / MB</div>
|
||
|
|
<div className="text-surface-200 font-medium mt-0.5">{formatWeiToETH(basePrice, 8)} ETH</div>
|
||
|
|
</div>
|
||
|
|
<div className="px-3 py-2 rounded bg-surface-800/30">
|
||
|
|
<div className="text-surface-400">Free Tier</div>
|
||
|
|
<div className="text-surface-200 font-medium mt-0.5">{formatBytes(freeTier)}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{isOwner && (
|
||
|
|
<>
|
||
|
|
{/* Set price */}
|
||
|
|
<div className="space-y-2 pt-3 border-t border-surface-800">
|
||
|
|
<label className="text-xs text-surface-400">Set Base Price / MB (wei)</label>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<input value={priceInput} onChange={e => setPriceInput(e.target.value)}
|
||
|
|
placeholder={basePrice.toString()}
|
||
|
|
className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
<button onClick={() => {
|
||
|
|
if (!priceInput) return;
|
||
|
|
const wei = BigInt(priceInput);
|
||
|
|
doTx('Set price', () => paymentService.adminSetPricePerMB(provider!, wei));
|
||
|
|
}}
|
||
|
|
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors">
|
||
|
|
Update
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div className="text-[10px] text-surface-500">
|
||
|
|
Current: {formatWeiToETH(basePrice, 8)} ETH — e.g. 100000000000000 = 0.0001 ETH
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Set free tier */}
|
||
|
|
<div className="space-y-2 pt-3 border-t border-surface-800">
|
||
|
|
<label className="text-xs text-surface-400">Set Free Tier (bytes)</label>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<input value={freeInput} onChange={e => setFreeInput(e.target.value)}
|
||
|
|
placeholder={freeTier.toString()}
|
||
|
|
className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
<button onClick={() => {
|
||
|
|
if (!freeInput) return;
|
||
|
|
doTx('Set free tier', () => paymentService.adminSetFreeTierBytes(provider!, BigInt(freeInput)));
|
||
|
|
}}
|
||
|
|
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors">
|
||
|
|
Update
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div className="text-[10px] text-surface-500">
|
||
|
|
Current: {formatBytes(freeTier)} — 10485760 = 10 MB
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Withdraw */}
|
||
|
|
<div className="pt-3 border-t border-surface-800 space-y-2">
|
||
|
|
<label className="text-xs text-surface-400">Withdraw Funds</label>
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
{tokens.filter(t => t.enabled).map(tk => {
|
||
|
|
const bal = contractBalance[tk.symbol];
|
||
|
|
if (!bal || bal === '0') return null;
|
||
|
|
return (
|
||
|
|
<button key={tk.symbol} onClick={() =>
|
||
|
|
doTx(`Withdraw ${tk.symbol}`, () =>
|
||
|
|
tk.symbol === 'ETH'
|
||
|
|
? paymentService.adminWithdrawETH(provider!, address as `0x${string}`)
|
||
|
|
: paymentService.adminWithdrawToken(provider!, tk.symbol, address as `0x${string}`)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-xs transition-colors">
|
||
|
|
Withdraw {tk.symbol} ({tk.symbol === 'ETH' ? formatWeiToETH(bal, 4) : bal})
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ── Tokens ── */}
|
||
|
|
{view === 'tokens' && (
|
||
|
|
<div className="max-w-2xl space-y-4">
|
||
|
|
<div className="glass rounded-xl p-5 space-y-4">
|
||
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||
|
|
<Coins className="w-4 h-4 text-brand-400" /> Supported Tokens
|
||
|
|
</h3>
|
||
|
|
|
||
|
|
{/* Token list */}
|
||
|
|
<div className="space-y-2">
|
||
|
|
{tokens.map(tk => (
|
||
|
|
<div key={tk.symbol} className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/40 text-xs">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<span className={`w-2 h-2 rounded-full ${tk.enabled ? 'bg-accent-green' : 'bg-surface-500'}`} />
|
||
|
|
<span className="font-medium text-surface-200">{tk.symbol}</span>
|
||
|
|
<span className="font-mono text-surface-500">{tk.address.slice(0, 10)}...</span>
|
||
|
|
<span className="text-surface-500">{tk.decimals} decimals</span>
|
||
|
|
</div>
|
||
|
|
<span className="text-surface-400">{formatWeiToETH(tk.weiPerToken, 4)} wei/token</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Admin: configure token */}
|
||
|
|
{isOwner && (
|
||
|
|
<div className="glass rounded-xl p-5 space-y-4">
|
||
|
|
<h3 className="text-sm font-semibold text-white">Configure Token</h3>
|
||
|
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||
|
|
<input value={tokenSymbol} onChange={e => setTokenSymbol(e.target.value.toUpperCase())}
|
||
|
|
placeholder="Symbol (e.g. USDC)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
<input value={tokenAddr} onChange={e => setTokenAddr(e.target.value)}
|
||
|
|
placeholder="Token address (0x...)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
<input value={tokenDec} onChange={e => setTokenDec(e.target.value)}
|
||
|
|
placeholder="Decimals (e.g. 6)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
<input value={tokenWei} onChange={e => setTokenWei(e.target.value)}
|
||
|
|
placeholder="weiPerToken (e.g. 500000000000000)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
<label className="flex items-center gap-2 text-surface-400">
|
||
|
|
<input type="checkbox" checked={tokenEnabled} onChange={e => setTokenEnabled(e.target.checked)}
|
||
|
|
className="rounded border-surface-600"
|
||
|
|
/>
|
||
|
|
Enabled
|
||
|
|
</label>
|
||
|
|
<button onClick={() => {
|
||
|
|
if (!tokenSymbol || !tokenAddr) return;
|
||
|
|
doTx('Configure token', () => paymentService.adminConfigureToken(
|
||
|
|
provider!, tokenSymbol, tokenAddr as `0x${string}`, parseInt(tokenDec), BigInt(tokenWei || '0'), tokenEnabled
|
||
|
|
));
|
||
|
|
}}
|
||
|
|
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white font-medium transition-colors">
|
||
|
|
Apply
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Withdraw section */}
|
||
|
|
{isOwner && Object.keys(contractBalance).length > 0 && (
|
||
|
|
<div className="glass rounded-xl p-5 space-y-3">
|
||
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||
|
|
<PiggyBank className="w-4 h-4 text-brand-400" /> Withdraw
|
||
|
|
</h3>
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
{Object.entries(contractBalance).filter(([_, bal]) => bal !== '0').map(([sym, bal]) => (
|
||
|
|
<button key={sym} onClick={() =>
|
||
|
|
doTx(`Withdraw ${sym}`, () =>
|
||
|
|
sym === 'ETH'
|
||
|
|
? paymentService.adminWithdrawETH(provider!, address as `0x${string}`)
|
||
|
|
: paymentService.adminWithdrawToken(provider!, sym, address as `0x${string}`)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-xs transition-colors">
|
||
|
|
Withdraw {sym} ({sym === 'ETH' ? formatWeiToETH(bal, 4) : bal})
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
{Object.values(contractBalance).every(b => b === '0') && (
|
||
|
|
<span className="text-xs text-surface-500">No balance to withdraw</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ── Discount Tiers ── */}
|
||
|
|
{view === 'tiers' && (
|
||
|
|
<div className="max-w-2xl space-y-4">
|
||
|
|
<div className="glass rounded-xl p-5 space-y-4">
|
||
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||
|
|
<Tags className="w-4 h-4 text-brand-400" /> MAOS Discount Tiers
|
||
|
|
</h3>
|
||
|
|
|
||
|
|
{/* Tier list */}
|
||
|
|
<div className="space-y-2">
|
||
|
|
{tiers.map((tier, i) => (
|
||
|
|
<div key={i} className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/40 text-xs">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<span className="text-surface-200 font-medium">{formatWeiToETH(tier.minBalance, 0)} MAOS</span>
|
||
|
|
<span className="px-1.5 py-0.5 rounded bg-accent-cyan/10 text-accent-cyan">{tier.discountBps / 100}% off</span>
|
||
|
|
</div>
|
||
|
|
{isOwner && (
|
||
|
|
<button onClick={() => doTx('Remove tier', () => paymentService.adminRemoveDiscountTier(provider!, i))}
|
||
|
|
className="p-1 rounded hover:bg-accent-rose/10 text-surface-400 hover:text-accent-rose transition-colors">
|
||
|
|
<Trash2 className="w-3.5 h-3.5" />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Admin: add tier */}
|
||
|
|
{isOwner && (
|
||
|
|
<div className="glass rounded-xl p-5 space-y-4">
|
||
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||
|
|
<Plus className="w-4 h-4 text-brand-400" /> Add / Update Tier
|
||
|
|
</h3>
|
||
|
|
<div className="flex gap-2 items-end">
|
||
|
|
<div className="flex-1 space-y-1">
|
||
|
|
<label className="text-[10px] text-surface-500">Min MAOS balance</label>
|
||
|
|
<input value={tierBalance} onChange={e => setTierBalance(e.target.value)}
|
||
|
|
placeholder="e.g. 5000" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex-1 space-y-1">
|
||
|
|
<label className="text-[10px] text-surface-500">Discount %</label>
|
||
|
|
<input value={tierBps} onChange={e => setTierBps(e.target.value)}
|
||
|
|
placeholder="e.g. 50" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<button onClick={() => {
|
||
|
|
if (!tierBalance || !tierBps) return;
|
||
|
|
const bal = BigInt(parseFloat(tierBalance) * 1e18);
|
||
|
|
const bps = parseInt(tierBps) * 100;
|
||
|
|
doTx('Set tier', () => paymentService.adminSetDiscountTier(provider!, bal, bps));
|
||
|
|
}}
|
||
|
|
className="px-4 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors whitespace-nowrap">
|
||
|
|
<Plus className="w-3.5 h-3.5 inline mr-1" /> Add
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ── TX Status ── */}
|
||
|
|
{txStatus && (
|
||
|
|
<div className={`fixed bottom-6 right-6 flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-sm animate-fade-in ${
|
||
|
|
txStatus.ok
|
||
|
|
? 'bg-accent-green/10 border border-accent-green/20 text-accent-green'
|
||
|
|
: 'bg-accent-rose/10 border border-accent-rose/20 text-accent-rose'
|
||
|
|
}`}>
|
||
|
|
{txStatus.ok ? <CheckCircle className="w-4 h-4 shrink-0" /> : <XCircle className="w-4 h-4 shrink-0" />}
|
||
|
|
{txStatus.msg}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</PortalLayout>
|
||
|
|
);
|
||
|
|
}
|