add lifetime 1-4x

This commit is contained in:
2026-04-10 13:56:59 +04:00
parent 180f888819
commit 824cb15faa
8 changed files with 136 additions and 63 deletions
+6 -4
View File
@@ -4,7 +4,7 @@ import { resolve } from "path";
import { resolveKey } from "./auth";
import sql from "../db";
import { sparklineFromPings, pickBestRegion } from "../utils/sparkline";
import { PLAN_LABELS, REGION_COLORS, REGION_LABELS, REGIONS } from "../../../shared/plans";
import { PLAN_LABELS, REGION_COLORS, REGION_LABELS, REGIONS, planTier } from "../../../shared/plans";
async function hashFile(path: string): Promise<string> {
const bytes = await Bun.file(path).bytes();
@@ -319,8 +319,10 @@ export const dashboard = new Elysia()
if (!resolved?.accountId) return redirect("/dashboard");
const [acc] = await sql`SELECT plan, plan_expires_at, plan_stack FROM accounts WHERE id = ${resolved.accountId}`;
const stack = typeof acc.plan_stack === "string" ? JSON.parse(acc.plan_stack) : (acc.plan_stack || []);
const hasLifetime = acc.plan === "lifetime" || stack.some((s: any) => s.plan === "lifetime");
if (acc.plan === "lifetime" && stack.length === 0) return redirect("/dashboard/settings");
const allPlans = [acc.plan, ...stack.map((s: any) => s.plan)];
const ownedLifetimes = allPlans.filter((p: string) => p.startsWith("lifetime"));
const maxLifetimeTier = Math.max(0, ...ownedLifetimes.map((p: string) => planTier(p)));
if (maxLifetimeTier >= 3) return redirect("/dashboard/settings"); // has lifetime4x, nothing to upgrade
const [{ total_spent }] = await sql`SELECT COALESCE(SUM(amount_usd), 0)::numeric as total_spent FROM payments WHERE account_id = ${resolved.accountId} AND status = 'paid'`;
@@ -332,7 +334,7 @@ export const dashboard = new Elysia()
coins = data.coins || [];
} catch {}
return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: null, coins, invoice: null, totalSpent: Number(total_spent), hasLifetime });
return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: null, coins, invoice: null, totalSpent: Number(total_spent), maxLifetimeTier });
})
.get("/dashboard/checkout/:id", async ({ cookie, headers, params }) => {
+82 -24
View File
@@ -7,15 +7,16 @@
const invoice = it.invoice;
const payApi = it.payApi || '';
const totalSpent = it.totalSpent || 0;
const hasLifetime = it.hasLifetime || false;
const maxLifetimeTier = it.maxLifetimeTier || 0;
const lifetimeBase = 140;
const lifetimeDiscount = hasLifetime ? 0 : Math.min(totalSpent, lifetimeBase * 0.75);
const lifetimePrice = lifetimeBase - lifetimeDiscount;
const lifetimePrices = { lifetime: lifetimeBase, lifetime2x: lifetimeBase * 2, lifetime4x: lifetimeBase * 4 };
const lifetimeCredit = Math.min(totalSpent, lifetimeBase * 0.90);
%>
<style>
/* Show pro options when pro is selected */
/* Show options when selected */
#plan-pro:checked ~ #pro-options { display: block; }
#plan-lifetime:checked ~ #lifetime-options { display: block; }
/* Highlight selected plan type */
#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); }
@@ -50,27 +51,22 @@
class="cursor-pointer text-left bg-surface border-2 border-border-subtle hover:border-blue-500/40 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">From $12<span class="text-sm font-normal text-gray-500">/mo</span></div>
<div class="text-xs text-gray-500 mt-2">200800 monitors, 5s intervals</div>
<div class="text-xs text-gray-500 mt-2">200-800 monitors, 5s intervals</div>
</label>
<% if (hasLifetime) { %>
<% if (maxLifetimeTier >= 3) { %>
<div class="text-left bg-surface border-2 border-border-subtle rounded-xl p-5 opacity-50 cursor-not-allowed relative">
<span class="absolute top-3 right-3 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-gray-500/15 text-gray-500 border border-gray-500/20">Owned</span>
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-1">Lifetime</div>
<div class="text-2xl font-bold text-gray-400">$<%= lifetimeBase %></div>
<div class="text-xs text-gray-600 mt-2">You already have Lifetime</div>
<div class="text-xs text-gray-600 mt-2">You have the highest lifetime tier</div>
</div>
<% } else { %>
<label for="plan-lifetime"
class="cursor-pointer text-left bg-surface border-2 border-border-subtle hover:border-yellow-500/40 rounded-xl p-5 transition-colors relative">
<span class="absolute top-3 right-3 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-yellow-500/15 text-yellow-500 border border-yellow-500/20">Launch Deal</span>
<div class="text-xs text-yellow-500/70 uppercase tracking-wider font-mono mb-1">Lifetime</div>
<% if (lifetimeDiscount > 0) { %>
<div class="text-2xl font-bold text-gray-100">$<%= lifetimePrice.toFixed(0) %> <span class="text-sm font-normal text-gray-600 line-through">$<%= lifetimeBase %></span></div>
<div class="text-xs text-green-400/70 mt-1">You've paid us $<%= totalSpent.toFixed(0) %>, so we've credited $<%= lifetimeDiscount.toFixed(0) %> toward lifetime</div>
<% } else { %>
<div class="text-2xl font-bold text-gray-100">$<%= lifetimeBase %></div>
<% } %>
<div class="text-xs text-gray-500 mt-2">One-time, 200 monitors forever</div>
<div class="text-2xl font-bold text-gray-100">From $<%= lifetimeBase %></div>
<div class="text-xs text-gray-500 mt-2">One-time, 200-800 monitors forever</div>
</label>
<% } %>
</div>
@@ -80,15 +76,15 @@
<div>
<label class="block text-sm text-gray-400 mb-2">Tier</label>
<div id="tier-selector" class="grid grid-cols-3 gap-2">
<button type="button" class="tier-btn text-center bg-surface border-2 border-blue-500/50 rounded-lg py-2.5 transition-colors" data-plan="pro">
<button type="button" class="tier-btn text-center bg-surface border-2 border-blue-500/50 rounded-lg py-2.5 transition-colors" data-plan="pro" data-type="pro">
<div class="text-sm font-semibold text-gray-200">1x</div>
<div class="text-xs text-gray-500">200 monitors · $12/mo</div>
</button>
<button type="button" class="tier-btn text-center bg-surface border-2 border-border-subtle hover:border-blue-500/40 rounded-lg py-2.5 transition-colors" data-plan="pro2x">
<button type="button" class="tier-btn text-center bg-surface border-2 border-border-subtle hover:border-blue-500/40 rounded-lg py-2.5 transition-colors" data-plan="pro2x" data-type="pro">
<div class="text-sm font-semibold text-gray-200">2x</div>
<div class="text-xs text-gray-500">400 monitors · $24/mo</div>
</button>
<button type="button" class="tier-btn text-center bg-surface border-2 border-border-subtle hover:border-blue-500/40 rounded-lg py-2.5 transition-colors" data-plan="pro4x">
<button type="button" class="tier-btn text-center bg-surface border-2 border-border-subtle hover:border-blue-500/40 rounded-lg py-2.5 transition-colors" data-plan="pro4x" data-type="pro">
<div class="text-sm font-semibold text-gray-200">4x</div>
<div class="text-xs text-gray-500">800 monitors · $48/mo</div>
</button>
@@ -105,6 +101,46 @@
</div>
</div>
<!-- Lifetime tier (visible only when lifetime selected, via CSS) -->
<div id="lifetime-options" class="hidden space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-2">Tier</label>
<div id="lifetime-tier-selector" class="grid grid-cols-3 gap-2">
<% if (maxLifetimeTier < 1) { %>
<button type="button" class="lt-btn text-center bg-surface border-2 border-yellow-500/50 rounded-lg py-2.5 transition-colors" data-plan="lifetime">
<div class="text-sm font-semibold text-gray-200">1x</div>
<div class="text-xs text-gray-500">200 monitors · $<%= lifetimeBase %></div>
</button>
<% } else { %>
<div class="text-center bg-surface border-2 border-border-subtle rounded-lg py-2.5 opacity-40 cursor-not-allowed">
<div class="text-sm font-semibold text-gray-400">1x</div>
<div class="text-xs text-gray-600">Owned</div>
</div>
<% } %>
<% if (maxLifetimeTier < 2) { %>
<button type="button" class="lt-btn text-center bg-surface border-2 border-border-subtle hover:border-yellow-500/40 rounded-lg py-2.5 transition-colors <%= maxLifetimeTier >= 1 ? '' : '' %>" data-plan="lifetime2x">
<div class="text-sm font-semibold text-gray-200">2x</div>
<div class="text-xs text-gray-500">400 monitors · $<%= lifetimeBase * 2 %></div>
</button>
<% } else { %>
<div class="text-center bg-surface border-2 border-border-subtle rounded-lg py-2.5 opacity-40 cursor-not-allowed">
<div class="text-sm font-semibold text-gray-400">2x</div>
<div class="text-xs text-gray-600">Owned</div>
</div>
<% } %>
<button type="button" class="lt-btn text-center bg-surface border-2 border-border-subtle hover:border-yellow-500/40 rounded-lg py-2.5 transition-colors" data-plan="lifetime4x">
<div class="text-sm font-semibold text-gray-200">4x</div>
<div class="text-xs text-gray-500">800 monitors · $<%= lifetimeBase * 4 %></div>
</button>
</div>
</div>
<% if (totalSpent > 0) { %>
<div id="lifetime-credit" class="text-xs text-green-400/70">
You've spent $<%= totalSpent.toFixed(0) %> with us. Up to 90% of the lifetime price is credited.
</div>
<% } %>
</div>
<!-- Coin selection -->
<div>
<label class="block text-sm text-gray-400 mb-2">Pay with</label>
@@ -283,32 +319,54 @@
}
const multipliers = { pro: 1, pro2x: 2, pro4x: 4 };
const lifetimePlans = { lifetime: 'lifetime', lifetime2x: 'lifetime2x', lifetime4x: 'lifetime4x' };
const planValue = document.getElementById('plan-value');
let selectedTier = 'pro';
let selectedProTier = 'pro';
let selectedLifetimeTier = document.querySelector('.lt-btn')?.dataset.plan || 'lifetime';
// Plan type toggle (pro vs lifetime)
document.querySelectorAll('input[name="_planType"]').forEach(radio => {
radio.addEventListener('change', () => {
planValue.value = radio.value === 'lifetime' ? 'lifetime' : selectedTier;
planValue.value = radio.value === 'lifetime' ? selectedLifetimeTier : selectedProTier;
});
});
// Tier buttons
// Pro tier buttons
document.querySelectorAll('.tier-btn').forEach(btn => {
btn.addEventListener('click', () => {
selectedTier = btn.dataset.plan;
planValue.value = selectedTier;
// Update active style
selectedProTier = btn.dataset.plan;
planValue.value = selectedProTier;
document.querySelectorAll('.tier-btn').forEach(b => {
b.classList.remove('border-blue-500/50');
b.classList.add('border-border-subtle');
});
btn.classList.remove('border-border-subtle');
btn.classList.add('border-blue-500/50');
updateMonthPricing(selectedTier);
updateMonthPricing(selectedProTier);
});
});
// Lifetime tier buttons
document.querySelectorAll('.lt-btn').forEach(btn => {
btn.addEventListener('click', () => {
selectedLifetimeTier = btn.dataset.plan;
planValue.value = selectedLifetimeTier;
document.querySelectorAll('.lt-btn').forEach(b => {
b.classList.remove('border-yellow-500/50');
b.classList.add('border-border-subtle');
});
btn.classList.remove('border-border-subtle');
btn.classList.add('border-yellow-500/50');
});
});
// Select first available lifetime tier by default
const firstLt = document.querySelector('.lt-btn');
if (firstLt) {
firstLt.classList.remove('border-border-subtle');
firstLt.classList.add('border-yellow-500/50');
}
function updateMonthPricing(plan) {
const sel = document.getElementById('months-select');
if (!sel) return;
+1 -1
View File
@@ -5,7 +5,7 @@
const bg = it._form?.bg || 'bg-gray-900';
const border = it._form?.border || 'border-gray-800';
const plan = it.plan || 'free';
const minInterval = { free: 30, pro: 5, pro2x: 5, pro4x: 5, lifetime: 5 }[plan] || 30;
const minInterval = { free: 30, pro: 5, pro2x: 5, pro4x: 5, lifetime: 5, lifetime2x: 5, lifetime4x: 5 }[plan] || 30;
const btnText = isEdit ? 'Save Changes' : 'Create Monitor';
const formId = prefix + 'form';
const methods = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'];
+15 -9
View File
@@ -7,11 +7,13 @@
const plan = it.account.plan || 'free';
const planLabel = it.planLabels[plan] || plan;
const limits = {
free: { monitors: 10, interval: '30s', regions: 1 },
pro: { monitors: 200, interval: '5s', regions: 'All' },
pro2x: { monitors: 400, interval: '5s', regions: 'All' },
pro4x: { monitors: 800, interval: '5s', regions: 'All' },
lifetime: { monitors: 200, interval: '5s', regions: 'All' },
free: { monitors: 10, interval: '30s', regions: 1 },
pro: { monitors: 200, interval: '5s', regions: 'All' },
pro2x: { monitors: 400, interval: '5s', regions: 'All' },
pro4x: { monitors: 800, interval: '5s', regions: 'All' },
lifetime: { monitors: 200, interval: '5s', regions: 'All' },
lifetime2x: { monitors: 400, interval: '5s', regions: 'All' },
lifetime4x: { monitors: 800, interval: '5s', regions: 'All' },
}[plan] || { monitors: 10, interval: '30s', regions: 1 };
%>
@@ -27,8 +29,8 @@
<span class="text-xs font-medium px-2.5 py-1 rounded-full bg-gray-800/50 border border-border-subtle text-gray-400">Free</span>
<% } else if (plan === 'pro' || plan === 'pro2x' || plan === 'pro4x') { %>
<span class="text-xs font-medium px-2.5 py-1 rounded-full bg-gradient-to-r from-blue-600/20 to-blue-500/10 border border-blue-500/30 text-blue-400"><%= planLabel %></span>
<% } else if (plan === 'lifetime') { %>
<span class="text-xs font-medium px-2.5 py-1 rounded-full bg-gradient-to-r from-yellow-600/20 to-yellow-500/10 border border-yellow-500/30 text-yellow-400">Lifetime</span>
<% } else if (plan.startsWith('lifetime')) { %>
<span class="text-xs font-medium px-2.5 py-1 rounded-full bg-gradient-to-r from-yellow-600/20 to-yellow-500/10 border border-yellow-500/30 text-yellow-400"><%= planLabel %></span>
<% } %>
</div>
<div class="grid grid-cols-3 gap-4 text-center">
@@ -52,7 +54,7 @@
<% } else if ((plan === 'pro' || plan === 'pro2x' || plan === 'pro4x') && it.account.plan_expires_at) { %>
<div class="mt-4 pt-4 border-t divider text-xs text-gray-500">
<%= planLabel %> 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>
<a href="/dashboard/checkout" class="text-blue-400 hover:text-blue-300 ml-1">Extend or upgrade</a>
<% const stack = typeof it.account.plan_stack === 'string' ? JSON.parse(it.account.plan_stack) : (it.account.plan_stack || []);
if (stack.length > 0) {
const parts = stack.map(function(s) {
@@ -63,6 +65,10 @@
<div class="mt-1 text-gray-600">Then: <%= parts.join(' → ') %></div>
<% } %>
</div>
<% } else if (plan.startsWith('lifetime') && plan !== 'lifetime4x') { %>
<div class="mt-4 pt-4 border-t divider text-xs text-gray-500">
<a href="/dashboard/checkout" class="text-yellow-400 hover:text-yellow-300">Upgrade to a higher tier</a>
</div>
<% } %>
</section>
@@ -175,7 +181,7 @@
const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' };
const statusColor = statusColors[inv.status] || 'gray';
const date = new Date(inv.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
const planLabel = inv.plan === 'lifetime' ? 'Lifetime' : `${it.planLabels[inv.plan] || 'Pro'} × ${inv.months}mo`;
const planLabel = inv.plan.startsWith('lifetime') ? (it.planLabels[inv.plan] || 'Lifetime') : `${it.planLabels[inv.plan] || 'Pro'} × ${inv.months}mo`;
%>
<div class="flex items-center justify-between p-3 rounded-lg bg-surface border border-border-subtle hover:border-border-strong transition-colors">
<div class="flex items-center gap-3">