add lifetime 1-4x

This commit is contained in:
nate 2026-04-10 13:56:59 +04:00
parent 180f888819
commit 824cb15faa
8 changed files with 136 additions and 63 deletions

View File

@ -239,10 +239,10 @@ export function computeApplyPlan(
): AccountUpdate { ): AccountUpdate {
const stack = (acc.plan_stack || []).slice(); const stack = (acc.plan_stack || []).slice();
const newPlan = payment.plan; const newPlan = payment.plan;
const newDays = payment.plan === "lifetime" ? null : (payment.months ?? 1) * 30; const newDays = payment.plan.startsWith("lifetime") ? null : (payment.months ?? 1) * 30;
const currentExpiry = acc.plan_expires_at ? new Date(acc.plan_expires_at) : null; const currentExpiry = acc.plan_expires_at ? new Date(acc.plan_expires_at) : null;
const currentIsActive = acc.plan === "lifetime" const currentIsActive = acc.plan.startsWith("lifetime")
|| (acc.plan !== "free" && currentExpiry && currentExpiry > now); || (acc.plan !== "free" && currentExpiry && currentExpiry > now);
if (!currentIsActive || acc.plan === "free") { if (!currentIsActive || acc.plan === "free") {
@ -256,7 +256,7 @@ export function computeApplyPlan(
} }
if (planTier(newPlan) > planTier(acc.plan)) { if (planTier(newPlan) > planTier(acc.plan)) {
const remainingDays = acc.plan === "lifetime" const remainingDays = acc.plan.startsWith("lifetime")
? null ? null
: Math.ceil((currentExpiry!.getTime() - now.getTime()) / 86400000); : Math.ceil((currentExpiry!.getTime() - now.getTime()) / 86400000);
const newStack = insertIntoStack(stack, { plan: acc.plan, remaining_days: remainingDays }); const newStack = insertIntoStack(stack, { plan: acc.plan, remaining_days: remainingDays });

View File

@ -18,10 +18,10 @@ export async function generateReceipt(paymentId: number): Promise<string> {
? new Date(payment.paid_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }) ? new Date(payment.paid_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
: "-"; : "-";
const createdDate = new Date(payment.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); const createdDate = new Date(payment.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
const planNames: Record<string, string> = { pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime" }; const planNames: Record<string, string> = { pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime", lifetime2x: "Lifetime 2x", lifetime4x: "Lifetime 4x" };
const planName = planNames[payment.plan] || payment.plan; const planName = planNames[payment.plan] || payment.plan;
const planLabel = payment.plan === "lifetime" const planLabel = payment.plan.startsWith("lifetime")
? "Lifetime" ? planName
: `${planName} × ${payment.months} month${payment.months > 1 ? "s" : ""}`; : `${planName} × ${payment.months} month${payment.months > 1 ? "s" : ""}`;
const txRows = txs.map((tx: any) => { const txRows = txs.map((tx: any) => {

View File

@ -2,7 +2,7 @@ import { Elysia, t } from "elysia";
import sql from "./db"; import sql from "./db";
import { derive } from "./address"; import { derive } from "./address";
import { getExchangeRates, getAvailableCoins, fetchQrBase64 } from "./freedom"; import { getExchangeRates, getAvailableCoins, fetchQrBase64 } from "./freedom";
import { PLAN_PRICING as PLANS, COINS } from "../../shared/plans"; import { PLAN_PRICING as PLANS, COINS, planTier } from "../../shared/plans";
import { generateReceipt } from "./receipt"; import { generateReceipt } from "./receipt";
import { watchPayment } from "./monitor"; import { watchPayment } from "./monitor";
import { resolveKey as sharedResolveKey, extractAuthKey } from "../../shared/auth"; import { resolveKey as sharedResolveKey, extractAuthKey } from "../../shared/auth";
@ -45,11 +45,13 @@ export const routes = new Elysia()
const { plan, months, coin } = body; const { plan, months, coin } = body;
if (plan === "lifetime") { if (plan.startsWith("lifetime")) {
const [acc] = await sql`SELECT plan, plan_stack FROM accounts WHERE id = ${accountId}`; const [acc] = await sql`SELECT plan, plan_stack FROM accounts WHERE id = ${accountId}`;
const stack = typeof acc.plan_stack === "string" ? JSON.parse(acc.plan_stack) : (acc.plan_stack || []); 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"); const buyingTier = planTier(plan);
if (hasLifetime) { set.status = 400; return { error: "You already have a lifetime plan" }; } const ownedLifetimes = [acc.plan, ...stack.map((s: any) => s.plan)].filter((p: string) => p.startsWith("lifetime"));
const maxOwnedTier = Math.max(0, ...ownedLifetimes.map((p: string) => planTier(p)));
if (maxOwnedTier >= buyingTier) { set.status = 400; return { error: "You already have this lifetime tier or higher" }; }
} }
if (!COINS[coin]) { set.status = 400; return { error: `Unknown coin: ${coin}` }; } if (!COINS[coin]) { set.status = 400; return { error: `Unknown coin: ${coin}` }; }
@ -60,9 +62,9 @@ export const routes = new Elysia()
if (!planDef) { set.status = 400; return { error: `Unknown plan: ${plan}` }; } if (!planDef) { set.status = 400; return { error: `Unknown plan: ${plan}` }; }
let amountUsd = planDef.priceUsd ?? (planDef.monthlyUsd! * (months ?? 1)); let amountUsd = planDef.priceUsd ?? (planDef.monthlyUsd! * (months ?? 1));
if (plan === "lifetime" && planDef.priceUsd) { if (plan.startsWith("lifetime") && planDef.priceUsd) {
const [{ total }] = await sql`SELECT COALESCE(SUM(amount_usd), 0)::numeric as total FROM payments WHERE account_id = ${accountId} AND status = 'paid'`; const [{ total }] = await sql`SELECT COALESCE(SUM(amount_usd), 0)::numeric as total FROM payments WHERE account_id = ${accountId} AND status = 'paid'`;
const credit = Math.min(Number(total), planDef.priceUsd * 0.75); const credit = Math.min(Number(total), planDef.priceUsd * 0.90);
amountUsd = Math.max(amountUsd - credit, 1); amountUsd = Math.max(amountUsd - credit, 1);
} }
const rates = await getExchangeRates(); const rates = await getExchangeRates();
@ -87,7 +89,7 @@ export const routes = new Elysia()
const [payment] = await sql` const [payment] = await sql`
INSERT INTO payments (account_id, plan, months, amount_usd, coin, amount_crypto, address, derivation_index, expires_at) INSERT INTO payments (account_id, plan, months, amount_usd, coin, amount_crypto, address, derivation_index, expires_at)
VALUES (${accountId}, ${plan}, ${plan === "lifetime" ? null : months ?? 1}, ${amountUsd}, ${coin}, ${amountCrypto}, ${address}, ${next_index}, ${expiresAt.toISOString()}) VALUES (${accountId}, ${plan}, ${plan.startsWith("lifetime") ? null : months ?? 1}, ${amountUsd}, ${coin}, ${amountCrypto}, ${address}, ${next_index}, ${expiresAt.toISOString()})
RETURNING * RETURNING *
`; `;

View File

@ -1,4 +1,4 @@
export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime"; export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime" | "lifetime2x" | "lifetime4x";
export interface PlanLimits { export interface PlanLimits {
maxMonitors: number; maxMonitors: number;
@ -12,6 +12,8 @@ const PLAN_LIMITS: Record<Plan, PlanLimits> = {
pro2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 }, pro2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 },
pro4x: { maxMonitors: 800, minIntervalS: 5, maxRegions: 99 }, pro4x: { maxMonitors: 800, minIntervalS: 5, maxRegions: 99 },
lifetime: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 }, lifetime: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
lifetime2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 },
lifetime4x: { maxMonitors: 800, minIntervalS: 5, maxRegions: 99 },
}; };
export function getPlanLimits(plan: string): PlanLimits { export function getPlanLimits(plan: string): PlanLimits {
@ -19,7 +21,8 @@ export function getPlanLimits(plan: string): PlanLimits {
} }
export const PLAN_LABELS: Record<string, string> = { export const PLAN_LABELS: Record<string, string> = {
free: "Free", pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime", free: "Free", pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x",
lifetime: "Lifetime", lifetime2x: "Lifetime 2x", lifetime4x: "Lifetime 4x",
}; };
export const PRO_MONTHLY_USD = 12; export const PRO_MONTHLY_USD = 12;
@ -30,6 +33,8 @@ export const PLAN_PRICING: Record<string, { label: string; monthlyUsd?: number;
pro2x: { label: "Pro 2x", monthlyUsd: PRO_MONTHLY_USD * 2 }, pro2x: { label: "Pro 2x", monthlyUsd: PRO_MONTHLY_USD * 2 },
pro4x: { label: "Pro 4x", monthlyUsd: PRO_MONTHLY_USD * 4 }, pro4x: { label: "Pro 4x", monthlyUsd: PRO_MONTHLY_USD * 4 },
lifetime: { label: "Lifetime", priceUsd: LIFETIME_USD }, lifetime: { label: "Lifetime", priceUsd: LIFETIME_USD },
lifetime2x: { label: "Lifetime 2x", priceUsd: LIFETIME_USD * 2 },
lifetime4x: { label: "Lifetime 4x", priceUsd: LIFETIME_USD * 4 },
}; };
export const COINS: Record<string, { label: string; ticker: string; confirmations: number; uri: string }> = { export const COINS: Record<string, { label: string; ticker: string; confirmations: number; uri: string }> = {
@ -42,7 +47,7 @@ export const COINS: Record<string, { label: string; ticker: string; confirmation
}; };
const PLAN_RANK: Record<string, number> = { const PLAN_RANK: Record<string, number> = {
free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3, free: 0, pro: 1, lifetime: 1, pro2x: 2, lifetime2x: 2, pro4x: 3, lifetime4x: 3,
}; };
export function planTier(plan: string): number { export function planTier(plan: string): number {

View File

@ -4,7 +4,7 @@ import { resolve } from "path";
import { resolveKey } from "./auth"; import { resolveKey } from "./auth";
import sql from "../db"; import sql from "../db";
import { sparklineFromPings, pickBestRegion } from "../utils/sparkline"; 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> { async function hashFile(path: string): Promise<string> {
const bytes = await Bun.file(path).bytes(); const bytes = await Bun.file(path).bytes();
@ -319,8 +319,10 @@ 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, plan_stack FROM accounts WHERE id = ${resolved.accountId}`; 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 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"); const allPlans = [acc.plan, ...stack.map((s: any) => s.plan)];
if (acc.plan === "lifetime" && stack.length === 0) return redirect("/dashboard/settings"); 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'`; 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 || []; coins = data.coins || [];
} catch {} } 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 }) => { .get("/dashboard/checkout/:id", async ({ cookie, headers, params }) => {

View File

@ -7,15 +7,16 @@
const invoice = it.invoice; const invoice = it.invoice;
const payApi = it.payApi || ''; const payApi = it.payApi || '';
const totalSpent = it.totalSpent || 0; const totalSpent = it.totalSpent || 0;
const hasLifetime = it.hasLifetime || false; const maxLifetimeTier = it.maxLifetimeTier || 0;
const lifetimeBase = 140; const lifetimeBase = 140;
const lifetimeDiscount = hasLifetime ? 0 : Math.min(totalSpent, lifetimeBase * 0.75); const lifetimePrices = { lifetime: lifetimeBase, lifetime2x: lifetimeBase * 2, lifetime4x: lifetimeBase * 4 };
const lifetimePrice = lifetimeBase - lifetimeDiscount; const lifetimeCredit = Math.min(totalSpent, lifetimeBase * 0.90);
%> %>
<style> <style>
/* Show pro options when pro is selected */ /* Show options when selected */
#plan-pro:checked ~ #pro-options { display: block; } #plan-pro:checked ~ #pro-options { display: block; }
#plan-lifetime:checked ~ #lifetime-options { display: block; }
/* Highlight selected plan type */ /* Highlight selected plan type */
#plan-pro:checked ~ .plan-labels label[for="plan-pro"] { border-color: rgb(59 130 246 / 0.5); } #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); } #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"> 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-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-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> </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"> <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> <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-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-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> </div>
<% } else { %> <% } else { %>
<label for="plan-lifetime" <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"> 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> <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> <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">From $<%= lifetimeBase %></div>
<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-gray-500 mt-2">One-time, 200-800 monitors forever</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>
</label> </label>
<% } %> <% } %>
</div> </div>
@ -80,15 +76,15 @@
<div> <div>
<label class="block text-sm text-gray-400 mb-2">Tier</label> <label class="block text-sm text-gray-400 mb-2">Tier</label>
<div id="tier-selector" class="grid grid-cols-3 gap-2"> <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-sm font-semibold text-gray-200">1x</div>
<div class="text-xs text-gray-500">200 monitors · $12/mo</div> <div class="text-xs text-gray-500">200 monitors · $12/mo</div>
</button> </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-sm font-semibold text-gray-200">2x</div>
<div class="text-xs text-gray-500">400 monitors · $24/mo</div> <div class="text-xs text-gray-500">400 monitors · $24/mo</div>
</button> </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-sm font-semibold text-gray-200">4x</div>
<div class="text-xs text-gray-500">800 monitors · $48/mo</div> <div class="text-xs text-gray-500">800 monitors · $48/mo</div>
</button> </button>
@ -105,6 +101,46 @@
</div> </div>
</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 --> <!-- Coin selection -->
<div> <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>
@ -283,32 +319,54 @@
} }
const multipliers = { pro: 1, pro2x: 2, pro4x: 4 }; const multipliers = { pro: 1, pro2x: 2, pro4x: 4 };
const lifetimePlans = { lifetime: 'lifetime', lifetime2x: 'lifetime2x', lifetime4x: 'lifetime4x' };
const planValue = document.getElementById('plan-value'); 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) // Plan type toggle (pro vs lifetime)
document.querySelectorAll('input[name="_planType"]').forEach(radio => { document.querySelectorAll('input[name="_planType"]').forEach(radio => {
radio.addEventListener('change', () => { 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 => { document.querySelectorAll('.tier-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
selectedTier = btn.dataset.plan; selectedProTier = btn.dataset.plan;
planValue.value = selectedTier; planValue.value = selectedProTier;
// Update active style
document.querySelectorAll('.tier-btn').forEach(b => { document.querySelectorAll('.tier-btn').forEach(b => {
b.classList.remove('border-blue-500/50'); b.classList.remove('border-blue-500/50');
b.classList.add('border-border-subtle'); b.classList.add('border-border-subtle');
}); });
btn.classList.remove('border-border-subtle'); btn.classList.remove('border-border-subtle');
btn.classList.add('border-blue-500/50'); 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) { function updateMonthPricing(plan) {
const sel = document.getElementById('months-select'); const sel = document.getElementById('months-select');
if (!sel) return; if (!sel) return;

View File

@ -5,7 +5,7 @@
const bg = it._form?.bg || 'bg-gray-900'; const bg = it._form?.bg || 'bg-gray-900';
const border = it._form?.border || 'border-gray-800'; const border = it._form?.border || 'border-gray-800';
const plan = it.plan || 'free'; 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 btnText = isEdit ? 'Save Changes' : 'Create Monitor';
const formId = prefix + 'form'; const formId = prefix + 'form';
const methods = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS']; const methods = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'];

View File

@ -12,6 +12,8 @@
pro2x: { monitors: 400, interval: '5s', regions: 'All' }, pro2x: { monitors: 400, interval: '5s', regions: 'All' },
pro4x: { monitors: 800, interval: '5s', regions: 'All' }, pro4x: { monitors: 800, interval: '5s', regions: 'All' },
lifetime: { monitors: 200, 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 }; }[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> <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') { %> <% } 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> <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') { %> <% } 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">Lifetime</span> <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>
<div class="grid grid-cols-3 gap-4 text-center"> <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) { %> <% } 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"> <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' }) %>. <%= 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 || []); <% const stack = typeof it.account.plan_stack === 'string' ? JSON.parse(it.account.plan_stack) : (it.account.plan_stack || []);
if (stack.length > 0) { if (stack.length > 0) {
const parts = stack.map(function(s) { const parts = stack.map(function(s) {
@ -63,6 +65,10 @@
<div class="mt-1 text-gray-600">Then: <%= parts.join(' → ') %></div> <div class="mt-1 text-gray-600">Then: <%= parts.join(' → ') %></div>
<% } %> <% } %>
</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> </section>
@ -175,7 +181,7 @@
const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' }; const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' };
const statusColor = statusColors[inv.status] || 'gray'; const statusColor = statusColors[inv.status] || 'gray';
const date = new Date(inv.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); 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 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"> <div class="flex items-center gap-3">