feat: crypto payment system with HD wallets, Freedom.st integration, and checkout UI

This commit is contained in:
2026-03-18 23:04:17 +04:00
parent bead49b870
commit c9130243e8
18 changed files with 943 additions and 35 deletions
+1
View File
@@ -56,6 +56,7 @@ export async function migrate() {
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
+10 -1
View File
@@ -198,7 +198,7 @@ export const dashboard = new Elysia()
const keyId = resolved?.keyId ?? null;
if (!accountId) return redirect("/dashboard");
const [acc] = await sql`SELECT id, email_hash, plan, created_at FROM accounts WHERE id = ${accountId}`;
const [acc] = await sql`SELECT id, email_hash, plan, plan_expires_at, created_at FROM accounts WHERE id = ${accountId}`;
const isSubKey = !!keyId;
const apiKeys = isSubKey ? [] : await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
const loginKey = isSubKey ? null : (cookie?.pingql_key?.value ?? null);
@@ -207,6 +207,15 @@ export const dashboard = new Elysia()
return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey, isSubKey, monitorCount });
})
// Checkout — upgrade plan
.get("/dashboard/checkout", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const [acc] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${resolved.accountId}`;
if (acc.plan === "lifetime") return redirect("/dashboard/settings");
return html("checkout", { nav: "settings", account: acc, payApi: process.env.PAY_API || "https://pay.pingql.com" });
})
// New monitor
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
+298
View File
@@ -0,0 +1,298 @@
<%~ 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">&larr; 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 || "" %>';
let selectedPlan = null;
let selectedCoin = null;
let selectedMonths = 1;
let coins = [];
let paymentId = null;
let pollInterval = null;
let countdownInterval = 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;
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 polling
pollInterval = setInterval(() => pollStatus(), 5000);
}
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);
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);
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);
}
function resetCheckout() {
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') %>
+21 -27
View File
@@ -509,68 +509,62 @@
</div>
<!-- Pro -->
<div class="rounded-xl border border-gray-700 bg-[#111] p-8 text-left relative">
<div class="absolute -top-3 right-6 px-3 py-1 bg-gray-800 border border-gray-700 rounded-full text-xs text-gray-400 font-mono">
coming soon
</div>
<div class="rounded-xl border border-blue-500/30 bg-[#111] p-8 text-left relative">
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Pro</div>
<div class="text-4xl font-bold mb-1 text-gray-500">$14</div>
<div class="text-sm text-gray-600 mb-6">per month</div>
<ul class="space-y-3 text-sm text-gray-500">
<div class="text-4xl font-bold mb-1">$14</div>
<div class="text-sm text-gray-500 mb-6">per month</div>
<ul class="space-y-3 text-sm text-gray-400">
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-600 shrink-0" 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>
<svg class="w-4 h-4 text-green-400 shrink-0" 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>
500 monitors
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-600 shrink-0" 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>
<svg class="w-4 h-4 text-green-400 shrink-0" 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>
2s check interval
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-600 shrink-0" 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>
<svg class="w-4 h-4 text-green-400 shrink-0" 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>
Webhook notifications
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-600 shrink-0" 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>
<svg class="w-4 h-4 text-green-400 shrink-0" 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>
Priority support
</li>
</ul>
<div class="mt-8 block text-center w-full py-3 border border-gray-700 text-gray-500 text-sm font-medium rounded-lg cursor-default">
Coming Soon
</div>
<a href="/dashboard/checkout" class="mt-8 block text-center w-full py-3 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors">
Upgrade to Pro
</a>
</div>
<!-- Lifetime -->
<div class="rounded-xl border border-yellow-500/30 bg-[#111] p-8 text-left relative">
<div class="absolute -top-3 right-6 px-3 py-1 bg-yellow-900/40 border border-yellow-500/30 rounded-full text-xs text-yellow-400 font-mono">
coming soon
</div>
<div class="text-xs text-yellow-500/70 uppercase tracking-wider font-mono mb-2">Lifetime</div>
<div class="flex items-baseline gap-3 mb-1">
<div class="text-4xl font-bold text-gray-400">$149</div>
<div class="text-4xl font-bold">$149</div>
<div class="text-xl text-gray-600 line-through">$179</div>
</div>
<div class="text-sm text-gray-600 mb-6">one-time</div>
<ul class="space-y-3 text-sm text-gray-500">
<div class="text-sm text-gray-500 mb-6">one-time</div>
<ul class="space-y-3 text-sm text-gray-400">
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" 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>
<svg class="w-4 h-4 text-yellow-500 shrink-0" 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>
Everything in Pro
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" 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>
<svg class="w-4 h-4 text-yellow-500 shrink-0" 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>
Pay once, use forever
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" 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>
<svg class="w-4 h-4 text-yellow-500 shrink-0" 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>
All future updates
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" 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>
<svg class="w-4 h-4 text-yellow-500 shrink-0" 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>
Limited availability
</li>
</ul>
<div class="mt-8 block text-center w-full py-3 border border-yellow-500/20 text-yellow-500/50 text-sm font-medium rounded-lg cursor-default">
Coming Soon
</div>
<a href="/dashboard/checkout" class="mt-8 block text-center w-full py-3 bg-yellow-600 hover:bg-yellow-500 text-white text-sm font-medium rounded-lg transition-colors">
Get Lifetime
</a>
</div>
</div>
</div>
+6 -1
View File
@@ -35,8 +35,13 @@
</div>
</div>
<% if (plan === 'free') { %>
<div class="mt-4 pt-4 border-t border-gray-800">
<a href="/dashboard/checkout" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors">Upgrade to Pro</a>
</div>
<% } else if (plan === 'pro' && it.account.plan_expires_at) { %>
<div class="mt-4 pt-4 border-t border-gray-800 text-xs text-gray-500">
Upgrade to Pro for 500 monitors and 2s intervals. <span class="text-gray-600">Coming soon.</span>
Pro plan expires <%= new Date(it.account.plan_expires_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %>.
<a href="/dashboard/checkout" class="text-blue-400 hover:text-blue-300 ml-1">Extend or upgrade to Lifetime</a>
</div>
<% } %>
</section>