refactor: simplify payment gateway, single evaluatePayment function, clean landing page pricing

This commit is contained in:
nate 2026-03-19 09:42:03 +04:00
parent df638c94f1
commit 632f006988
5 changed files with 146 additions and 332 deletions

View File

@ -1,8 +1,7 @@
/// HD address derivation using bitcore-lib family.
/// Each coin uses its own xpub from env vars.
/// Derives child addresses at m/0/{index} (external receive chain).
// @ts-ignore — bitcore libs don't have perfect types
// @ts-ignore
import bitcore from "bitcore-lib";
import bs58check from "bs58check";
// @ts-ignore
@ -14,18 +13,6 @@ import bitcoreDoge from "bitcore-lib-doge";
// @ts-ignore
import dashcore from "@dashevo/dashcore-lib";
interface CoinLib {
HDPublicKey: any;
}
const LIBS: Record<string, CoinLib> = {
btc: bitcore,
bch: bitcoreCash,
ltc: bitcoreLtc,
doge: bitcoreDoge,
dash: dashcore,
};
function getXpub(coin: string): string {
const key = `XPUB_${coin.toUpperCase()}`;
const val = process.env[key];
@ -33,40 +20,7 @@ function getXpub(coin: string): string {
return val;
}
/**
* Derive a receive address for the given coin at the given index.
* Uses the standard BIP44 external chain: m/0/{index}
*/
export function deriveAddress(coin: string, index: number): string {
if (coin === "xec") {
// XEC uses the same address format as BCH but with ecash: prefix
// Derive using bitcore-lib-cash, then convert prefix
const xpub = getXpub("xec");
const hdPub = new bitcoreCash.HDPublicKey(xpub);
const child = hdPub.deriveChild(0).deriveChild(index);
const addr = new bitcoreCash.Address(child.publicKey, bitcoreCash.Networks.mainnet);
// bitcore-lib-cash gives "bitcoincash:q..." — replace prefix with "ecash:"
const cashAddr = addr.toCashAddress();
return cashAddr.replace(/^bitcoincash:/, "ecash:");
}
const lib = LIBS[coin];
if (!lib) throw new Error(`Unsupported coin: ${coin}`);
const xpub = getXpub(coin);
const hdPub = new lib.HDPublicKey(xpub);
const child = hdPub.deriveChild(0).deriveChild(index);
const addr = new lib.HDPublicKey.prototype.constructor.Address
? new (lib as any).Address(child.publicKey)
: child.publicKey.toAddress();
return addr.toString();
}
/**
* Simpler approach derive using each lib's built-in methods.
*/
export function deriveAddressSafe(coin: string, index: number): string {
export function derive(coin: string, index: number): string {
const xpub = getXpub(coin === "xec" ? "xec" : coin);
if (coin === "btc") {
@ -83,15 +37,12 @@ export function deriveAddressSafe(coin: string, index: number): string {
return addr.replace(/^bitcoincash:/, "ecash:");
}
if (coin === "ltc") {
// All zpub/Ltub/Mtub/xpub variants share the same key data — only version bytes differ.
// Remap to the standard xpub version (0x0488b21e) so bitcore-lib-ltc can parse it,
// then derive a native segwit P2WPKH (bech32 ltc1q...) address.
// Remap zpub/Ltub/Mtub to standard xpub version bytes for bitcore-lib-ltc
const decoded = Buffer.from(bs58check.decode(xpub));
decoded[0] = 0x04; decoded[1] = 0x88; decoded[2] = 0xb2; decoded[3] = 0x1e;
const normalized = bs58check.encode(decoded);
const hd = new bitcoreLtc.HDPublicKey(normalized);
const pubkey = hd.deriveChild(0).deriveChild(index).publicKey;
// Derive as P2WPKH (native segwit, bech32 ltc1q...)
return new bitcoreLtc.Address(pubkey, bitcoreLtc.Networks.mainnet, bitcoreLtc.Address.PayToWitnessPublicKeyHash).toString();
}
if (coin === "doge") {
@ -105,5 +56,3 @@ export function deriveAddressSafe(coin: string, index: number): string {
throw new Error(`Unsupported coin: ${coin}`);
}
export { deriveAddressSafe as derive };

View File

@ -15,8 +15,6 @@ export async function getAddressInfo(address: string): Promise<any> {
return res.json();
}
/** Bulk address lookup POST /address with { terms: [...] }
* Normalizes response to { address: info } map regardless of API format. */
export async function getAddressInfoBulk(addresses: string[]): Promise<Record<string, any>> {
if (addresses.length === 0) return {};
const res = await fetch(`${API}/address`, {
@ -24,39 +22,13 @@ export async function getAddressInfoBulk(addresses: string[]): Promise<Record<st
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ terms: addresses }),
});
const data = await res.json();
// Normalize: if response is already keyed by address, return as-is.
// If it's an array, index by address field.
if (Array.isArray(data)) {
const map: Record<string, any> = {};
for (const item of data) {
if (item?.address) map[item.address] = item;
}
return map;
}
// Check if it's keyed by address — verify first value has address-like fields
const firstKey = Object.keys(data)[0];
if (firstKey && data[firstKey]?.address) return data;
// If keyed by index (0, 1, 2...), map back to addresses
if (firstKey === "0" || firstKey === "1") {
const map: Record<string, any> = {};
for (let i = 0; i < addresses.length; i++) {
if (data[i]) map[addresses[i]] = data[i];
}
return map;
}
return data;
return res.json();
}
export function getQrUrl(text: string): string {
return `${API}/invoice/qr/${encodeURIComponent(text)}`;
}
/** Returns coin keys where the chain is online (no null uptime field). */
export async function getAvailableCoins(): Promise<string[]> {
const info = await getChainInfo();
return Object.entries(info)

View File

@ -1,6 +1,5 @@
/// Payment monitor: raw SSE stream for instant tx/block detection,
/// with bulk polling as fallback.
/// States: pending → underpaid → confirming → paid (or expired)
/// Payment monitor: raw SSE + polling fallback.
/// States: pending → underpaid → confirming → paid | expired
import sql from "./db";
import { getAddressInfo, getAddressInfoBulk } from "./freedom";
import { COINS } from "./plans";
@ -8,7 +7,7 @@ import { COINS } from "./plans";
const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st";
const THRESHOLD = 0.995;
// ── In-memory maps ──────────────────────────────────────────────────
// ── In-memory lookups for SSE matching ──────────────────────────────
let addressMap = new Map<string, any>(); // address → payment
let txidToPayment = new Map<string, number>(); // txid → payment.id
const seenTxids = new Set<string>();
@ -19,15 +18,11 @@ async function refreshMaps() {
WHERE status IN ('pending', 'underpaid', 'confirming')
AND expires_at >= now()
`;
const newAddr = new Map<string, any>();
const newTxid = new Map<string, number>();
for (const p of active) {
newAddr.set(p.address, p);
}
for (const p of active) newAddr.set(p.address, p);
// Load all txids for active payments
if (active.length > 0) {
const ids = active.map((p: any) => p.id);
const txs = await sql`SELECT payment_id, txid FROM payment_txs WHERE payment_id = ANY(${ids})`;
@ -36,13 +31,104 @@ async function refreshMaps() {
addressMap = newAddr;
txidToPayment = newTxid;
for (const t of seenTxids) { if (!newTxid.has(t)) seenTxids.delete(t); }
}
for (const txid of seenTxids) {
if (!newTxid.has(txid)) seenTxids.delete(txid);
// ── Core logic: one place for all state transitions ─────────────────
async function recordTx(paymentId: number, txid: string, amount: number, confirmed: boolean) {
const [ins] = await sql`
INSERT INTO payment_txs (payment_id, txid, amount, confirmed)
VALUES (${paymentId}, ${txid}, ${amount.toFixed(8)}, ${confirmed})
ON CONFLICT (payment_id, txid) DO UPDATE SET confirmed = EXCLUDED.confirmed OR payment_txs.confirmed
RETURNING (xmax = 0) as is_new
`;
// Extend expiry to 24h on new tx
if (ins?.is_new) {
const exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
await sql`UPDATE payments SET expires_at = ${exp} WHERE id = ${paymentId} AND expires_at < ${exp}`;
}
}
// ── Single raw SSE connection ───────────────────────────────────────
async function evaluatePayment(paymentId: number) {
const [payment] = await sql`
SELECT * FROM payments WHERE id = ${paymentId} AND status IN ('pending', 'underpaid', 'confirming')
`;
if (!payment) return;
const coin = COINS[payment.coin];
if (!coin) return;
const [{ total, unconfirmed }] = await sql`
SELECT COALESCE(SUM(amount::numeric), 0) as total,
COUNT(*) FILTER (WHERE NOT confirmed)::int as unconfirmed
FROM payment_txs WHERE payment_id = ${paymentId}
`;
const received = Number(total);
const expected = parseFloat(payment.amount_crypto);
const threshold = expected * THRESHOLD;
let newStatus = payment.status;
if (received >= threshold && (coin.confirmations === 0 || Number(unconfirmed) === 0)) {
newStatus = "paid";
} else if (received >= threshold) {
newStatus = "confirming";
} else if (received > 0) {
newStatus = "underpaid";
}
if (newStatus === payment.status && received <= parseFloat(payment.amount_received || "0")) return;
if (newStatus === "paid") {
await sql`UPDATE payments SET status = 'paid', amount_received = ${received.toFixed(8)}, paid_at = now() WHERE id = ${paymentId} AND status != 'paid'`;
await applyPlan(payment);
addressMap.delete(payment.address);
console.log(`Payment ${paymentId} paid`);
} else {
await sql`UPDATE payments SET status = ${newStatus}, amount_received = ${received.toFixed(8)} WHERE id = ${paymentId}`;
}
}
// ── SSE ─────────────────────────────────────────────────────────────
async function handleTxEvent(event: any) {
const txHash = event.data?.tx?.hash;
if (!txHash || seenTxids.has(txHash)) return;
seenTxids.add(txHash);
const outputs = event.data?.out ?? [];
for (const out of outputs) {
const addr = out?.script?.address;
const payment = addr && addressMap.get(addr);
if (!payment) continue;
const txValue = outputs
.filter((o: any) => o?.script?.address === addr)
.reduce((s: number, o: any) => s + Number(o.value ?? 0), 0);
if (txValue <= 0) continue;
console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin}`);
await recordTx(payment.id, txHash, txValue, false);
txidToPayment.set(txHash, payment.id);
await evaluatePayment(payment.id);
return;
}
}
async function handleBlockEvent(event: any) {
const blockTxs: string[] = event.data?.tx ?? [];
const matched = blockTxs.filter(t => txidToPayment.has(t));
if (matched.length === 0) return;
await sql`UPDATE payment_txs SET confirmed = true WHERE txid = ANY(${matched})`;
const pids = new Set(matched.map(t => txidToPayment.get(t)!));
for (const pid of pids) {
console.log(`SSE: block confirmed tx for payment ${pid}`);
await evaluatePayment(pid);
}
}
function startSSE() {
connectSSE(`${SOCK_API}/sse`);
@ -52,35 +138,26 @@ async function connectSSE(url: string) {
while (true) {
try {
const res = await fetch(url);
if (!res.ok || !res.body) {
console.error(`SSE: HTTP ${res.status}`);
await sleep(5000);
continue;
}
if (!res.ok || !res.body) { await sleep(5000); continue; }
console.log("SSE: connected");
console.log("SSE: connected (raw, all chains)");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
try {
const json = line.startsWith("data: ") ? line.slice(6) : line.slice(5);
const event = JSON.parse(json);
if (event.type === "block") {
handleBlockEvent(event).catch(() => {});
} else if (event.type === "tx") {
handleTxEvent(event).catch(() => {});
}
const event = JSON.parse(line.slice(line.indexOf("{")));
if (event.type === "block") handleBlockEvent(event).catch(() => {});
else if (event.type === "tx") handleTxEvent(event).catch(() => {});
} catch {}
}
}
@ -92,226 +169,42 @@ async function connectSSE(url: string) {
}
}
// ── SSE tx handler ──────────────────────────────────────────────────
async function handleTxEvent(event: any) {
const outputs = event.data?.out ?? [];
const txHash = event.data?.tx?.hash ?? null;
if (!txHash) return;
if (seenTxids.has(txHash)) return;
seenTxids.add(txHash);
for (const out of outputs) {
const addr = out?.script?.address;
if (!addr) continue;
const payment = addressMap.get(addr);
if (!payment) continue;
const coin = COINS[payment.coin];
if (!coin) continue;
// Sum outputs going to our address
const txValue = outputs
.filter((o: any) => o?.script?.address === addr)
.reduce((sum: number, o: any) => sum + Number(o.value ?? 0), 0);
if (txValue <= 0) continue;
console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin}`);
// Insert into payment_txs (ignore duplicate)
const [inserted] = await sql`
INSERT INTO payment_txs (payment_id, txid, amount)
VALUES (${payment.id}, ${txHash}, ${txValue.toFixed(8)})
ON CONFLICT (payment_id, txid) DO NOTHING
RETURNING id
`;
txidToPayment.set(txHash, payment.id);
// Extend expiry to 24h on first tx detection
if (inserted) {
const newExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
await sql`UPDATE payments SET expires_at = ${newExpiry.toISOString()} WHERE id = ${payment.id} AND expires_at < ${newExpiry.toISOString()}`;
}
// Recalculate total received
const [{ total }] = await sql`
SELECT COALESCE(SUM(amount::numeric), 0) as total FROM payment_txs WHERE payment_id = ${payment.id}
`;
const totalReceived = Number(total);
const expected = parseFloat(payment.amount_crypto);
const threshold = expected * THRESHOLD;
if (coin.confirmations === 0 && totalReceived >= threshold) {
await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await applyPlan(payment);
addressMap.delete(addr);
console.log(`Payment ${payment.id} paid (0-conf)`);
} else if (totalReceived >= threshold) {
await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'confirming' WHERE id = ${payment.id}`;
payment.amount_received = totalReceived.toFixed(8);
payment.status = "confirming";
console.log(`Payment ${payment.id} confirming: ${totalReceived}/${expected}`);
} else {
await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'underpaid' WHERE id = ${payment.id}`;
payment.amount_received = totalReceived.toFixed(8);
payment.status = "underpaid";
console.log(`Payment ${payment.id} underpaid: ${totalReceived}/${expected}`);
}
return;
}
}
// ── SSE block handler ───────────────────────────────────────────────
async function handleBlockEvent(event: any) {
const blockTxs: string[] = event.data?.tx ?? [];
if (blockTxs.length === 0) return;
// Find payment txs in this block
const paymentIds = new Set<number>();
for (const txid of blockTxs) {
const pid = txidToPayment.get(txid);
if (pid != null) paymentIds.add(pid);
}
if (paymentIds.size === 0) return;
// Mark matched txs as confirmed
const matchedTxids = blockTxs.filter(t => txidToPayment.has(t));
if (matchedTxids.length > 0) {
await sql`UPDATE payment_txs SET confirmed = true WHERE txid = ANY(${matchedTxids})`;
}
for (const pid of paymentIds) {
const [payment] = await sql`
SELECT * FROM payments WHERE id = ${pid} AND status IN ('underpaid', 'confirming')
`;
if (!payment) continue;
// Check if all txs for this payment are confirmed
const [{ unconfirmed }] = await sql`
SELECT COUNT(*)::int as unconfirmed FROM payment_txs
WHERE payment_id = ${pid} AND confirmed = false
`;
if (Number(unconfirmed) > 0) continue;
// All confirmed — check total meets threshold
const [{ total }] = await sql`
SELECT COALESCE(SUM(amount::numeric), 0) as total FROM payment_txs WHERE payment_id = ${pid}
`;
const totalReceived = Number(total);
const expected = parseFloat(payment.amount_crypto);
if (totalReceived >= expected * THRESHOLD) {
await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${pid} AND status != 'paid'`;
await applyPlan(payment);
addressMap.delete(payment.address);
console.log(`Payment ${pid} paid (all txs confirmed)`);
}
}
}
// ── Bulk polling fallback ───────────────────────────────────────────
// ── Polling fallback ────────────────────────────────────────────────
export async function checkPayments() {
await sql`
UPDATE payments SET status = 'expired'
WHERE status IN ('pending', 'underpaid', 'confirming')
AND expires_at < now()
WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at < now()
`;
await refreshMaps();
const allPayments = await sql`
SELECT * FROM payments
WHERE status IN ('pending', 'underpaid', 'confirming')
AND expires_at >= now()
const payments = await sql`
SELECT * FROM payments WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at >= now()
`;
if (payments.length === 0) return;
if (allPayments.length === 0) return;
const addresses = allPayments.map((p: any) => p.address);
let bulk: Record<string, any> = {};
try {
bulk = await getAddressInfoBulk(addresses);
} catch (e) {
console.error("Bulk address lookup failed, falling back to individual:", e);
}
try { bulk = await getAddressInfoBulk(payments.map((p: any) => p.address)); } catch {}
for (const payment of allPayments) {
for (const payment of payments) {
try {
let info = bulk[payment.address];
if (!info) {
try { info = await getAddressInfo(payment.address); } catch { continue; }
}
if (!info) try { info = await getAddressInfo(payment.address); } catch { continue; }
if (!info || info.error) continue;
const coin = COINS[payment.coin];
if (!coin) continue;
const received = Number(info.received ?? 0);
const receivedConfirmed = Number(info.received_confirmed ?? 0);
const expected = parseFloat(payment.amount_crypto);
const threshold = expected * THRESHOLD;
// Sync txs from address info into payment_txs
let newTxDetected = false;
if (info.in?.length) {
for (const tx of info.in) {
// Sync txs from address API
for (const tx of info.in ?? []) {
if (!tx.txid) continue;
const [ins] = await sql`
INSERT INTO payment_txs (payment_id, txid, amount, confirmed)
VALUES (${payment.id}, ${tx.txid}, ${String(tx.amount ?? 0)}, ${tx.block != null})
ON CONFLICT (payment_id, txid) DO UPDATE SET confirmed = EXCLUDED.confirmed
RETURNING id
`;
if (ins) newTxDetected = true;
}
}
// Extend expiry to 24h on first tx detection
if (newTxDetected) {
const newExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
await sql`UPDATE payments SET expires_at = ${newExpiry.toISOString()} WHERE id = ${payment.id} AND expires_at < ${newExpiry.toISOString()}`;
}
if (payment.status === "pending" || payment.status === "underpaid") {
if (coin.confirmations === 0 && received >= threshold) {
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await applyPlan(payment);
} else if (coin.confirmations > 0 && receivedConfirmed >= threshold) {
// Check all txs confirmed
const [{ unconfirmed }] = await sql`
SELECT COUNT(*)::int as unconfirmed FROM payment_txs WHERE payment_id = ${payment.id} AND confirmed = false
`;
if (Number(unconfirmed) === 0) {
await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await applyPlan(payment);
}
} else if (received >= threshold) {
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, status = 'confirming' WHERE id = ${payment.id}`;
} else if (received > 0) {
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, status = 'underpaid' WHERE id = ${payment.id}`;
}
} else if (payment.status === "confirming") {
const [{ unconfirmed }] = await sql`
SELECT COUNT(*)::int as unconfirmed FROM payment_txs WHERE payment_id = ${payment.id} AND confirmed = false
`;
if (receivedConfirmed >= threshold && Number(unconfirmed) === 0) {
await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await applyPlan(payment);
}
await recordTx(payment.id, tx.txid, Number(tx.amount ?? 0), tx.block != null);
}
await evaluatePayment(payment.id);
} catch (e) {
console.error(`Error checking payment ${payment.id}:`, e);
}
}
}
// ── Helpers ──────────────────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────────────────
export function watchPayment(payment: any) {
addressMap.set(payment.address, payment);
@ -321,10 +214,10 @@ async function applyPlan(payment: any) {
if (payment.plan === "lifetime") {
await sql`UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL WHERE id = ${payment.account_id}`;
} else {
const [account] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id}`;
const [acc] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id}`;
const now = new Date();
const currentExpiry = account.plan_expires_at ? new Date(account.plan_expires_at) : null;
const base = (account.plan === "pro" && currentExpiry && currentExpiry > now) ? currentExpiry : now;
const expiry = acc.plan_expires_at ? new Date(acc.plan_expires_at) : null;
const base = (acc.plan === "pro" && expiry && expiry > now) ? expiry : now;
const newExpiry = new Date(base);
newExpiry.setMonth(newExpiry.getMonth() + payment.months);
await sql`UPDATE accounts SET plan = 'pro', plan_expires_at = ${newExpiry.toISOString()} WHERE id = ${payment.account_id}`;
@ -335,17 +228,11 @@ async function applyPlan(payment: any) {
export async function expireProPlans() {
const result = await sql`
UPDATE accounts SET plan = 'free', plan_expires_at = NULL
WHERE plan = 'pro'
AND plan_expires_at IS NOT NULL
AND plan_expires_at < now()
WHERE plan = 'pro' AND plan_expires_at IS NOT NULL AND plan_expires_at < now()
`;
if (result.count > 0) {
console.log(`Downgraded ${result.count} expired pro accounts to free`);
}
if (result.count > 0) console.log(`Downgraded ${result.count} expired pro accounts`);
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }
startSSE();

View File

@ -404,6 +404,14 @@
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 {}

View File

@ -503,9 +503,6 @@
No credit card
</li>
</ul>
<a href="/dashboard" class="mt-8 block text-center w-full py-3 bg-brand hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors">
Get Started
</a>
</div>
<!-- Pro -->
@ -531,9 +528,6 @@
Priority support
</li>
</ul>
<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 -->
@ -562,11 +556,15 @@
Limited availability
</li>
</ul>
<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 class="mt-12">
<a href="/dashboard" class="inline-flex items-center gap-2 px-8 py-3.5 bg-brand hover:bg-blue-500 text-white font-medium rounded-lg transition-colors text-sm">
Get Started
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
</a>
</div>
</div>
</section>