add lifetime 1-4x
This commit is contained in:
parent
180f888819
commit
824cb15faa
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -7,11 +7,13 @@ export interface PlanLimits {
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||||
free: { maxMonitors: 10, minIntervalS: 30, maxRegions: 1 },
|
free: { maxMonitors: 10, minIntervalS: 30, maxRegions: 1 },
|
||||||
pro: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
|
pro: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
|
||||||
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,17 +21,20 @@ 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;
|
||||||
export const LIFETIME_USD = 140;
|
export const LIFETIME_USD = 140;
|
||||||
|
|
||||||
export const PLAN_PRICING: Record<string, { label: string; monthlyUsd?: number; priceUsd?: number }> = {
|
export const PLAN_PRICING: Record<string, { label: string; monthlyUsd?: number; priceUsd?: number }> = {
|
||||||
pro: { label: "Pro", monthlyUsd: PRO_MONTHLY_USD },
|
pro: { label: "Pro", monthlyUsd: PRO_MONTHLY_USD },
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -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">200–800 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;
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@
|
||||||
const plan = it.account.plan || 'free';
|
const plan = it.account.plan || 'free';
|
||||||
const planLabel = it.planLabels[plan] || plan;
|
const planLabel = it.planLabels[plan] || plan;
|
||||||
const limits = {
|
const limits = {
|
||||||
free: { monitors: 10, interval: '30s', regions: 1 },
|
free: { monitors: 10, interval: '30s', regions: 1 },
|
||||||
pro: { monitors: 200, interval: '5s', regions: 'All' },
|
pro: { monitors: 200, interval: '5s', regions: 'All' },
|
||||||
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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue