add lifetime 1-4x
This commit is contained in:
parent
180f888819
commit
824cb15faa
|
|
@ -239,10 +239,10 @@ export function computeApplyPlan(
|
|||
): AccountUpdate {
|
||||
const stack = (acc.plan_stack || []).slice();
|
||||
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 currentIsActive = acc.plan === "lifetime"
|
||||
const currentIsActive = acc.plan.startsWith("lifetime")
|
||||
|| (acc.plan !== "free" && currentExpiry && currentExpiry > now);
|
||||
|
||||
if (!currentIsActive || acc.plan === "free") {
|
||||
|
|
@ -256,7 +256,7 @@ export function computeApplyPlan(
|
|||
}
|
||||
|
||||
if (planTier(newPlan) > planTier(acc.plan)) {
|
||||
const remainingDays = acc.plan === "lifetime"
|
||||
const remainingDays = acc.plan.startsWith("lifetime")
|
||||
? null
|
||||
: Math.ceil((currentExpiry!.getTime() - now.getTime()) / 86400000);
|
||||
const newStack = insertIntoStack(stack, { plan: acc.plan, remaining_days: remainingDays });
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
: "-";
|
||||
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 planLabel = payment.plan === "lifetime"
|
||||
? "Lifetime"
|
||||
const planLabel = payment.plan.startsWith("lifetime")
|
||||
? planName
|
||||
: `${planName} × ${payment.months} month${payment.months > 1 ? "s" : ""}`;
|
||||
|
||||
const txRows = txs.map((tx: any) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Elysia, t } from "elysia";
|
|||
import sql from "./db";
|
||||
import { derive } from "./address";
|
||||
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 { watchPayment } from "./monitor";
|
||||
import { resolveKey as sharedResolveKey, extractAuthKey } from "../../shared/auth";
|
||||
|
|
@ -45,11 +45,13 @@ export const routes = new Elysia()
|
|||
|
||||
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 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 (hasLifetime) { set.status = 400; return { error: "You already have a lifetime plan" }; }
|
||||
const buyingTier = planTier(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}` }; }
|
||||
|
|
@ -60,9 +62,9 @@ export const routes = new Elysia()
|
|||
if (!planDef) { set.status = 400; return { error: `Unknown plan: ${plan}` }; }
|
||||
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 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);
|
||||
}
|
||||
const rates = await getExchangeRates();
|
||||
|
|
@ -87,7 +89,7 @@ export const routes = new Elysia()
|
|||
|
||||
const [payment] = await sql`
|
||||
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 *
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
maxMonitors: number;
|
||||
|
|
@ -12,6 +12,8 @@ const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
|||
pro2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 },
|
||||
pro4x: { maxMonitors: 800, 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 {
|
||||
|
|
@ -19,7 +21,8 @@ export function getPlanLimits(plan: string): PlanLimits {
|
|||
}
|
||||
|
||||
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;
|
||||
|
|
@ -30,6 +33,8 @@ export const PLAN_PRICING: Record<string, { label: string; monthlyUsd?: number;
|
|||
pro2x: { label: "Pro 2x", monthlyUsd: PRO_MONTHLY_USD * 2 },
|
||||
pro4x: { label: "Pro 4x", monthlyUsd: PRO_MONTHLY_USD * 4 },
|
||||
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 }> = {
|
||||
|
|
@ -42,7 +47,7 @@ export const COINS: Record<string, { label: string; ticker: string; confirmation
|
|||
};
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue