394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState, useEffect, useCallback } from 'react';
|
||
|
|
import { paymentService, type PriceInfo, type TokenInfo, type MAOSDiscountTier, type UserUploadStats } from '@/lib/payment';
|
||
|
|
import {
|
||
|
|
connectWallet, switchToChain270, getInjectedProvider,
|
||
|
|
formatWeiToETH, formatBytes, type WalletProvider,
|
||
|
|
} from '@/lib/wallet';
|
||
|
|
import {
|
||
|
|
Wallet, Coins, Loader2, CheckCircle, XCircle,
|
||
|
|
Zap, Shield, Tag, ArrowRight, ExternalLink, Copy,
|
||
|
|
} from 'lucide-react';
|
||
|
|
|
||
|
|
/* ── Token icons ── */
|
||
|
|
const TOKEN_ICONS: Record<string, string> = {
|
||
|
|
ETH: '⟠',
|
||
|
|
MAOS: 'Ⓜ',
|
||
|
|
USDC: '⬡',
|
||
|
|
};
|
||
|
|
|
||
|
|
interface PaymentPanelProps {
|
||
|
|
fileSize: number; // bytes
|
||
|
|
fileName?: string;
|
||
|
|
onPaid: (paymentInfo: { cid: string; txHash?: string; method: 'free' | 'eth' | 'token' }) => void;
|
||
|
|
onUpload: () => Promise<{ cid: string; size: number }>;
|
||
|
|
contractAddress?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function PaymentPanel({
|
||
|
|
fileSize,
|
||
|
|
fileName,
|
||
|
|
onPaid,
|
||
|
|
onUpload,
|
||
|
|
contractAddress,
|
||
|
|
}: PaymentPanelProps) {
|
||
|
|
// Wallet state
|
||
|
|
const [address, setAddress] = useState<string | null>(null);
|
||
|
|
const [provider, setProvider] = useState<WalletProvider | null>(null);
|
||
|
|
const [connecting, setConnecting] = useState(false);
|
||
|
|
const [walletType, setWalletType] = useState<string | null>(null);
|
||
|
|
const [wrongChain, setWrongChain] = useState(false);
|
||
|
|
|
||
|
|
// Contract state
|
||
|
|
const [deployed, setDeployed] = useState(false);
|
||
|
|
const [price, setPrice] = useState<PriceInfo | null>(null);
|
||
|
|
const [userStats, setUserStats] = useState<UserUploadStats | null>(null);
|
||
|
|
const [tokens, setTokens] = useState<TokenInfo[]>([]);
|
||
|
|
const [discountTiers, setDiscountTiers] = useState<MAOSDiscountTier[]>([]);
|
||
|
|
const [selectedToken, setSelectedToken] = useState('ETH');
|
||
|
|
const [freeTierBytes, setFreeTierBytes] = useState<bigint>(BigInt(0));
|
||
|
|
|
||
|
|
// Upload state
|
||
|
|
const [uploading, setUploading] = useState(false);
|
||
|
|
const [paying, setPaying] = useState(false);
|
||
|
|
const [status, setStatus] = useState<'idle' | 'paying' | 'uploading' | 'complete' | 'error'>('idle');
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [txHash, setTxHash] = useState<string | null>(null);
|
||
|
|
const [uploadResult, setUploadResult] = useState<{ cid: string; size: number } | null>(null);
|
||
|
|
const [copied, setCopied] = useState(false);
|
||
|
|
|
||
|
|
// Init
|
||
|
|
useEffect(() => {
|
||
|
|
if (contractAddress) {
|
||
|
|
paymentService.setContractAddress(contractAddress);
|
||
|
|
}
|
||
|
|
paymentService.isDeployed().then(setDeployed);
|
||
|
|
}, [contractAddress]);
|
||
|
|
|
||
|
|
// Refresh price + stats when address or fileSize changes
|
||
|
|
const refreshPricing = useCallback(async (addr: string, size: number) => {
|
||
|
|
if (!deployed) return;
|
||
|
|
try {
|
||
|
|
const [p, stats, t, tiers, freeBytes] = await Promise.all([
|
||
|
|
paymentService.getPrice(size, addr as `0x${string}`),
|
||
|
|
paymentService.getUserStats(addr as `0x${string}`),
|
||
|
|
paymentService.getSupportedTokens(),
|
||
|
|
paymentService.getMAOSDiscountTiers(),
|
||
|
|
paymentService.getFreeTierBytes(),
|
||
|
|
]);
|
||
|
|
setPrice(p);
|
||
|
|
setUserStats(stats);
|
||
|
|
setTokens(t.filter(tk => tk.enabled || tk.symbol === 'ETH'));
|
||
|
|
setDiscountTiers(tiers);
|
||
|
|
setFreeTierBytes(freeBytes);
|
||
|
|
} catch (e) {
|
||
|
|
console.warn('Payment contract read failed:', e);
|
||
|
|
}
|
||
|
|
}, [deployed]);
|
||
|
|
|
||
|
|
// Connect wallet
|
||
|
|
async function handleConnect() {
|
||
|
|
setConnecting(true);
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
const result = await connectWallet();
|
||
|
|
const prov = getInjectedProvider();
|
||
|
|
setAddress(result.address);
|
||
|
|
setProvider(prov);
|
||
|
|
|
||
|
|
// Detect wallet type
|
||
|
|
// @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');
|
||
|
|
|
||
|
|
// Check chain
|
||
|
|
if (result.chainId !== 270) {
|
||
|
|
setWrongChain(true);
|
||
|
|
const switched = await switchToChain270(prov || undefined);
|
||
|
|
if (switched) setWrongChain(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
await refreshPricing(result.address, fileSize);
|
||
|
|
} catch (e: any) {
|
||
|
|
setError(e.message || 'Failed to connect wallet');
|
||
|
|
} finally {
|
||
|
|
setConnecting(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Refresh when fileSize changes
|
||
|
|
useEffect(() => {
|
||
|
|
if (address && deployed) {
|
||
|
|
refreshPricing(address, fileSize);
|
||
|
|
}
|
||
|
|
}, [fileSize, address, deployed, refreshPricing]);
|
||
|
|
|
||
|
|
// Pay + Upload flow
|
||
|
|
async function handlePayAndUpload() {
|
||
|
|
if (!provider || !address) return;
|
||
|
|
setPaying(true);
|
||
|
|
setError(null);
|
||
|
|
setStatus('paying');
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Step 1: Upload to IPFS
|
||
|
|
setStatus('uploading');
|
||
|
|
const upload = await onUpload();
|
||
|
|
setUploadResult(upload);
|
||
|
|
|
||
|
|
// Step 2: Pay (if not free)
|
||
|
|
if (price && !price.isFree && price.finalWei > BigInt(0)) {
|
||
|
|
if (selectedToken === 'ETH') {
|
||
|
|
const hash = await paymentService.payWithETH(provider, upload.cid, fileSize, price.finalWei);
|
||
|
|
setTxHash(hash);
|
||
|
|
onPaid({ cid: upload.cid, txHash: hash, method: 'eth' });
|
||
|
|
} else {
|
||
|
|
// Token payment — need to find token address
|
||
|
|
const token = tokens.find(t => t.symbol === selectedToken);
|
||
|
|
if (!token || token.address === '0x0000000000000000000000000000000000000000') {
|
||
|
|
throw new Error(`${selectedToken} not configured on contract`);
|
||
|
|
}
|
||
|
|
// Calculate token amount
|
||
|
|
const tokenAmount = (price.finalWei * BigInt(10 ** token.decimals)) / token.weiPerToken;
|
||
|
|
const result = await paymentService.approveAndPayWithToken(
|
||
|
|
provider, token.address as `0x${string}`,
|
||
|
|
upload.cid, fileSize, tokenAmount, selectedToken
|
||
|
|
);
|
||
|
|
setTxHash(result.payHash);
|
||
|
|
onPaid({ cid: upload.cid, txHash: result.payHash, method: 'token' });
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Free upload
|
||
|
|
onPaid({ cid: upload.cid, method: 'free' });
|
||
|
|
}
|
||
|
|
|
||
|
|
setStatus('complete');
|
||
|
|
} catch (e: any) {
|
||
|
|
setError(e.message || 'Payment/upload failed');
|
||
|
|
setStatus('error');
|
||
|
|
} finally {
|
||
|
|
setPaying(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function copyCID() {
|
||
|
|
if (!uploadResult) return;
|
||
|
|
try {
|
||
|
|
await navigator.clipboard.writeText(uploadResult.cid);
|
||
|
|
setCopied(true);
|
||
|
|
setTimeout(() => setCopied(false), 2000);
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Render ── */
|
||
|
|
return (
|
||
|
|
<div className="glass rounded-xl p-5 space-y-5">
|
||
|
|
{/* ── Header ── */}
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Wallet className="w-4 h-4 text-brand-400" />
|
||
|
|
<h3 className="text-sm font-semibold text-white">Crypto Payment</h3>
|
||
|
|
</div>
|
||
|
|
{address && walletType && (
|
||
|
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-700 text-surface-400">
|
||
|
|
{walletType}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{!deployed ? (
|
||
|
|
/* ── Not deployed ── */
|
||
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-800/50 text-surface-400 text-xs">
|
||
|
|
<Shield className="w-3.5 h-3.5" />
|
||
|
|
Payment contract not deployed — uploads are free
|
||
|
|
</div>
|
||
|
|
) : !address ? (
|
||
|
|
/* ── Connect wallet ── */
|
||
|
|
<button
|
||
|
|
onClick={handleConnect}
|
||
|
|
disabled={connecting}
|
||
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 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>
|
||
|
|
) : wrongChain ? (
|
||
|
|
/* ── Wrong chain ── */
|
||
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs">
|
||
|
|
<XCircle className="w-3.5 h-3.5 shrink-0" />
|
||
|
|
Please switch to zkSync Local (chain 270)
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
/* ── Connected — show pricing ── */
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* Wallet address */}
|
||
|
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
|
||
|
|
<span className="text-xs text-surface-400">Connected</span>
|
||
|
|
<span className="text-xs font-mono text-surface-200">
|
||
|
|
{address.slice(0, 6)}...{address.slice(-4)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Free tier info */}
|
||
|
|
{userStats && (
|
||
|
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Zap className="w-3.5 h-3.5 text-accent-cyan" />
|
||
|
|
<span className="text-xs text-surface-400">Free tier</span>
|
||
|
|
</div>
|
||
|
|
<span className="text-xs text-surface-300">
|
||
|
|
{formatBytes(userStats.remainingFree)} / {formatBytes(freeTierBytes)} remaining
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Price info */}
|
||
|
|
{price && (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
|
||
|
|
<span className="text-xs text-surface-400">File size</span>
|
||
|
|
<span className="text-xs text-surface-200">{formatBytes(fileSize)}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{price.isFree ? (
|
||
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent-green/10 border border-accent-green/20 text-accent-green text-xs">
|
||
|
|
<CheckCircle className="w-3.5 h-3.5" />
|
||
|
|
Free upload (within free tier)
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
|
||
|
|
<span className="text-xs text-surface-400">Price</span>
|
||
|
|
<span className="text-xs text-surface-200">{formatWeiToETH(price.totalWei, 6)} ETH</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{price.discountBps > 0 && (
|
||
|
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-accent-cyan/10 border border-accent-cyan/20">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Tag className="w-3 h-3 text-accent-cyan" />
|
||
|
|
<span className="text-xs text-accent-cyan">MAOS discount</span>
|
||
|
|
</div>
|
||
|
|
<span className="text-xs text-accent-cyan font-medium">-{price.discountBps / 100}%</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-brand-500/10 border border-brand-500/20">
|
||
|
|
<span className="text-xs font-medium text-surface-300">Final</span>
|
||
|
|
<span className="text-sm font-bold text-brand-400">
|
||
|
|
{formatWeiToETH(price.finalWei, 6)} ETH
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Token selector */}
|
||
|
|
{tokens.length > 1 && (
|
||
|
|
<div>
|
||
|
|
<label className="text-xs text-surface-400 mb-1.5 block">Pay with</label>
|
||
|
|
<div className="flex gap-1.5">
|
||
|
|
{tokens.filter(t => t.enabled || t.symbol === 'ETH').map(token => (
|
||
|
|
<button
|
||
|
|
key={token.symbol}
|
||
|
|
onClick={() => setSelectedToken(token.symbol)}
|
||
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||
|
|
selectedToken === token.symbol
|
||
|
|
? 'bg-brand-500/20 text-brand-400 border border-brand-500/30'
|
||
|
|
: 'bg-surface-800 text-surface-400 border border-surface-700 hover:border-surface-500'
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<span>{TOKEN_ICONS[token.symbol] || '●'}</span>
|
||
|
|
{token.symbol}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Action button */}
|
||
|
|
<button
|
||
|
|
onClick={handlePayAndUpload}
|
||
|
|
disabled={paying || uploading || status === 'complete'}
|
||
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||
|
|
>
|
||
|
|
{paying ? (
|
||
|
|
<><Loader2 className="w-4 h-4 animate-spin" /> Processing payment...</>
|
||
|
|
) : uploading ? (
|
||
|
|
<><Loader2 className="w-4 h-4 animate-spin" /> Uploading to IPFS...</>
|
||
|
|
) : status === 'complete' ? (
|
||
|
|
<><CheckCircle className="w-4 h-4" /> Complete</>
|
||
|
|
) : (
|
||
|
|
<><ArrowRight className="w-4 h-4" /> {price?.isFree ? 'Upload Free' : `Pay & Upload`}</>
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ── Result ── */}
|
||
|
|
{uploadResult && status === 'complete' && (
|
||
|
|
<div className="space-y-2 p-3 rounded-lg bg-accent-green/10 border border-accent-green/20 animate-fade-in">
|
||
|
|
<div className="flex items-center gap-2 text-accent-green">
|
||
|
|
<CheckCircle className="w-4 h-4" />
|
||
|
|
<span className="text-xs font-medium">Upload + Payment successful</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-between text-xs">
|
||
|
|
<span className="text-surface-400">CID</span>
|
||
|
|
<button onClick={copyCID} className="flex items-center gap-1 text-surface-300 hover:text-surface-100 font-mono">
|
||
|
|
{uploadResult.cid.slice(0, 12)}...
|
||
|
|
{copied ? <CheckCircle className="w-3 h-3 text-accent-green" /> : <Copy className="w-3 h-3" />}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{txHash && (
|
||
|
|
<div className="flex items-center justify-between text-xs">
|
||
|
|
<span className="text-surface-400">Tx</span>
|
||
|
|
<a
|
||
|
|
href={`http://tom1687.no-ip.org:3050/tx/${txHash}`}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="flex items-center gap-1 text-brand-400 hover:text-brand-300 font-mono"
|
||
|
|
>
|
||
|
|
{txHash.slice(0, 10)}...
|
||
|
|
<ExternalLink className="w-3 h-3" />
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ── Error ── */}
|
||
|
|
{error && (
|
||
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-xs animate-fade-in">
|
||
|
|
<XCircle className="w-3.5 h-3.5 shrink-0" />
|
||
|
|
{error}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ── MAOS Discount tiers ── */}
|
||
|
|
{address && discountTiers.length > 0 && (
|
||
|
|
<details className="text-xs text-surface-500">
|
||
|
|
<summary className="cursor-pointer hover:text-surface-400 transition-colors">
|
||
|
|
MAOS discount tiers
|
||
|
|
</summary>
|
||
|
|
<div className="mt-2 space-y-1">
|
||
|
|
{discountTiers.map((tier, i) => (
|
||
|
|
<div key={i} className="flex justify-between px-2 py-1 rounded bg-surface-800/30">
|
||
|
|
<span>{formatWeiToETH(tier.minBalance, 0)} MAOS</span>
|
||
|
|
<span className="text-accent-cyan">{tier.discountBps / 100}% off</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</details>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|