forked from maik/IPFS-portal
207 lines
6.9 KiB
TypeScript
207 lines
6.9 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState, useEffect, useCallback } from 'react';
|
||
|
|
import { usePaymentService, type PriceInfo, type TokenInfo, type MAOSDiscountTier, type UserUploadStats } from '@/lib/payment';
|
||
|
|
import {
|
||
|
|
connectWallet, switchToChain270, getInjectedProvider,
|
||
|
|
type WalletProvider,
|
||
|
|
} from '@/lib/wallet';
|
||
|
|
import PaymentHeader from './PaymentHeader';
|
||
|
|
import PricingDisplay from './PricingDisplay';
|
||
|
|
import PaymentResult from './PaymentResult';
|
||
|
|
|
||
|
|
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) {
|
||
|
|
const paymentService = usePaymentService();
|
||
|
|
// 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);
|
||
|
|
|
||
|
|
// Init
|
||
|
|
useEffect(() => {
|
||
|
|
if (contractAddress) {
|
||
|
|
paymentService.setContractAddress(contractAddress);
|
||
|
|
}
|
||
|
|
paymentService.isDeployed().then(setDeployed).catch((err) => { console.error('[PaymentPanel] Deploy check failed:', err); 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: unknown) {
|
||
|
|
setError(e instanceof Error ? 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) ** BigInt(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: unknown) {
|
||
|
|
setError(e instanceof Error ? e.message : 'Payment/upload failed');
|
||
|
|
setStatus('error');
|
||
|
|
} finally {
|
||
|
|
setPaying(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Render ── */
|
||
|
|
return (
|
||
|
|
<div className="glass rounded-xl p-5 space-y-5">
|
||
|
|
<PaymentHeader
|
||
|
|
deployed={deployed}
|
||
|
|
address={address}
|
||
|
|
walletType={walletType}
|
||
|
|
connecting={connecting}
|
||
|
|
wrongChain={wrongChain}
|
||
|
|
onConnect={handleConnect}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{deployed && address && !wrongChain && (
|
||
|
|
<PricingDisplay
|
||
|
|
price={price}
|
||
|
|
userStats={userStats}
|
||
|
|
tokens={tokens}
|
||
|
|
discountTiers={discountTiers}
|
||
|
|
selectedToken={selectedToken}
|
||
|
|
freeTierBytes={freeTierBytes}
|
||
|
|
fileSize={fileSize}
|
||
|
|
status={status}
|
||
|
|
paying={paying}
|
||
|
|
uploading={uploading}
|
||
|
|
address={address}
|
||
|
|
onSelectToken={setSelectedToken}
|
||
|
|
onPayAndUpload={handlePayAndUpload}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<PaymentResult
|
||
|
|
uploadResult={uploadResult}
|
||
|
|
txHash={txHash}
|
||
|
|
status={status}
|
||
|
|
error={error}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|