add lifetime 1-4x
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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">200–800 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;
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user