update: ....

This commit is contained in:
2026-03-19 01:02:52 +04:00
parent 1e6739b42a
commit 955b26f942
6 changed files with 267 additions and 194 deletions
+1 -1
View File
@@ -211,7 +211,7 @@ export const dashboard = new Elysia()
SELECT id, plan, months, amount_usd, coin, amount_crypto, status, created_at, paid_at, expires_at, txid
FROM payments
WHERE account_id = ${accountId}
AND (status = 'paid' OR (status IN ('pending', 'confirming') AND expires_at >= now()))
AND (status = 'paid' OR (status IN ('pending', 'underpaid', 'confirming') AND expires_at >= now()))
ORDER BY created_at DESC
LIMIT 20
`;
+146 -82
View File
@@ -55,7 +55,6 @@
<div id="coin-section" class="hidden">
<label class="block text-sm text-gray-400 mb-2">Pay with</label>
<div id="coin-grid" class="grid grid-cols-3 gap-2">
<!-- Populated by JS -->
</div>
</div>
@@ -84,6 +83,20 @@
<div class="text-xs text-gray-600 mt-1">$<span id="pay-usd"></span> USD</div>
</div>
<!-- Received / Remaining (shown on underpaid) -->
<div id="pay-received-section" class="hidden">
<div class="flex justify-center gap-6 text-sm">
<div>
<span class="text-gray-500">Received:</span>
<span class="text-green-400 font-mono" id="pay-received">0</span>
</div>
<div>
<span class="text-gray-500">Remaining:</span>
<span class="text-yellow-400 font-mono" id="pay-remaining">0</span>
</div>
</div>
</div>
<!-- Address -->
<div>
<label class="block text-xs text-gray-500 mb-1">Send to</label>
@@ -134,8 +147,13 @@
let selectedMonths = 1;
let coins = [];
let paymentId = null;
let paymentData = null; // full payment object
let pollInterval = null;
let countdownInterval = null;
let eventSource = null;
let watchedAddress = null;
let watchedTxids = [];
let localReceived = 0; // track received amount from SSE locally
// Fetch available coins on load
(async () => {
@@ -153,10 +171,7 @@
document.querySelectorAll('.plan-card').forEach(el => el.classList.remove('border-blue-500', 'border-yellow-500'));
const card = document.getElementById(`plan-${plan}`);
card.classList.add(plan === 'lifetime' ? 'border-yellow-500' : 'border-blue-500');
const monthsSection = document.getElementById('months-section');
monthsSection.classList.toggle('hidden', plan !== 'pro');
document.getElementById('months-section').classList.toggle('hidden', plan !== 'pro');
showCoins();
}
@@ -210,7 +225,6 @@
if (!res.ok) throw new Error(data.error || 'Checkout failed');
paymentId = data.id;
// Update URL so refreshing restores this invoice
history.replaceState(null, '', `/dashboard/checkout/${data.id}`);
showPayment(data);
} catch (err) {
@@ -223,48 +237,94 @@
}
function showPayment(data) {
paymentData = data;
document.getElementById('step-select').classList.add('hidden');
document.getElementById('step-pay').classList.remove('hidden');
document.getElementById('pay-qr').src = data.qr_url;
document.getElementById('pay-amount').textContent = data.amount_crypto;
document.getElementById('pay-coin-label').textContent = data.coin_label + ' (' + data.coin_ticker + ')';
document.getElementById('pay-usd').textContent = data.amount_usd.toFixed(2);
document.getElementById('pay-address').textContent = data.address;
document.getElementById('pay-coin-label').textContent = data.coin_label + ' (' + data.coin_ticker + ')';
document.getElementById('pay-usd').textContent = Number(data.amount_usd).toFixed(2);
// Show current status immediately
if (data.status === 'confirming') {
document.getElementById('pay-status').innerHTML = `
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
<span class="text-blue-400">Transaction detected, waiting for confirmation...</span>
`;
if (data.txid) watchedTxids.push(data.txid);
}
localReceived = parseFloat(data.amount_received || '0');
updateAmountDisplay(data);
applyStatus(data.status, data);
// Start countdown
const expiresAt = new Date(data.expires_at).getTime();
updateCountdown(expiresAt);
countdownInterval = setInterval(() => updateCountdown(expiresAt), 1000);
// Start SSE for instant tx/block detection
// Start SSE
watchAddress(data.coin, data.address);
// Poll full checkout as fallback
// Poll as fallback
pollInterval = setInterval(() => pollPayment(), 10000);
}
let watchedAddress = null;
let watchedTxids = [];
function updateAmountDisplay(data) {
const received = parseFloat(data.amount_received || '0');
const total = parseFloat(data.amount_crypto);
const remaining = Math.max(0, total - received);
/** Listen to raw SSE via EventSource for this coin, match tx outputs locally.
* Tracks multiple txids (user may send across several transactions).
* On block, checks if any of our txids got confirmed. */
let eventSource = null;
if (received > 0 && remaining > 0) {
// Underpaid: show remaining as main amount
document.getElementById('pay-amount').textContent = remaining.toFixed(8);
document.getElementById('pay-received-section').classList.remove('hidden');
document.getElementById('pay-received').textContent = received.toFixed(8);
document.getElementById('pay-remaining').textContent = remaining.toFixed(8);
// Update QR with remaining amount
if (data.qr_url) document.getElementById('pay-qr').src = data.qr_url;
} else {
document.getElementById('pay-amount').textContent = data.amount_crypto;
document.getElementById('pay-received-section').classList.add('hidden');
if (data.qr_url) document.getElementById('pay-qr').src = data.qr_url;
}
}
function applyStatus(status, data) {
if (status === 'underpaid') {
document.getElementById('pay-status').innerHTML = `
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
<span class="text-yellow-400">Underpaid — please send the remaining amount</span>
`;
if (data?.txid) {
for (const t of data.txid.split(',')) {
if (!watchedTxids.includes(t)) watchedTxids.push(t);
}
}
} else if (status === 'confirming') {
document.getElementById('pay-status').innerHTML = `
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
<span class="text-blue-400">Transaction detected, waiting for confirmation...</span>
`;
if (data?.txid) {
for (const t of data.txid.split(',')) {
if (!watchedTxids.includes(t)) watchedTxids.push(t);
}
}
} else if (status === 'paid') {
clearInterval(pollInterval);
clearInterval(countdownInterval);
if (eventSource) { eventSource.close(); eventSource = null; }
document.getElementById('pay-status-section').classList.add('hidden');
document.getElementById('pay-received-section').classList.add('hidden');
document.getElementById('pay-success').classList.remove('hidden');
setTimeout(() => { window.location.href = '/dashboard/settings'; }, 3000);
} else if (status === 'expired') {
clearInterval(pollInterval);
clearInterval(countdownInterval);
if (eventSource) { eventSource.close(); eventSource = null; }
document.getElementById('pay-status-section').classList.add('hidden');
document.getElementById('pay-received-section').classList.add('hidden');
document.getElementById('pay-expired').classList.remove('hidden');
}
}
// ── SSE ──────────────────────────────────────────────────────────
function watchAddress(coin, address) {
if (eventSource) { eventSource.close(); eventSource = null; }
watchedAddress = address;
watchedTxids = [];
eventSource = new EventSource(`${SOCK_API}/sse`);
console.log('SSE: connected, watching for', address);
@@ -272,11 +332,8 @@
eventSource.onmessage = (e) => {
try {
const event = JSON.parse(e.data);
if (event.type === 'block') {
onBlock(event);
} else if (event.type === 'tx') {
onTx(event);
}
if (event.type === 'block') onBlock(event);
else if (event.type === 'tx') onTx(event);
} catch {}
};
@@ -284,20 +341,40 @@
}
function onTx(event) {
if (!watchedAddress) return;
if (!watchedAddress || !paymentData) return;
const outputs = event.data?.out ?? [];
const txHash = event.data?.tx?.hash ?? null;
if (!txHash || watchedTxids.includes(txHash)) return;
// Sum outputs going to our address
let txValue = 0;
for (const out of outputs) {
const addr = out?.script?.address;
if (!addr || addr !== watchedAddress) continue;
const txHash = event.data?.tx?.hash ?? null;
if (!txHash || watchedTxids.includes(txHash)) return;
watchedTxids.push(txHash);
console.log('SSE: tx', txHash, 'matches our address');
document.getElementById('pay-status').innerHTML = `
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
<span class="text-blue-400">Transaction detected, waiting for confirmation...</span>
`;
return;
if (out?.script?.address === watchedAddress) {
txValue += Number(out.value ?? 0);
}
}
if (txValue === 0) return;
watchedTxids.push(txHash);
localReceived += txValue;
console.log('SSE: tx', txHash, '+' + txValue, 'total:', localReceived);
const expected = parseFloat(paymentData.amount_crypto);
const remaining = Math.max(0, expected - localReceived);
// Update received display
document.getElementById('pay-received-section').classList.remove('hidden');
document.getElementById('pay-received').textContent = localReceived.toFixed(8);
document.getElementById('pay-remaining').textContent = remaining.toFixed(8);
if (remaining <= expected * 0.005) {
// Full amount received
document.getElementById('pay-amount').textContent = expected.toFixed(8);
applyStatus('confirming', null);
} else {
// Underpaid
document.getElementById('pay-amount').textContent = remaining.toFixed(8);
applyStatus('underpaid', null);
}
}
@@ -306,51 +383,35 @@
const blockTxs = event.data?.tx ?? [];
if (watchedTxids.some(t => blockTxs.includes(t))) {
console.log('SSE: block confirmed our tx');
// Show confirmed immediately, poll will finalize with backend
clearInterval(countdownInterval);
if (eventSource) { eventSource.close(); eventSource = null; }
document.getElementById('pay-status-section').classList.add('hidden');
document.getElementById('pay-success').classList.remove('hidden');
setTimeout(() => { window.location.href = '/dashboard/settings'; }, 3000);
applyStatus('paid', null);
}
}
// ── Polling fallback ─────────────────────────────────────────────
async function pollPayment() {
try {
const res = await fetch(`${PAY_API}/checkout/${paymentId}`, { credentials: 'include' });
const data = await res.json();
paymentData = data;
// Sync local received from server if server knows more
const serverReceived = parseFloat(data.amount_received || '0');
if (serverReceived > localReceived) localReceived = serverReceived;
updateAmountDisplay({ ...data, amount_received: localReceived.toFixed(8) });
applyStatus(data.status, data);
} catch {}
}
// ── Utilities ────────────────────────────────────────────────────
function updateCountdown(expiresAt) {
const remaining = Math.max(0, expiresAt - Date.now());
const mins = Math.floor(remaining / 60000);
const secs = Math.floor((remaining % 60000) / 1000);
document.getElementById('pay-countdown').textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
if (remaining <= 0) {
clearInterval(countdownInterval);
}
}
async function pollPayment() {
try {
const res = await fetch(`${PAY_API}/checkout/${paymentId}`, { credentials: 'include' });
const data = await res.json();
if (data.status === 'confirming') {
document.getElementById('pay-status').innerHTML = `
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
<span class="text-blue-400">Transaction detected, waiting for confirmation...</span>
`;
if (data.txid && !watchedTxids.includes(data.txid)) watchedTxids.push(data.txid);
} else if (data.status === 'paid') {
clearInterval(pollInterval);
clearInterval(countdownInterval);
if (eventSource) { eventSource.close(); eventSource = null; }
document.getElementById('pay-status-section').classList.add('hidden');
document.getElementById('pay-success').classList.remove('hidden');
setTimeout(() => { window.location.href = '/dashboard/settings'; }, 3000);
} else if (data.status === 'expired') {
clearInterval(pollInterval);
clearInterval(countdownInterval);
if (eventSource) { eventSource.close(); eventSource = null; }
document.getElementById('pay-status-section').classList.add('hidden');
document.getElementById('pay-expired').classList.remove('hidden');
}
} catch {}
if (remaining <= 0) clearInterval(countdownInterval);
}
function copyAddress() {
@@ -361,7 +422,7 @@
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
}
// Auto-load existing invoice if arriving via /dashboard/checkout/:id
// Auto-load existing invoice
const PRELOAD_INVOICE_ID = <%~ JSON.stringify(it.invoiceId) %>;
if (PRELOAD_INVOICE_ID) {
(async () => {
@@ -393,10 +454,13 @@
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
watchedAddress = null;
watchedTxids = [];
localReceived = 0;
paymentData = null;
document.getElementById('step-select').classList.remove('hidden');
document.getElementById('step-pay').classList.add('hidden');
document.getElementById('pay-success').classList.add('hidden');
document.getElementById('pay-expired').classList.add('hidden');
document.getElementById('pay-received-section').classList.add('hidden');
document.getElementById('pay-status-section').classList.remove('hidden');
document.getElementById('pay-status').innerHTML = `
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
+3 -3
View File
@@ -52,14 +52,14 @@
<h2 class="text-sm font-semibold text-gray-300 mb-4">Invoices</h2>
<div class="space-y-2">
<% it.invoices.forEach(function(inv) {
const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow' };
const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' };
const statusColor = statusColors[inv.status] || 'gray';
const date = new Date(inv.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
const planLabel = inv.plan === 'lifetime' ? 'Lifetime' : `Pro × ${inv.months}mo`;
%>
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
<div class="flex items-center gap-3">
<span class="w-2 h-2 rounded-full bg-<%= statusColor %>-500 <%= inv.status === 'pending' || inv.status === 'confirming' ? 'animate-pulse' : '' %>"></span>
<span class="w-2 h-2 rounded-full bg-<%= statusColor %>-500 <%= inv.status !== 'paid' ? 'animate-pulse' : '' %>"></span>
<div>
<span class="text-sm text-gray-200"><%= planLabel %></span>
<span class="text-xs text-gray-600 ml-2">$<%= Number(inv.amount_usd).toFixed(2) %> · <%= inv.coin.toUpperCase() %></span>
@@ -67,7 +67,7 @@
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-500"><%= date %></span>
<% if (inv.status === 'pending' || inv.status === 'confirming') { %>
<% if (inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming') { %>
<a href="/dashboard/checkout/<%= inv.id %>" class="text-xs text-blue-400 hover:text-blue-300">View</a>
<% } else if (inv.status === 'paid' && inv.txid) { %>
<span class="text-xs text-green-500/70">Paid</span>