fix: more nojs
This commit is contained in:
parent
b6fa544d5e
commit
57bf994926
|
|
@ -236,15 +236,66 @@ export const dashboard = new Elysia()
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
const [acc] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${resolved.accountId}`;
|
const [acc] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${resolved.accountId}`;
|
||||||
if (acc.plan === "lifetime") return redirect("/dashboard/settings");
|
if (acc.plan === "lifetime") return redirect("/dashboard/settings");
|
||||||
return html("checkout", { nav: "settings", account: acc, payApi: process.env.PAY_API || "https://pay.pingql.com", invoiceId: null });
|
|
||||||
|
// Fetch coins server-side for no-JS rendering
|
||||||
|
const payApi = process.env.PAY_API || "https://pay.pingql.com";
|
||||||
|
let coins: any[] = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${payApi}/coins`);
|
||||||
|
const data = await res.json();
|
||||||
|
coins = data.coins || [];
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: null, coins, invoice: null });
|
||||||
})
|
})
|
||||||
|
|
||||||
// Existing invoice by ID — survives refreshes
|
// Existing invoice by ID — SSR the payment status
|
||||||
.get("/dashboard/checkout/:id", async ({ cookie, headers, params }) => {
|
.get("/dashboard/checkout/:id", async ({ cookie, headers, params }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
const [acc] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${resolved.accountId}`;
|
const [acc] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${resolved.accountId}`;
|
||||||
return html("checkout", { nav: "settings", account: acc, payApi: process.env.PAY_API || "https://pay.pingql.com", invoiceId: params.id });
|
|
||||||
|
const payApi = process.env.PAY_API || "https://pay.pingql.com";
|
||||||
|
const key = cookie?.pingql_key?.value;
|
||||||
|
let invoice: any = null;
|
||||||
|
let coins: any[] = [];
|
||||||
|
try {
|
||||||
|
const [invoiceRes, coinsRes] = await Promise.all([
|
||||||
|
fetch(`${payApi}/checkout/${params.id}`, { headers: { "Authorization": `Bearer ${key}` } }),
|
||||||
|
fetch(`${payApi}/coins`),
|
||||||
|
]);
|
||||||
|
if (invoiceRes.ok) invoice = await invoiceRes.json();
|
||||||
|
const coinsData = await coinsRes.json();
|
||||||
|
coins = coinsData.coins || [];
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: params.id, coins, invoice });
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create checkout via form POST (no-JS)
|
||||||
|
.post("/dashboard/checkout", async ({ cookie, headers, body }) => {
|
||||||
|
const resolved = await getAccountId(cookie, headers);
|
||||||
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
|
|
||||||
|
const payApi = process.env.PAY_API || "https://pay.pingql.com";
|
||||||
|
const key = cookie?.pingql_key?.value;
|
||||||
|
const b = body as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${payApi}/checkout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
plan: b.plan,
|
||||||
|
coin: b.coin,
|
||||||
|
months: b.months ? Number(b.months) : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.id) return redirect(`/dashboard/checkout/${data.id}`);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return redirect("/dashboard/checkout");
|
||||||
})
|
})
|
||||||
|
|
||||||
// New monitor
|
// New monitor
|
||||||
|
|
|
||||||
|
|
@ -3,360 +3,224 @@
|
||||||
|
|
||||||
<%
|
<%
|
||||||
const plan = it.account.plan;
|
const plan = it.account.plan;
|
||||||
const expiresAt = it.account.plan_expires_at;
|
const coins = it.coins || [];
|
||||||
|
const invoice = it.invoice;
|
||||||
|
const payApi = it.payApi || '';
|
||||||
%>
|
%>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Show months section only when pro is selected */
|
||||||
|
#plan-pro:checked ~ #months-section { display: block; }
|
||||||
|
/* Highlight selected plan */
|
||||||
|
#plan-pro:checked ~ .plan-labels label[for="plan-pro"] { border-color: rgb(59 130 246 / 0.5); }
|
||||||
|
#plan-lifetime:checked ~ .plan-labels label[for="plan-lifetime"] { border-color: rgb(234 179 8 / 0.5); }
|
||||||
|
/* Highlight selected coin */
|
||||||
|
.coin-radio:checked + label { border-color: rgb(59 130 246); }
|
||||||
|
</style>
|
||||||
|
|
||||||
<main class="max-w-2xl mx-auto px-6 py-8">
|
<main class="max-w-2xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/dashboard/settings" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back to settings</a>
|
<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>
|
<h2 class="text-lg font-semibold text-gray-200 mt-2">Upgrade Plan</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 1: Plan & coin selection -->
|
<% if (!invoice) { %>
|
||||||
<div id="step-select" class="space-y-6">
|
<!-- ─── Step 1: Plan & coin selection (form, works without JS) ─── -->
|
||||||
|
<form action="/dashboard/checkout" method="POST" class="space-y-6">
|
||||||
|
|
||||||
<!-- Plan cards -->
|
<!-- Hidden radios for plan (CSS uses :checked) -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<input type="radio" name="plan" value="pro" id="plan-pro" class="hidden peer/pro" checked>
|
||||||
<% if (plan !== 'lifetime') { %>
|
<input type="radio" name="plan" value="lifetime" id="plan-lifetime" class="hidden peer/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="plan-labels grid grid-cols-2 gap-4">
|
||||||
|
<label for="plan-pro"
|
||||||
|
class="cursor-pointer 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-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-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>
|
<div class="text-xs text-gray-500 mt-2">500 monitors, 2s intervals</div>
|
||||||
</button>
|
</label>
|
||||||
<% } %>
|
<label for="plan-lifetime"
|
||||||
<% if (plan !== 'lifetime') { %>
|
class="cursor-pointer text-left bg-gray-900 border-2 border-gray-800 hover:border-yellow-500/50 rounded-xl p-5 transition-colors">
|
||||||
<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-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-2xl font-bold text-gray-100">$149</div>
|
||||||
<div class="text-xs text-gray-500 mt-2">One-time, forever</div>
|
<div class="text-xs text-gray-500 mt-2">One-time, forever</div>
|
||||||
</button>
|
</label>
|
||||||
<% } %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Months selector (shown for pro) -->
|
<!-- Months (visible only when pro selected, via CSS) -->
|
||||||
<div id="months-section" class="hidden">
|
<div id="months-section" class="hidden">
|
||||||
<label class="block text-sm text-gray-400 mb-2">How many months?</label>
|
<label class="block text-sm text-gray-400 mb-2">How many months?</label>
|
||||||
<div class="flex items-center gap-4">
|
<select name="months" class="bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
<input type="range" id="months-range" min="1" max="12" value="1"
|
<% for (let i = 1; i <= 12; i++) { %>
|
||||||
class="flex-1 accent-blue-500" oninput="updateMonths(this.value)">
|
<option value="<%= i %>"><%= i %> month<%= i > 1 ? 's' : '' %> — $<%= i * 14 %></option>
|
||||||
<div class="text-right" style="min-width:80px">
|
<% } %>
|
||||||
<span id="months-display" class="text-lg font-semibold text-gray-100">1</span>
|
</select>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Coin selector -->
|
<!-- Coin selection -->
|
||||||
<div id="coin-section" class="hidden">
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-2">Pay with</label>
|
<label class="block text-sm text-gray-400 mb-2">Pay with</label>
|
||||||
<div id="coin-grid" class="grid grid-cols-3 gap-2">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<% coins.forEach(function(c, i) { %>
|
||||||
|
<input type="radio" name="coin" value="<%= c.id %>" id="coin-<%= c.id %>" class="hidden coin-radio" <%= i === 0 ? 'checked' : '' %>>
|
||||||
|
<label for="coin-<%= c.id %>"
|
||||||
|
class="cursor-pointer 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>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<button type="submit"
|
||||||
<div id="select-error" class="text-red-400 text-sm hidden"></div>
|
class="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
|
||||||
|
|
||||||
<!-- 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
|
Continue to Payment
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<!-- Step 2: Payment -->
|
<% } else { %>
|
||||||
<div id="step-pay" class="hidden">
|
<!-- ─── Step 2: Payment display (SSR, auto-refreshes without JS) ─── -->
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-6 text-center space-y-5">
|
<%
|
||||||
<!-- QR -->
|
const inv = invoice;
|
||||||
|
const isPending = inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming';
|
||||||
|
const received = parseFloat(inv.amount_received || '0');
|
||||||
|
const total = parseFloat(inv.amount_crypto);
|
||||||
|
const remaining = Math.max(0, total - received);
|
||||||
|
const expiresAt = new Date(inv.expires_at);
|
||||||
|
const now = new Date();
|
||||||
|
const remainingSec = Math.max(0, Math.floor((expiresAt.getTime() - now.getTime()) / 1000));
|
||||||
|
const mins = Math.floor(remainingSec / 60);
|
||||||
|
const secs = remainingSec % 60;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<% if (isPending) { %>
|
||||||
|
<!-- Auto-refresh every 5s for status updates (no-JS polling) -->
|
||||||
|
<meta http-equiv="refresh" content="5">
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="bg-gray-900 border border-gray-800 rounded-xl p-6 text-center space-y-5">
|
||||||
|
|
||||||
|
<% if (inv.status === 'confirming') { %>
|
||||||
|
<!-- Loading spinner instead of QR -->
|
||||||
|
<div class="w-48 h-48 mx-auto rounded-lg bg-gray-800 flex items-center justify-center">
|
||||||
|
<svg class="w-10 h-10 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||||
|
</div>
|
||||||
|
<% } else if (inv.status !== 'paid') { %>
|
||||||
|
<!-- QR -->
|
||||||
|
<div>
|
||||||
|
<img src="<%= inv.qr_url %>" alt="QR Code" class="w-48 h-48 mx-auto rounded-lg bg-white p-2" id="pay-qr">
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (inv.status !== 'paid' && inv.status !== 'expired') { %>
|
||||||
|
<!-- Amount -->
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold font-mono text-gray-100"><%= received > 0 && remaining > 0 ? remaining.toFixed(8) : inv.amount_crypto %></div>
|
||||||
|
<div class="text-sm text-gray-500"><%= inv.coin_label %> (<%= inv.coin_ticker %>)</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">$<%= Number(inv.amount_usd).toFixed(2) %> USD</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (received > 0 && remaining > 0) { %>
|
||||||
|
<!-- Received / Remaining -->
|
||||||
|
<div class="flex justify-center gap-6 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<img id="pay-qr" src="" alt="QR Code" class="w-48 h-48 mx-auto rounded-lg bg-white p-2">
|
<span class="text-gray-500">Received:</span>
|
||||||
|
<span class="text-green-400 font-mono"><%= received.toFixed(8) %></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Amount -->
|
|
||||||
<div>
|
<div>
|
||||||
<div class="text-2xl font-bold font-mono text-gray-100" id="pay-amount"></div>
|
<span class="text-gray-500">Remaining:</span>
|
||||||
<div class="text-sm text-gray-500" id="pay-coin-label"></div>
|
<span class="text-yellow-400 font-mono"><%= remaining.toFixed(8) %></span>
|
||||||
<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>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Send to</label>
|
||||||
|
<code class="block 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" id="pay-address"><%= inv.address %></code>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<% if (inv.status === 'pending') { %>
|
||||||
|
<div 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 class="font-mono"><%= mins %>:<%= String(secs).padStart(2, '0') %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% } else if (inv.status === 'underpaid') { %>
|
||||||
|
<div 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-yellow-400">Underpaid — please send the remaining amount</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-2">
|
||||||
|
Expires in <span class="font-mono"><%= mins %>:<%= String(secs).padStart(2, '0') %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% } else if (inv.status === 'confirming') { %>
|
||||||
|
<div class="flex items-center justify-center gap-2 text-sm">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||||
|
<span class="text-blue-400">Transaction received, waiting for 1 confirmation...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% } else if (inv.status === 'paid') { %>
|
||||||
|
<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>
|
||||||
|
<a href="/dashboard/settings" class="text-xs text-blue-400 hover:text-blue-300">Back to settings</a>
|
||||||
|
|
||||||
|
<% } else if (inv.status === 'expired') { %>
|
||||||
|
<div class="flex items-center justify-center gap-2 text-red-400">
|
||||||
|
<span class="font-medium">Payment expired</span>
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/checkout" class="mt-3 text-sm text-blue-400 hover:text-blue-300">Try again</a>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const PAY_API = '<%= it.payApi || "" %>';
|
const PAY_API = '<%= payApi %>';
|
||||||
const SOCK_API = 'https://sock-v1.freedom.st';
|
const SOCK_API = 'https://sock-v1.freedom.st';
|
||||||
|
|
||||||
let selectedPlan = null;
|
<% if (invoice && (invoice.status === 'pending' || invoice.status === 'underpaid' || invoice.status === 'confirming')) { %>
|
||||||
let selectedCoin = null;
|
// ── JS enhancements for live updates (progressive, page works without this) ──
|
||||||
let selectedMonths = 1;
|
|
||||||
let coins = [];
|
let paymentId = '<%~ invoice.id %>';
|
||||||
let paymentId = null;
|
let paymentData = <%~ JSON.stringify(invoice) %>;
|
||||||
let paymentData = null; // full payment object
|
let watchedTxids = [<% (invoice.txs || []).forEach(function(tx) { %>'<%= tx.txid %>',<% }) %>];
|
||||||
|
let localReceived = <%= parseFloat(invoice.amount_received || '0') %>;
|
||||||
let pollInterval = null;
|
let pollInterval = null;
|
||||||
let countdownInterval = null;
|
let countdownInterval = null;
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let watchedAddress = null;
|
let watchedAddress = '<%= invoice.address %>';
|
||||||
let watchedTxids = [];
|
|
||||||
let localReceived = 0; // track received amount from SSE locally
|
|
||||||
|
|
||||||
// Fetch available coins on load
|
// Stop the meta refresh since JS is handling it
|
||||||
(async () => {
|
document.querySelector('meta[http-equiv="refresh"]')?.remove();
|
||||||
|
|
||||||
|
// Start SSE
|
||||||
|
eventSource = new EventSource(`${SOCK_API}/sse`);
|
||||||
|
eventSource.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${PAY_API}/coins`, { credentials: 'include' });
|
const event = JSON.parse(e.data);
|
||||||
const data = await res.json();
|
if (event.type === 'tx') onTx(event);
|
||||||
coins = data.coins;
|
else if (event.type === 'block') onBlock(event);
|
||||||
} catch (e) {
|
} catch {}
|
||||||
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');
|
|
||||||
document.getElementById('months-section').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;
|
|
||||||
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) {
|
|
||||||
paymentData = data;
|
|
||||||
document.getElementById('step-select').classList.add('hidden');
|
|
||||||
document.getElementById('step-pay').classList.remove('hidden');
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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
|
|
||||||
watchAddress(data.coin, data.address);
|
|
||||||
|
|
||||||
// Poll as fallback
|
|
||||||
pollInterval = setInterval(() => pollPayment(), 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAmountDisplay(data) {
|
|
||||||
const received = parseFloat(data.amount_received || '0');
|
|
||||||
const total = parseFloat(data.amount_crypto);
|
|
||||||
const remaining = Math.max(0, total - received);
|
|
||||||
|
|
||||||
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 syncTxids(data) {
|
|
||||||
if (!data?.txs) return;
|
|
||||||
for (const tx of data.txs) {
|
|
||||||
if (tx.txid && !watchedTxids.includes(tx.txid)) watchedTxids.push(tx.txid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
`;
|
|
||||||
syncTxids(data);
|
|
||||||
} 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 received, waiting for 1 confirmation...</span>
|
|
||||||
`;
|
|
||||||
// Replace QR with loading spinner
|
|
||||||
document.getElementById('pay-qr').replaceWith(Object.assign(document.createElement('div'), {
|
|
||||||
id: 'pay-qr',
|
|
||||||
className: 'w-48 h-48 mx-auto rounded-lg bg-gray-800 flex items-center justify-center',
|
|
||||||
innerHTML: '<svg class="w-10 h-10 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>',
|
|
||||||
}));
|
|
||||||
syncTxids(data);
|
|
||||||
} 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;
|
|
||||||
|
|
||||||
eventSource = new EventSource(`${SOCK_API}/sse`);
|
|
||||||
console.log('SSE: connected, watching for', address);
|
|
||||||
|
|
||||||
eventSource.onmessage = (e) => {
|
|
||||||
try {
|
|
||||||
const event = JSON.parse(e.data);
|
|
||||||
if (event.type === 'block') onBlock(event);
|
|
||||||
else if (event.type === 'tx') onTx(event);
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTx(event) {
|
function onTx(event) {
|
||||||
if (!watchedAddress || !paymentData) return;
|
|
||||||
const outputs = event.data?.out ?? [];
|
const outputs = event.data?.out ?? [];
|
||||||
const txHash = event.data?.tx?.hash ?? null;
|
const txHash = event.data?.tx?.hash;
|
||||||
if (!txHash || watchedTxids.includes(txHash)) return;
|
if (!txHash || watchedTxids.includes(txHash)) return;
|
||||||
|
|
||||||
// Sum outputs going to our address
|
|
||||||
let txValue = 0;
|
let txValue = 0;
|
||||||
for (const out of outputs) {
|
for (const out of outputs) {
|
||||||
if (out?.script?.address === watchedAddress) {
|
if (out?.script?.address === watchedAddress) txValue += Number(out.value ?? 0);
|
||||||
txValue += Number(out.value ?? 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (txValue === 0) return;
|
if (txValue === 0) return;
|
||||||
|
|
||||||
|
|
@ -367,19 +231,14 @@
|
||||||
const expected = parseFloat(paymentData.amount_crypto);
|
const expected = parseFloat(paymentData.amount_crypto);
|
||||||
const remaining = Math.max(0, expected - localReceived);
|
const remaining = Math.max(0, expected - localReceived);
|
||||||
|
|
||||||
// Update received display
|
// Update UI inline
|
||||||
document.getElementById('pay-received-section').classList.remove('hidden');
|
const statusEl = document.getElementById('pay-status');
|
||||||
document.getElementById('pay-received').textContent = localReceived.toFixed(8);
|
if (statusEl) {
|
||||||
document.getElementById('pay-remaining').textContent = remaining.toFixed(8);
|
if (remaining <= expected * 0.005) {
|
||||||
|
statusEl.innerHTML = '<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span><span class="text-blue-400">Transaction received, waiting for 1 confirmation...</span>';
|
||||||
if (remaining <= expected * 0.005) {
|
} else {
|
||||||
// Full amount received
|
statusEl.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>';
|
||||||
document.getElementById('pay-amount').textContent = expected.toFixed(8);
|
}
|
||||||
applyStatus('confirming', null);
|
|
||||||
} else {
|
|
||||||
// Underpaid
|
|
||||||
document.getElementById('pay-amount').textContent = remaining.toFixed(8);
|
|
||||||
applyStatus('underpaid', null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,99 +246,31 @@
|
||||||
if (watchedTxids.length === 0) return;
|
if (watchedTxids.length === 0) return;
|
||||||
const blockTxs = event.data?.tx ?? [];
|
const blockTxs = event.data?.tx ?? [];
|
||||||
if (watchedTxids.some(t => blockTxs.includes(t))) {
|
if (watchedTxids.some(t => blockTxs.includes(t))) {
|
||||||
console.log('SSE: block confirmed our tx');
|
console.log('SSE: block confirmed');
|
||||||
applyStatus('paid', null);
|
const statusEl = document.getElementById('pay-status');
|
||||||
|
if (statusEl) statusEl.innerHTML = '<span class="text-green-400 font-medium">Payment confirmed!</span>';
|
||||||
|
if (eventSource) eventSource.close();
|
||||||
|
setTimeout(() => { window.location.href = '/dashboard/settings'; }, 2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Polling fallback ─────────────────────────────────────────────
|
// Poll as fallback
|
||||||
|
pollInterval = setInterval(async () => {
|
||||||
async function pollPayment() {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${PAY_API}/checkout/${paymentId}`, { credentials: 'include' });
|
const res = await fetch(`${PAY_API}/checkout/${paymentId}`, { credentials: 'include' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
paymentData = data;
|
if (data.status === 'paid' || data.status === 'expired') {
|
||||||
|
window.location.reload();
|
||||||
// Sync local received from server if server knows more
|
|
||||||
const serverReceived = parseFloat(data.amount_received || '0');
|
|
||||||
if (serverReceived > localReceived) localReceived = serverReceived;
|
|
||||||
|
|
||||||
// Update expiry countdown if server extended it
|
|
||||||
if (data.expires_at) {
|
|
||||||
const newExpiry = new Date(data.expires_at).getTime();
|
|
||||||
clearInterval(countdownInterval);
|
|
||||||
updateCountdown(newExpiry);
|
|
||||||
countdownInterval = setInterval(() => updateCountdown(newExpiry), 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAmountDisplay({ ...data, amount_received: localReceived.toFixed(8) });
|
|
||||||
applyStatus(data.status, data);
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}, 10000);
|
||||||
|
|
||||||
// ── 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Clipboard copy (progressive)
|
||||||
function copyAddress() {
|
function copyAddress() {
|
||||||
navigator.clipboard.writeText(document.getElementById('pay-address').textContent);
|
const addr = document.getElementById('pay-address');
|
||||||
const btn = document.getElementById('copy-btn');
|
if (addr) navigator.clipboard.writeText(addr.textContent);
|
||||||
btn.textContent = 'Copied!';
|
|
||||||
btn.classList.add('text-green-400');
|
|
||||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-load existing invoice
|
|
||||||
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') {
|
|
||||||
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-expired').classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
paymentId = data.id;
|
|
||||||
showPayment(data);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCheckout() {
|
|
||||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
|
||||||
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
|
||||||
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>
|
|
||||||
<span class="text-gray-400">Waiting for payment...</span>
|
|
||||||
`;
|
|
||||||
paymentId = null;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue