Files
IPFS-portal/src/components/PaymentPanel.tsx
T

391 lines
16 KiB
TypeScript
Raw Normal View History

'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).catch(() => setDeployed(false));
}, [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
if (window.maosv6) setWalletType('MAOS Wallet');
else if (window.ethereum?.isRabby) setWalletType('Rabby');
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>
);
}