diff --git a/.github/workflows/landing.yml b/.github/workflows/landing.yml
index be351ca..1d4ad9f 100644
--- a/.github/workflows/landing.yml
+++ b/.github/workflows/landing.yml
@@ -3,9 +3,9 @@ name: landing
on:
push:
branches: [main]
- paths: ["landing/**", ".github/workflows/landing.yml"]
+ paths: ["landing/**", "shared/**", ".github/workflows/landing.yml"]
pull_request:
- paths: ["landing/**", ".github/workflows/landing.yml"]
+ paths: ["landing/**", "shared/**", ".github/workflows/landing.yml"]
jobs:
check:
@@ -26,3 +26,4 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test
+ - run: pnpm exec tsc --noEmit
diff --git a/.github/workflows/portal.yml b/.github/workflows/portal.yml
new file mode 100644
index 0000000..e59b2aa
--- /dev/null
+++ b/.github/workflows/portal.yml
@@ -0,0 +1,27 @@
+name: portal
+
+on:
+ push:
+ branches: [main]
+ paths: ["portal/**", "shared/**", ".github/workflows/portal.yml"]
+ pull_request:
+ paths: ["portal/**", "shared/**", ".github/workflows/portal.yml"]
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: portal
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 11
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: portal/pnpm-lock.yaml
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm exec tsc --noEmit
diff --git a/landing/app/components/Pricing.tsx b/landing/app/components/Pricing.tsx
index e1680db..03a6e2b 100644
--- a/landing/app/components/Pricing.tsx
+++ b/landing/app/components/Pricing.tsx
@@ -2,91 +2,24 @@
import { useFadeIn } from "../hooks/useFadeIn";
-const plans = [
- {
- name: "Free",
- price: "$0",
- period: "forever",
- desc: "Everything you need, no account required.",
- highlight: false,
- cta: "Download",
- ctaHref: "#download",
- features: [
- "All core SSH features",
- "SFTP with drag & drop",
- "Docker & serial console",
- "Gist E2EE sync (free)",
- "Plugin system",
- "Custom themes",
- "Local terminal",
- "Persistent sessions & workspace restore",
- "Port forwarding",
- "Audit logs",
- "Snippets & command palette",
- "Import / Export (no lock-in)",
- ],
- },
- {
- name: "Pro",
- price: "$7",
- period: "/ month",
- billingNote: "billed annually",
- monthlyOption: "or $9 billed monthly",
- savings: "Save 22% with annual billing",
- desc: "Real-time sync and unlimited vaults for power users.",
- highlight: true,
- trial: "14-day free trial",
- cta: "Start free trial",
- ctaHref: "https://app.voltius.app/signup?plan=pro",
- features: [
- "Everything in Free",
- "Real-time cloud sync (CRDTs)",
- "Sub-second updates via SSE",
- "Cross-device sessions — one live terminal, shared on all your devices",
- "Unlimited private vaults",
- "Real-time collaboration — 1 session · 1 participant",
- ],
- },
- {
- name: "Teams",
- price: "$15",
- period: "/ user / month",
- billingNote: "billed annually",
- monthlyOption: "or $18 billed monthly",
- savings: "Save 17% with annual billing",
- desc: "Shared vaults, live terminals, and access control for teams (3-user minimum).",
- highlight: false,
- cta: "Get Teams",
- ctaHref: "https://app.voltius.app/signup?plan=teams",
- features: [
- "Everything in Pro",
- "Team vaults & invites",
- "Real-time collaboration — 5 sessions · 10 participants each",
- "Built-in roles (Owner, Manager, Editor, Member)",
- "Team audit logs",
- ],
- },
- {
- name: "Business",
- price: "$25",
- period: "/ user / month",
- billingNote: "billed annually",
- monthlyOption: "or $30 billed monthly",
- savings: "Save 17% with annual billing",
- desc: "Commercial license, advanced collaboration, and dedicated support for organizations (3-user minimum).",
- highlight: false,
- cta: "Get Business",
- ctaHref: "https://app.voltius.app/signup?plan=business",
- features: [
- "Everything in Teams",
- "Real-time collaboration — 20 sessions · 50 participants each",
- "Custom roles & granular permissions",
- "Commercial license",
- "Priority support",
- "Custom contracts",
- ],
- },
-];
+import { PLANS, priceLabel, trialLabel, trialCardLabel, type PlanId } from "@shared/plans";
+
+/** Presentation that belongs to this page only: which card is featured and where its button goes. */
+const PRESENTATION: Record = {
+ free: { highlight: false, cta: "Download", ctaHref: "#download" },
+ pro: { highlight: true, cta: "Start free trial", ctaHref: "https://app.voltius.app/signup?plan=pro" },
+ teams: { highlight: false, cta: "Get Teams", ctaHref: "https://app.voltius.app/signup?plan=teams" },
+ business: { highlight: false, cta: "Get Business", ctaHref: "https://app.voltius.app/signup?plan=business" },
+};
+
+const plans = PLANS.map((plan) => ({
+ ...plan,
+ ...PRESENTATION[plan.id],
+ price: priceLabel(plan.annualPrice),
+ billingNote: plan.annualPrice > 0 ? "billed annually" : null,
+ monthlyOption: plan.monthlyPrice > 0 ? `or ${priceLabel(plan.monthlyPrice)} billed monthly` : null,
+ trialNote: plan.trial ? `${trialLabel(plan.trial)} • ${trialCardLabel(plan.trial)}` : null,
+}));
function PricingCard({ plan, index }: { plan: (typeof plans)[0]; index: number }) {
const ref = useFadeIn(index * 80);
@@ -111,13 +44,13 @@ function PricingCard({ plan, index }: { plan: (typeof plans)[0]; index: number }
{plan.price}
{plan.period && {plan.period}}
- {"billingNote" in plan && plan.billingNote && (
+ {plan.billingNote && (
{plan.billingNote}
)}
- {"monthlyOption" in plan && plan.monthlyOption && (
+ {plan.monthlyOption && (
{plan.monthlyOption}
)}
- {"savings" in plan && plan.savings && (
+ {plan.savings && (
{plan.savings}
)}
{plan.desc}
@@ -133,10 +66,8 @@ function PricingCard({ plan, index }: { plan: (typeof plans)[0]; index: number }
>
{plan.cta}
- {"trial" in plan && (
-
- {plan.trial as string} • {"trialNote" in plan ? plan.trialNote as string : "no credit card required"}
-
+ {plan.trialNote && (
+ {plan.trialNote}
)}
diff --git a/landing/app/plans.test.ts b/landing/app/plans.test.ts
new file mode 100644
index 0000000..fc15b57
--- /dev/null
+++ b/landing/app/plans.test.ts
@@ -0,0 +1,46 @@
+import { test, expect } from "vitest";
+import { PLANS, PER_SEAT_PLAN_IDS, MIN_SEATS, planOrder, planById } from "@shared/plans";
+
+test("every plan the site sells has both prices and an order", () => {
+ for (const plan of PLANS) {
+ expect(plan.name).toBeTruthy();
+ expect(plan.annualPrice).toBeGreaterThanOrEqual(0);
+ expect(plan.monthlyPrice).toBeGreaterThanOrEqual(plan.annualPrice);
+ expect(planOrder(plan.id)).toBeGreaterThanOrEqual(0);
+ }
+});
+
+test("annual never costs more than monthly", () => {
+ // The site advertises annual as the saving; a paid plan priced the other way
+ // round would contradict its own savings label.
+ for (const plan of PLANS.filter((p) => p.annualPrice > 0)) {
+ expect(plan.annualPrice).toBeLessThan(plan.monthlyPrice);
+ expect(plan.savings).toBeTruthy();
+ }
+});
+
+test("per-seat plans are exactly Teams and Business", () => {
+ // Mirrors PER_SEAT_PLANS in the server's billing handler.
+ expect([...PER_SEAT_PLAN_IDS].sort()).toEqual(["business", "teams"]);
+});
+
+test("Business never undercuts Teams at the shared seat floor", () => {
+ const teams = planById("teams")!;
+ const business = planById("business")!;
+ for (const period of ["annualPrice", "monthlyPrice"] as const) {
+ expect(business[period] * MIN_SEATS).toBeGreaterThan(teams[period] * MIN_SEATS);
+ }
+});
+
+test("only the Pro trial is free of a credit card", () => {
+ // Pro's trial is granted server-side at registration; every other trial runs
+ // through LemonSqueezy, which takes a card.
+ for (const plan of PLANS.filter((p) => p.trial)) {
+ expect(plan.trial!.creditCardRequired).toBe(plan.id !== "pro");
+ }
+});
+
+test("Business is sold without a trial", () => {
+ // A free trial of a commercial licence would grant AGPL relief for nothing.
+ expect(planById("business")!.trial).toBeNull();
+});
diff --git a/landing/app/terms/page.tsx b/landing/app/terms/page.tsx
index 536ba6b..b81664b 100644
--- a/landing/app/terms/page.tsx
+++ b/landing/app/terms/page.tsx
@@ -1,10 +1,19 @@
import Link from "next/link";
import type { Metadata } from "next";
+import { PLANS, MIN_SEATS, priceLabel, trialLabel, trialCardLabel, type Plan } from "@shared/plans";
export const metadata: Metadata = {
title: "Terms of Service — Voltius",
};
+/** Renders the billing terms for one plan from the catalogue, so prices are stated once. */
+function planPriceSentence(plan: Plan): string {
+ if (plan.annualPrice === 0) return "$0, no account required.";
+ const rate = plan.perSeat ? "/user/month" : "/month";
+ const seats = plan.perSeat ? `, billed per seat with a ${MIN_SEATS}-user minimum` : "";
+ return `${priceLabel(plan.annualPrice)}${rate} (billed annually) or ${priceLabel(plan.monthlyPrice)}${rate} (billed monthly)${seats}.`;
+}
+
const EFFECTIVE_DATE = "April 23, 2026";
const CONTACT_EMAIL = "contact@voltius.app";
@@ -56,23 +65,13 @@ export default function TermsPage() {
date. No refunds are issued for partial periods, except as required by applicable law.
- -
- Free — $0, no account required.
-
- -
- Pro — $7/month (billed annually) or
- $9/month (billed monthly). Includes a 14-day free trial, no credit card required.
-
- -
- Teams — $15/user/month (billed annually) or
- $18/user/month (billed monthly), billed per seat with a 3-user minimum. Includes a
- 14-day free trial, credit card required.
-
- -
- Business — $25/user/month (billed annually)
- or $30/user/month (billed monthly), billed per seat with a 3-user minimum.
- Contact us for custom contracts and self-hosted deployment.
-
+ {PLANS.map((plan) => (
+ -
+ {plan.name} — {planPriceSentence(plan)}
+ {plan.trial && ` Includes a ${trialLabel(plan.trial)}, ${trialCardLabel(plan.trial)}.`}
+ {plan.id === "business" && " Contact us for custom contracts and self-hosted deployment."}
+
+ ))}
diff --git a/landing/next.config.ts b/landing/next.config.ts
index 094ffc9..a0c8216 100644
--- a/landing/next.config.ts
+++ b/landing/next.config.ts
@@ -1,6 +1,14 @@
import type { NextConfig } from "next";
+import path from "node:path";
const nextConfig: NextConfig = {
+ // The plan catalogue lives above this app so both web apps share one copy.
+ // Turbopack needs the workspace root widened and the alias spelled out;
+ // tsconfig paths alone resolve for tsc but not for the bundler.
+ turbopack: {
+ root: path.join(__dirname, ".."),
+ resolveAlias: { "@shared": path.join(__dirname, "..", "shared") },
+ },
async redirects() {
return [
// The in-app updater and the README send users to /download; the download
diff --git a/landing/tsconfig.json b/landing/tsconfig.json
index 7d1f9d1..858102c 100644
--- a/landing/tsconfig.json
+++ b/landing/tsconfig.json
@@ -19,11 +19,13 @@
}
],
"paths": {
- "@/*": ["./*"]
+ "@/*": ["./*"],
+ "@shared/*": ["../shared/*"]
}
},
"include": [
"next-env.d.ts",
+ "../shared/**/*.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
diff --git a/landing/vitest.config.mts b/landing/vitest.config.mts
index 4215f3b..db7325f 100644
--- a/landing/vitest.config.mts
+++ b/landing/vitest.config.mts
@@ -1,6 +1,15 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
+const here = path.dirname(fileURLToPath(import.meta.url));
+
export default defineConfig({
+ // Matches the Turbopack alias in next.config.ts; vitest does not read
+ // tsconfig paths on its own.
+ resolve: {
+ alias: { "@shared": path.join(here, "..", "shared") },
+ },
test: {
environment: "node",
include: ["app/**/*.test.ts"],
diff --git a/portal/app/account/page.tsx b/portal/app/account/page.tsx
index 18fa556..0e22b7a 100644
--- a/portal/app/account/page.tsx
+++ b/portal/app/account/page.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
+import { PLANS, PER_SEAT_PLAN_IDS, MIN_SEATS, planOrder, trialLabel } from "@shared/plans";
import Image from "next/image";
import { getCheckoutUrl, getPortalUrl, updateSeats, refreshJwt, getSubscription, cancelSubscription, resumeSubscription, resendVerificationEmail } from "../../lib/api";
import EditEmailModal from "./EditEmailModal";
@@ -19,96 +20,11 @@ const PLAN_LABELS: Record = {
pro_trial: "Pro (Trial)",
};
-const PLAN_ORDER: Record = { free: 0, pro: 1, teams: 2, business: 3 };
-
-// Billed on quantity by the server, which floors both at 3 seats.
-const PER_SEAT_PLAN_IDS = ["teams", "business"];
-const MIN_SEATS = 3;
-
-const allPlans = [
- {
- id: "free",
- name: "Free",
- annualPrice: 0,
- monthlyPrice: 0,
- period: "forever",
- desc: "Everything you need, no account required.",
- trial: null as string | null,
- noCreditCard: false,
- comingSoon: false,
- features: [
- "All core SSH features",
- "SFTP with drag & drop",
- "Docker & serial console",
- "Gist E2EE sync (free)",
- "Plugin system",
- "Custom themes",
- "Local terminal",
- "Port forwarding",
- "Audit logs",
- "Snippets & command palette",
- "Import / Export (no lock-in)",
- ],
- },
- {
- id: "pro",
- name: "Pro",
- annualPrice: 7,
- monthlyPrice: 9,
- period: "/ month",
- savings: "Save 22% with annual billing",
- desc: "Real-time sync and unlimited vaults for power users.",
- trial: "14-day free trial" as string | null,
- noCreditCard: true,
- comingSoon: false,
- features: [
- "Everything in Free",
- "Real-time cloud sync (CRDTs)",
- "Sub-second updates via SSE",
- "Unlimited private vaults",
- "Real-time collaboration — 1 session · 1 participant",
- ],
- },
- {
- id: "teams",
- name: "Teams",
- annualPrice: 15,
- monthlyPrice: 18,
- period: "/ user / month",
- savings: "Save 17% with annual billing",
- desc: "Shared vaults, live terminals, and access control for teams (3-user minimum).",
- trial: "14-day free trial" as string | null,
- noCreditCard: false,
- comingSoon: false,
- features: [
- "Everything in Pro",
- "Team vaults & invites",
- "Real-time collaboration — 5 sessions · 10 participants each",
- "Built-in roles (Owner, Manager, Editor, Member)",
- "Team audit logs",
- ],
- },
- {
- id: "business",
- name: "Business",
- annualPrice: 25,
- monthlyPrice: 30,
- period: "/ user / month",
- savings: "Save 17% with annual billing" as string | null,
- desc: "Commercial license, advanced collaboration, and dedicated support for organizations (3-user minimum).",
- trial: null as string | null,
- noCreditCard: false,
- comingSoon: false,
- features: [
- "Everything in Teams",
- "Real-time collaboration — 20 sessions · 50 participants each",
- "Custom roles & granular permissions",
- "Commercial license exception",
- "Priority SLA support",
- "Custom contracts",
- ],
- },
-];
+const allPlans = PLANS.map((plan) => ({
+ ...plan,
+ trial: plan.trial ? trialLabel(plan.trial) : null,
+ noCreditCard: plan.trial ? !plan.trial.creditCardRequired : false,
+}));
export default function AccountPage() {
const router = useRouter();
@@ -648,10 +564,9 @@ function PlanCard({
: billingPeriod === "annual" ? "billed annually"
: "billed monthly";
- const currentOrder = PLAN_ORDER[activePlanId] ?? 0;
- const planOrder = PLAN_ORDER[plan.id] ?? 0;
+ const currentOrder = planOrder(activePlanId);
const isActive = plan.id === activePlanId;
- const isUpgrade = planOrder > currentOrder;
+ const isUpgrade = planOrder(plan.id) > currentOrder;
const loading = checkoutLoading || portalLoading;
const renewalDate = formatBillingDate(renewsAt);
@@ -764,13 +679,7 @@ function PlanCard({
)}
{/* CTA */}
- {plan.comingSoon ? (
-
- Coming soon
-
- ) : isActive ? (
+ {isActive ? (
{onTrial && plan.id === "pro" ? (
<>
diff --git a/portal/next.config.ts b/portal/next.config.ts
index 20e8ca0..0d3eb1d 100644
--- a/portal/next.config.ts
+++ b/portal/next.config.ts
@@ -1,8 +1,13 @@
import type { NextConfig } from "next";
+import path from "node:path";
const nextConfig: NextConfig = {
transpilePackages: ["@voltius/crypto-wasm"],
- turbopack: {},
+ // See landing/next.config.ts: the shared plan catalogue sits outside this app.
+ turbopack: {
+ root: path.join(__dirname, ".."),
+ resolveAlias: { "@shared": path.join(__dirname, "..", "shared") },
+ },
webpack(config) {
config.experiments = { ...config.experiments, asyncWebAssembly: true };
return config;
diff --git a/portal/pnpm-workspace.yaml b/portal/pnpm-workspace.yaml
index a2a1883..dd1b4fd 100644
--- a/portal/pnpm-workspace.yaml
+++ b/portal/pnpm-workspace.yaml
@@ -1,3 +1,3 @@
allowBuilds:
- sharp: set this to true or false
- unrs-resolver: set this to true or false
+ sharp: true
+ unrs-resolver: true
diff --git a/portal/tsconfig.json b/portal/tsconfig.json
index e7ff3a2..ca3792a 100644
--- a/portal/tsconfig.json
+++ b/portal/tsconfig.json
@@ -25,11 +25,15 @@
"paths": {
"@/*": [
"./*"
+ ],
+ "@shared/*": [
+ "../shared/*"
]
}
},
"include": [
"next-env.d.ts",
+ "../shared/**/*.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
diff --git a/shared/plans.ts b/shared/plans.ts
new file mode 100644
index 0000000..c7278cc
--- /dev/null
+++ b/shared/plans.ts
@@ -0,0 +1,143 @@
+/**
+ * The plan catalogue both web apps render from.
+ *
+ * Prices are per seat per month in USD; annual plans bill twelve times the
+ * annual rate. The server owns enforcement — seat floors and LemonSqueezy
+ * variant ids — so nothing here is authoritative for billing, only for display.
+ */
+
+export type PlanId = "free" | "pro" | "teams" | "business";
+
+export interface PlanTrial {
+ days: number;
+ /** LemonSqueezy trials take a card; the Pro trial is granted at registration and does not. */
+ creditCardRequired: boolean;
+}
+
+export interface Plan {
+ id: PlanId;
+ name: string;
+ annualPrice: number;
+ monthlyPrice: number;
+ period: string;
+ savings: string | null;
+ desc: string;
+ trial: PlanTrial | null;
+ perSeat: boolean;
+ features: string[];
+}
+
+/** Mirrors MIN_SEATS in the server's billing handler, which returns 422 below it. */
+export const MIN_SEATS = 3;
+
+export const PLANS: Plan[] = [
+ {
+ id: "free",
+ name: "Free",
+ annualPrice: 0,
+ monthlyPrice: 0,
+ period: "forever",
+ savings: null,
+ desc: "Everything you need, no account required.",
+ trial: null,
+ perSeat: false,
+ features: [
+ "All core SSH features",
+ "SFTP with drag & drop",
+ "Docker & serial console",
+ "Gist E2EE sync (free)",
+ "Plugin system",
+ "Custom themes",
+ "Local terminal",
+ "Persistent sessions & workspace restore",
+ "Port forwarding",
+ "Audit logs",
+ "Snippets & command palette",
+ "Import / Export (no lock-in)",
+ ],
+ },
+ {
+ id: "pro",
+ name: "Pro",
+ annualPrice: 7,
+ monthlyPrice: 9,
+ period: "/ month",
+ savings: "Save 22% with annual billing",
+ desc: "Real-time sync and unlimited vaults for power users.",
+ trial: { days: 14, creditCardRequired: false },
+ perSeat: false,
+ features: [
+ "Everything in Free",
+ "Real-time cloud sync (CRDTs)",
+ "Sub-second updates via SSE",
+ "Cross-device sessions — one live terminal, shared on all your devices",
+ "Unlimited private vaults",
+ "Real-time collaboration — 1 session · 1 participant",
+ ],
+ },
+ {
+ id: "teams",
+ name: "Teams",
+ annualPrice: 15,
+ monthlyPrice: 18,
+ period: "/ user / month",
+ savings: "Save 17% with annual billing",
+ desc: "Shared vaults, live terminals, and access control for teams (3-user minimum).",
+ trial: { days: 14, creditCardRequired: true },
+ perSeat: true,
+ features: [
+ "Everything in Pro",
+ "Team vaults & invites",
+ "Real-time collaboration — 5 sessions · 10 participants each",
+ "Built-in roles (Owner, Manager, Editor, Member)",
+ "Team audit logs",
+ ],
+ },
+ {
+ id: "business",
+ name: "Business",
+ annualPrice: 25,
+ monthlyPrice: 30,
+ period: "/ user / month",
+ savings: "Save 17% with annual billing",
+ desc: "Commercial license, advanced collaboration, and dedicated support for organizations (3-user minimum).",
+ trial: null,
+ perSeat: true,
+ features: [
+ "Everything in Teams",
+ "Real-time collaboration — 20 sessions · 50 participants each",
+ "Custom roles & granular permissions",
+ "Commercial license",
+ "Priority support",
+ "Custom contracts",
+ ],
+ },
+];
+
+export const PLAN_ORDER: Record
= { free: 0, pro: 1, teams: 2, business: 3 };
+
+export const PER_SEAT_PLAN_IDS: PlanId[] = PLANS.filter((p) => p.perSeat).map((p) => p.id);
+
+/** Order lookup that tolerates an arbitrary tier string from the server. */
+export function planOrder(id: string): number {
+ return PLAN_ORDER[id as PlanId] ?? 0;
+}
+
+export function planById(id: string): Plan | undefined {
+ return PLANS.find((p) => p.id === id);
+}
+
+/** "$15" — whole dollars, which every current price is. */
+export function priceLabel(amount: number): string {
+ return `$${amount}`;
+}
+
+/** "14-day free trial" */
+export function trialLabel(trial: PlanTrial): string {
+ return `${trial.days}-day free trial`;
+}
+
+/** "credit card required" / "no credit card required" */
+export function trialCardLabel(trial: PlanTrial): string {
+ return trial.creditCardRequired ? "credit card required" : "no credit card required";
+}