386 lines
16 KiB
Plaintext
386 lines
16 KiB
Plaintext
<%~ include('./partials/head', { title: 'Upgrade' }) %>
|
|
<%~ include('./partials/nav', { nav: 'settings' }) %>
|
|
|
|
<%
|
|
const plan = it.account.plan;
|
|
const expiresAt = it.account.plan_expires_at;
|
|
%>
|
|
|
|
<main class="max-w-2xl mx-auto px-6 py-8">
|
|
<div class="mb-6">
|
|
<a href="/dashboard/settings" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back to settings</a>
|
|
<h2 class="text-lg font-semibold text-gray-200 mt-2">Upgrade Plan</h2>
|
|
</div>
|
|
|
|
<!-- Step 1: Plan & coin selection -->
|
|
<div id="step-select" class="space-y-6">
|
|
|
|
<!-- Plan cards -->
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<% if (plan !== 'lifetime') { %>
|
|
<button onclick="selectPlan('pro')" id="plan-pro"
|
|
class="plan-card text-left bg-gray-900 border-2 border-gray-800 hover:border-blue-500/50 rounded-xl p-5 transition-colors">
|
|
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-1">Pro</div>
|
|
<div class="text-2xl font-bold text-gray-100">$14<span class="text-sm font-normal text-gray-500">/mo</span></div>
|
|
<div class="text-xs text-gray-500 mt-2">500 monitors, 2s intervals</div>
|
|
</button>
|
|
<% } %>
|
|
<% if (plan !== 'lifetime') { %>
|
|
<button onclick="selectPlan('lifetime')" id="plan-lifetime"
|
|
class="plan-card text-left bg-gray-900 border-2 border-gray-800 hover:border-yellow-500/50 rounded-xl p-5 transition-colors">
|
|
<div class="text-xs text-yellow-500/70 uppercase tracking-wider font-mono mb-1">Lifetime</div>
|
|
<div class="text-2xl font-bold text-gray-100">$149</div>
|
|
<div class="text-xs text-gray-500 mt-2">One-time, forever</div>
|
|
</button>
|
|
<% } %>
|
|
</div>
|
|
|
|
<!-- Months selector (shown for pro) -->
|
|
<div id="months-section" class="hidden">
|
|
<label class="block text-sm text-gray-400 mb-2">How many months?</label>
|
|
<div class="flex items-center gap-4">
|
|
<input type="range" id="months-range" min="1" max="12" value="1"
|
|
class="flex-1 accent-blue-500" oninput="updateMonths(this.value)">
|
|
<div class="text-right" style="min-width:80px">
|
|
<span id="months-display" class="text-lg font-semibold text-gray-100">1</span>
|
|
<span class="text-sm text-gray-500"> month</span>
|
|
</div>
|
|
</div>
|
|
<div class="text-right mt-1">
|
|
<span class="text-sm text-gray-400">Total: $<span id="total-display">14</span></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Coin selector -->
|
|
<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>
|
|
|
|
<!-- Error -->
|
|
<div id="select-error" class="text-red-400 text-sm hidden"></div>
|
|
|
|
<!-- Continue button -->
|
|
<button id="continue-btn" onclick="createCheckout()" disabled
|
|
class="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-gray-800 disabled:text-gray-600 text-white font-medium py-3 rounded-lg transition-colors hidden">
|
|
Continue to Payment
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Step 2: Payment -->
|
|
<div id="step-pay" class="hidden">
|
|
<div class="bg-gray-900 border border-gray-800 rounded-xl p-6 text-center space-y-5">
|
|
<!-- QR -->
|
|
<div>
|
|
<img id="pay-qr" src="" alt="QR Code" class="w-48 h-48 mx-auto rounded-lg bg-white p-2">
|
|
</div>
|
|
|
|
<!-- Amount -->
|
|
<div>
|
|
<div class="text-2xl font-bold font-mono text-gray-100" id="pay-amount"></div>
|
|
<div class="text-sm text-gray-500" id="pay-coin-label"></div>
|
|
<div class="text-xs text-gray-600 mt-1">$<span id="pay-usd"></span> USD</div>
|
|
</div>
|
|
|
|
<!-- Address -->
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Send to</label>
|
|
<div class="flex gap-2">
|
|
<code id="pay-address" class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-blue-400 text-xs font-mono select-all break-all"></code>
|
|
<button onclick="copyAddress()" id="copy-btn" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors shrink-0">Copy</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div id="pay-status-section">
|
|
<div id="pay-status" class="flex items-center justify-center gap-2 text-sm">
|
|
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
|
|
<span class="text-gray-400">Waiting for payment...</span>
|
|
</div>
|
|
<div class="text-xs text-gray-600 mt-2">
|
|
Expires in <span id="pay-countdown" class="font-mono"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Success -->
|
|
<div id="pay-success" class="hidden">
|
|
<div class="flex items-center justify-center gap-2 text-green-400">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
<span class="font-medium">Payment confirmed!</span>
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-2">Redirecting to settings...</p>
|
|
</div>
|
|
|
|
<!-- Expired -->
|
|
<div id="pay-expired" class="hidden">
|
|
<div class="flex items-center justify-center gap-2 text-red-400">
|
|
<span class="font-medium">Payment expired</span>
|
|
</div>
|
|
<button onclick="resetCheckout()" class="mt-3 text-sm text-blue-400 hover:text-blue-300">Try again</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<script>
|
|
const PAY_API = '<%= it.payApi || "" %>';
|
|
const SOCK_API = 'https://sock-v1.freedom.st';
|
|
|
|
let selectedPlan = null;
|
|
let selectedCoin = null;
|
|
let selectedMonths = 1;
|
|
let coins = [];
|
|
let paymentId = null;
|
|
let pollInterval = null;
|
|
let countdownInterval = null;
|
|
let sseAbort = null;
|
|
|
|
// Fetch available coins on load
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(`${PAY_API}/coins`, { credentials: 'include' });
|
|
const data = await res.json();
|
|
coins = data.coins;
|
|
} catch (e) {
|
|
console.error('Failed to fetch coins:', e);
|
|
}
|
|
})();
|
|
|
|
function selectPlan(plan) {
|
|
selectedPlan = plan;
|
|
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');
|
|
|
|
showCoins();
|
|
}
|
|
|
|
function updateMonths(val) {
|
|
selectedMonths = Number(val);
|
|
document.getElementById('months-display').textContent = val;
|
|
document.getElementById('total-display').textContent = (14 * selectedMonths).toString();
|
|
document.querySelector('#months-section .text-sm.text-gray-500').textContent = selectedMonths === 1 ? ' month' : ' months';
|
|
}
|
|
|
|
function showCoins() {
|
|
const grid = document.getElementById('coin-grid');
|
|
grid.innerHTML = coins.map(c => `
|
|
<button onclick="selectCoin('${c.id}')" id="coin-${c.id}"
|
|
class="coin-btn flex items-center gap-2 bg-gray-800 border-2 border-gray-700 hover:border-gray-500 rounded-lg px-3 py-2.5 transition-colors text-left">
|
|
<span class="text-sm font-medium text-gray-300">${c.ticker}</span>
|
|
<span class="text-xs text-gray-600">${c.label}</span>
|
|
</button>
|
|
`).join('');
|
|
document.getElementById('coin-section').classList.remove('hidden');
|
|
document.getElementById('continue-btn').classList.remove('hidden');
|
|
selectedCoin = null;
|
|
document.getElementById('continue-btn').disabled = true;
|
|
}
|
|
|
|
function selectCoin(coinId) {
|
|
selectedCoin = coinId;
|
|
document.querySelectorAll('.coin-btn').forEach(el => el.classList.remove('border-blue-500'));
|
|
document.getElementById(`coin-${coinId}`).classList.add('border-blue-500');
|
|
document.getElementById('continue-btn').disabled = false;
|
|
}
|
|
|
|
async function createCheckout() {
|
|
const btn = document.getElementById('continue-btn');
|
|
const errEl = document.getElementById('select-error');
|
|
errEl.classList.add('hidden');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Creating...';
|
|
|
|
try {
|
|
const body = { plan: selectedPlan, coin: selectedCoin };
|
|
if (selectedPlan === 'pro') body.months = selectedMonths;
|
|
|
|
const res = await fetch(`${PAY_API}/checkout`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await res.json();
|
|
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) {
|
|
errEl.textContent = err.message;
|
|
errEl.classList.remove('hidden');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Continue to Payment';
|
|
}
|
|
}
|
|
|
|
function showPayment(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;
|
|
|
|
// Start countdown
|
|
const expiresAt = new Date(data.expires_at).getTime();
|
|
updateCountdown(expiresAt);
|
|
countdownInterval = setInterval(() => updateCountdown(expiresAt), 1000);
|
|
|
|
// Start SSE stream for instant tx detection on the client
|
|
watchAddress(data.coin, data.address);
|
|
|
|
// Poll as fallback (for confirmations and in case SSE drops)
|
|
pollInterval = setInterval(() => pollStatus(), 10000);
|
|
}
|
|
|
|
/** Subscribe to Freedom.st SSE filtered by our payment address for instant detection. */
|
|
function watchAddress(coin, address) {
|
|
if (sseAbort) sseAbort.abort();
|
|
sseAbort = new AbortController();
|
|
|
|
const query = { chain: coin, "data.out.script.address": address };
|
|
const q = btoa(JSON.stringify(query));
|
|
const url = `${SOCK_API}/sse?q=${q}`;
|
|
|
|
(async function connect() {
|
|
while (!sseAbort.signal.aborted) {
|
|
try {
|
|
const res = await fetch(url, { signal: sseAbort.signal });
|
|
if (!res.ok || !res.body) { await new Promise(r => setTimeout(r, 3000)); continue; }
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
while (!sseAbort.signal.aborted) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() ?? '';
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data: ')) continue;
|
|
try {
|
|
const event = JSON.parse(line.slice(6));
|
|
onTxDetected(event);
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (sseAbort.signal.aborted) return;
|
|
}
|
|
if (!sseAbort.signal.aborted) await new Promise(r => setTimeout(r, 3000));
|
|
}
|
|
})();
|
|
}
|
|
|
|
function onTxDetected(event) {
|
|
// Transaction matched our address filter — payment detected instantly
|
|
console.log('TX detected via SSE:', event);
|
|
const statusEl = document.getElementById('pay-status');
|
|
statusEl.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>
|
|
`;
|
|
// Immediately poll the pay API to get authoritative status update
|
|
pollStatus();
|
|
}
|
|
|
|
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 pollStatus() {
|
|
try {
|
|
const res = await fetch(`${PAY_API}/checkout/${paymentId}/status`, { 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>
|
|
`;
|
|
} else if (data.status === 'paid') {
|
|
clearInterval(pollInterval);
|
|
clearInterval(countdownInterval);
|
|
if (sseAbort) { sseAbort.abort(); sseAbort = 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 (sseAbort) { sseAbort.abort(); sseAbort = null; }
|
|
document.getElementById('pay-status-section').classList.add('hidden');
|
|
document.getElementById('pay-expired').classList.remove('hidden');
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function copyAddress() {
|
|
navigator.clipboard.writeText(document.getElementById('pay-address').textContent);
|
|
const btn = document.getElementById('copy-btn');
|
|
btn.textContent = 'Copied!';
|
|
btn.classList.add('text-green-400');
|
|
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
|
}
|
|
|
|
// Auto-load existing invoice if arriving via /dashboard/checkout/:id
|
|
const PRELOAD_INVOICE_ID = <%~ JSON.stringify(it.invoiceId) %>;
|
|
if (PRELOAD_INVOICE_ID) {
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(`${PAY_API}/checkout/${PRELOAD_INVOICE_ID}`, { credentials: 'include' });
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
if (data.status === 'paid') {
|
|
document.getElementById('step-select').classList.add('hidden');
|
|
document.getElementById('step-pay').classList.remove('hidden');
|
|
document.getElementById('pay-status-section').classList.add('hidden');
|
|
document.getElementById('pay-success').classList.remove('hidden');
|
|
} else if (data.status !== 'expired') {
|
|
paymentId = data.id;
|
|
showPayment(data);
|
|
}
|
|
} catch {}
|
|
})();
|
|
}
|
|
|
|
function resetCheckout() {
|
|
if (sseAbort) { sseAbort.abort(); sseAbort = null; }
|
|
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
|
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = 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-status-section').classList.remove('hidden');
|
|
document.getElementById('pay-status').innerHTML = `
|
|
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
|
|
<span class="text-gray-400">Waiting for payment...</span>
|
|
`;
|
|
paymentId = null;
|
|
}
|
|
</script>
|
|
|
|
<%~ include('./partials/foot') %>
|