292 lines
14 KiB
TypeScript
292 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
Copy,
|
||
CheckCircle2,
|
||
RefreshCw,
|
||
AlertCircle,
|
||
ChevronDown
|
||
} from 'lucide-react';
|
||
import cryptoConfig from '@/lib/crypto-config.json';
|
||
|
||
interface CryptoCheckoutProps {
|
||
amount: number;
|
||
currency: string;
|
||
txId: string;
|
||
wallets?: {
|
||
[key: string]: any;
|
||
};
|
||
onSuccess: (txHash: string) => void;
|
||
}
|
||
|
||
export default function CryptoCheckout({ amount, currency, txId, wallets, onSuccess }: CryptoCheckoutProps) {
|
||
const [selectedNetwork, setSelectedNetwork] = useState(cryptoConfig.networks[0]);
|
||
const [selectedToken, setSelectedToken] = useState(selectedNetwork.tokens[0]);
|
||
const [depositAddress, setDepositAddress] = useState<string>('Yükleniyor...');
|
||
const [isVerifying, setIsVerifying] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
const [status, setStatus] = useState<'waiting' | 'verifying' | 'success' | 'error'>('waiting');
|
||
const [cryptoAmount, setCryptoAmount] = useState<string>('Hesaplanıyor...');
|
||
const [unitPrice, setUnitPrice] = useState<string>('');
|
||
const [unitPriceUsd, setUnitPriceUsd] = useState<string>('');
|
||
|
||
// Update address based on selected network
|
||
useEffect(() => {
|
||
if (wallets) {
|
||
// Support all 4 networks and both string/object formats
|
||
const rawVal = wallets[selectedNetwork.id] || wallets['EVM'];
|
||
const addr = typeof rawVal === 'string' ? rawVal : rawVal?.address;
|
||
setDepositAddress(addr || 'Adres Bulunamadı');
|
||
}
|
||
}, [selectedNetwork, wallets]);
|
||
|
||
// Fetch exchange rate
|
||
useEffect(() => {
|
||
async function fetchExchangeRate() {
|
||
setCryptoAmount('...');
|
||
try {
|
||
const symbol = selectedToken.symbol;
|
||
let rateInTry = 32.5;
|
||
let rateInUsd = 1.0;
|
||
|
||
// 1. Get USDTRY rate first
|
||
const tryRes = await fetch(`https://api.binance.com/api/v3/ticker/price?symbol=USDTTRY`);
|
||
const tryData = await tryRes.json();
|
||
const tryToUsdRate = parseFloat(tryData.price) || 32.5;
|
||
|
||
const isStable = ['USDT', 'USDC', 'BUSD', 'DAI'].includes(symbol);
|
||
|
||
if (!isStable) {
|
||
// Try to fetch [SYMBOL]USDT
|
||
try {
|
||
const pair = `${symbol}USDT`;
|
||
const res = await fetch(`https://api.binance.com/api/v3/ticker/price?symbol=${pair}`);
|
||
const data = await res.json();
|
||
|
||
if (data.price) {
|
||
rateInUsd = parseFloat(data.price);
|
||
rateInTry = rateInUsd * tryToUsdRate;
|
||
} else {
|
||
// Try fallback mappings if direct USDT pair fails
|
||
rateInTry = tryToUsdRate; // Default to 1 USD value
|
||
}
|
||
} catch (e) {
|
||
rateInTry = tryToUsdRate;
|
||
}
|
||
} else {
|
||
rateInTry = tryToUsdRate;
|
||
rateInUsd = 1.0;
|
||
}
|
||
|
||
setUnitPrice(rateInTry.toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 4 }));
|
||
setUnitPriceUsd(rateInUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 }));
|
||
|
||
const finalAmount = (amount / rateInTry).toFixed(selectedToken.decimals < 9 ? 4 : 6);
|
||
setCryptoAmount(finalAmount);
|
||
} catch (error) {
|
||
setCryptoAmount((amount / 32.5).toFixed(2));
|
||
}
|
||
}
|
||
fetchExchangeRate();
|
||
}, [amount, selectedToken, selectedNetwork]);
|
||
|
||
// Auto-polling for payment verification
|
||
useEffect(() => {
|
||
let interval: NodeJS.Timeout;
|
||
if (status === 'waiting' && depositAddress !== 'Yükleniyor...') {
|
||
interval = setInterval(() => {
|
||
verifyPayment();
|
||
}, 8000); // Check every 8 seconds
|
||
}
|
||
return () => clearInterval(interval);
|
||
}, [status, depositAddress, selectedNetwork, selectedToken]);
|
||
|
||
const changeNetwork = (networkId: string) => {
|
||
const network = cryptoConfig.networks.find(n => n.id === networkId) || cryptoConfig.networks[0];
|
||
setSelectedNetwork(network);
|
||
setSelectedToken(network.tokens[0]);
|
||
};
|
||
|
||
// Save payment intent to DB so background sync knows what to check
|
||
useEffect(() => {
|
||
if (!txId || !selectedNetwork || !selectedToken) return;
|
||
|
||
const saveIntent = async () => {
|
||
try {
|
||
await fetch(`/api/transactions/${txId}/intent`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
network: selectedNetwork.id,
|
||
token: selectedToken.symbol
|
||
})
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to save payment intent:", err);
|
||
}
|
||
};
|
||
|
||
const timer = setTimeout(saveIntent, 1000); // Debounce
|
||
return () => clearTimeout(timer);
|
||
}, [txId, selectedNetwork.id, selectedToken.symbol]);
|
||
|
||
const handleCopy = () => {
|
||
navigator.clipboard.writeText(depositAddress);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
const verifyPayment = async () => {
|
||
setIsVerifying(true);
|
||
try {
|
||
const response = await fetch('/api/crypto-sweep', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
txId: txId,
|
||
network: selectedNetwork.id,
|
||
token: selectedToken.symbol
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
setStatus('success');
|
||
onSuccess(data.hashes?.merchant || '0x_mock_hash');
|
||
} else if (data.status === 'waiting') {
|
||
setStatus('waiting');
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
} finally {
|
||
setIsVerifying(false);
|
||
}
|
||
};
|
||
|
||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${depositAddress}`;
|
||
|
||
return (
|
||
<div className="bg-white p-8 rounded-[40px] border border-gray-100 shadow-sm space-y-6 w-full max-w-lg">
|
||
{/* Crypto Selection Header */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Ağ Seçin</label>
|
||
<div className="relative group">
|
||
<select
|
||
value={selectedNetwork.id}
|
||
onChange={(e) => changeNetwork(e.target.value)}
|
||
className="w-full bg-gray-50 border-none rounded-2xl p-4 text-sm font-bold text-gray-900 appearance-none cursor-pointer focus:ring-2 focus:ring-blue-500/20"
|
||
>
|
||
{cryptoConfig.networks.map(net => (
|
||
<option key={net.id} value={net.id}>{net.icon} {net.name}</option>
|
||
))}
|
||
</select>
|
||
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" size={16} />
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Varlık</label>
|
||
<div className="relative">
|
||
<select
|
||
value={selectedToken.symbol}
|
||
onChange={(e) => setSelectedToken(selectedNetwork.tokens.find(t => t.symbol === e.target.value) || selectedNetwork.tokens[0])}
|
||
className="w-full bg-gray-50 border-none rounded-2xl p-4 text-sm font-bold text-gray-900 appearance-none cursor-pointer focus:ring-2 focus:ring-blue-500/20 pl-11"
|
||
>
|
||
{selectedNetwork.tokens.map(token => (
|
||
<option key={token.symbol} value={token.symbol}>{token.symbol}</option>
|
||
))}
|
||
</select>
|
||
<div className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center">
|
||
{/* @ts-ignore */}
|
||
<img
|
||
src={selectedToken.logo}
|
||
alt=""
|
||
className="w-full h-full object-contain rounded-full"
|
||
referrerPolicy="no-referrer"
|
||
/>
|
||
</div>
|
||
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" size={16} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-6 bg-gray-50 rounded-3xl flex items-center justify-between border border-gray-100">
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-14 h-14 bg-white rounded-2xl flex items-center justify-center p-2 shadow-sm border border-gray-50 overflow-hidden">
|
||
{/* @ts-ignore */}
|
||
<img
|
||
src={selectedToken.logo}
|
||
alt={selectedToken.symbol}
|
||
className="w-full h-full object-contain"
|
||
referrerPolicy="no-referrer"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-tight">Ödenecek Tutar</p>
|
||
<h4 className="text-2xl font-black text-gray-900 leading-none">{cryptoAmount} {selectedToken.symbol}</h4>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-[10px] text-gray-400 font-bold uppercase">Kur</p>
|
||
<p className="text-[10px] font-black text-blue-600 mb-1">1 {selectedToken.symbol} = {unitPrice} TRY</p>
|
||
<p className="text-[10px] font-bold text-gray-400">($ {unitPriceUsd})</p>
|
||
</div>
|
||
</div>
|
||
|
||
{status === 'success' ? (
|
||
<div className="py-8 text-center space-y-4 bg-emerald-50 rounded-3xl border border-emerald-100 animate-in fade-in slide-in-from-bottom-4">
|
||
<div className="w-16 h-16 bg-emerald-500 rounded-full flex items-center justify-center text-white mx-auto">
|
||
<CheckCircle2 size={32} />
|
||
</div>
|
||
<h2 className="text-xl font-black text-emerald-900 uppercase">Ödeme Alındı</h2>
|
||
<p className="text-emerald-700 font-bold text-[10px] uppercase tracking-widest">Mağazaya yönlendiriliyorsunuz...</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="space-y-4">
|
||
<div className="bg-white aspect-square rounded-[32px] flex flex-col items-center justify-center border-2 border-dashed border-gray-100 relative group overflow-hidden p-8">
|
||
{depositAddress !== 'Yükleniyor...' ? (
|
||
<img src={qrUrl} alt="Wallet QR" className="w-full h-full object-contain" />
|
||
) : (
|
||
<RefreshCw className="w-12 h-12 text-gray-200 animate-spin" />
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Cüzdan Adresi ({selectedNetwork.name})</label>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 bg-gray-50 p-4 rounded-2xl border border-gray-100 font-mono text-[10px] text-gray-500 break-all leading-tight">
|
||
{depositAddress}
|
||
</div>
|
||
<button
|
||
onClick={handleCopy}
|
||
className="p-4 bg-gray-900 text-white rounded-2xl hover:bg-black transition-all shadow-lg active:scale-95"
|
||
>
|
||
{copied ? <CheckCircle2 size={18} /> : <span>Kopyala</span>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 bg-orange-50 rounded-2xl border border-orange-100 flex gap-3">
|
||
<AlertCircle size={18} className="text-orange-600 shrink-0" />
|
||
<p className="text-[10px] text-orange-800 leading-relaxed font-bold">
|
||
Sadece <span className="underline text-orange-950">{selectedNetwork.name}</span> ağı üzerinden <span className="underline text-orange-950">{selectedToken.symbol}</span> gönderin.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={verifyPayment}
|
||
disabled={isVerifying || depositAddress === 'Yükleniyor...'}
|
||
className="w-full py-5 bg-gray-900 text-white rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl flex items-center justify-center gap-3 disabled:opacity-50"
|
||
>
|
||
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||
{isVerifying ? 'Doğrulanıyor...' : 'Otomatik Tarama Aktif'}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|