Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/landing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -26,3 +26,4 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test
- run: pnpm exec tsc --noEmit
27 changes: 27 additions & 0 deletions .github/workflows/portal.yml
Original file line number Diff line number Diff line change
@@ -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
115 changes: 23 additions & 92 deletions landing/app/components/Pricing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlanId, { highlight: boolean; cta: string; ctaHref: string }> = {
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);
Expand All @@ -111,13 +44,13 @@ function PricingCard({ plan, index }: { plan: (typeof plans)[0]; index: number }
<span className="text-3xl font-bold text-white">{plan.price}</span>
{plan.period && <span className="text-sm text-zinc-500">{plan.period}</span>}
</div>
{"billingNote" in plan && plan.billingNote && (
{plan.billingNote && (
<p className="mt-1 text-xs text-zinc-500">{plan.billingNote}</p>
)}
{"monthlyOption" in plan && plan.monthlyOption && (
{plan.monthlyOption && (
<p className="mt-1 text-xs text-zinc-500">{plan.monthlyOption}</p>
)}
{"savings" in plan && plan.savings && (
{plan.savings && (
<p className="mt-1 text-xs font-medium text-cyan-400">{plan.savings}</p>
)}
<p className="mt-2 text-xs text-zinc-500 leading-relaxed">{plan.desc}</p>
Expand All @@ -133,10 +66,8 @@ function PricingCard({ plan, index }: { plan: (typeof plans)[0]; index: number }
>
{plan.cta}
</a>
{"trial" in plan && (
<p className="-mt-3 text-center text-xs text-zinc-500">
{plan.trial as string} • {"trialNote" in plan ? plan.trialNote as string : "no credit card required"}
</p>
{plan.trialNote && (
<p className="-mt-3 text-center text-xs text-zinc-500">{plan.trialNote}</p>
)}

<ul className="flex flex-col gap-2">
Expand Down
46 changes: 46 additions & 0 deletions landing/app/plans.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
33 changes: 16 additions & 17 deletions landing/app/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -56,23 +65,13 @@ export default function TermsPage() {
date. No refunds are issued for partial periods, except as required by applicable law.
</p>
<ul className="list-disc pl-6 mt-3 space-y-1 text-sm">
<li>
<strong className="text-white">Free</strong> — $0, no account required.
</li>
<li>
<strong className="text-white">Pro</strong> — $7/month (billed annually) or
$9/month (billed monthly). Includes a 14-day free trial, no credit card required.
</li>
<li>
<strong className="text-white">Teams</strong> — $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.
</li>
<li>
<strong className="text-white">Business</strong> — $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.
</li>
{PLANS.map((plan) => (
<li key={plan.id}>
<strong className="text-white">{plan.name}</strong> — {planPriceSentence(plan)}
{plan.trial && ` Includes a ${trialLabel(plan.trial)}, ${trialCardLabel(plan.trial)}.`}
{plan.id === "business" && " Contact us for custom contracts and self-hosted deployment."}
</li>
))}
</ul>
</Section>

Expand Down
8 changes: 8 additions & 0 deletions landing/next.config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion landing/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"@shared/*": ["../shared/*"]
}
},
"include": [
"next-env.d.ts",
"../shared/**/*.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
Expand Down
9 changes: 9 additions & 0 deletions landing/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
Loading
Loading