From 9f6a4ee43fb3a320850e0c3ebd7784d4772133b3 Mon Sep 17 00:00:00 2001
From: kipavy
Date: Sat, 12 Sep 2026 22:51:13 +0000
Subject: [PATCH 1/2] refactor(plans): render every surface from one plan
catalogue
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plan data was maintained by hand in three places: the pricing cards, the
portal's own list, and the billing terms as prose. They drifted three times in
a single day — the portal priced Business at $30 instead of $25, showed Teams
as "Coming soon" while the site sold it, and the terms promised a Teams trial
that did not exist yet.
shared/plans.ts is now the single source for prices, savings, descriptions,
features, trials and which plans bill per seat. All three surfaces derive from
it, keeping only what is genuinely local: the landing cards keep their
highlight flag and CTA targets, the portal keeps its checkout behaviour.
Two mechanical details, both found by building rather than by type-checking:
Turbopack does not resolve tsconfig paths pointing outside the app root, so
each app widens turbopack.root to the repository and spells out the @shared
alias. `tsc --noEmit` passed against the shared module while `next build`
failed with "Can't resolve '@shared/plans'"; only a real build proves this.
Vitest does not read tsconfig paths either and needs the same alias.
The portal had no CI at all, which is why its copy drifted unnoticed — Vercel
deploy checks do not type-check. It gains a workflow running tsc, landing gains
a tsc step alongside its tests, and both now trigger on changes to shared/.
Six tests pin the invariants that matter commercially: annual never exceeds
monthly, per-seat plans match the server's list, Business cannot undercut Teams
at the shared seat floor, only Pro's trial skips the credit card, and Business
carries no trial at all.
Feature lists are unified to the fuller landing copy, so the portal now also
shows cross-device sessions on Pro and workspace restore on Free.
---
.github/workflows/landing.yml | 5 +-
.github/workflows/portal.yml | 27 ++++++
landing/app/components/Pricing.tsx | 115 +++++------------------
landing/app/plans.test.ts | 46 ++++++++++
landing/app/terms/page.tsx | 33 ++++---
landing/next.config.ts | 8 ++
landing/tsconfig.json | 4 +-
landing/vitest.config.mts | 9 ++
portal/app/account/page.tsx | 109 ++--------------------
portal/next.config.ts | 7 +-
portal/tsconfig.json | 4 +
shared/plans.ts | 143 +++++++++++++++++++++++++++++
12 files changed, 297 insertions(+), 213 deletions(-)
create mode 100644 .github/workflows/portal.yml
create mode 100644 landing/app/plans.test.ts
create mode 100644 shared/plans.ts
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/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";
+}
From 4b5b70793481340be14176e928ee96ad717e09bd Mon Sep 17 00:00:00 2001
From: kipavy
Date: Sat, 12 Sep 2026 22:53:44 +0000
Subject: [PATCH 2/2] fix(portal): answer the pnpm allowBuilds prompts
portal/pnpm-workspace.yaml still carried the literal placeholder "set this to
true or false" for sharp and unrs-resolver. pnpm 11 treats that as unresolved
and fails the install with ERR_PNPM_IGNORED_BUILDS, so the new portal CI job
could not get past `pnpm install --frozen-lockfile`. The same error silently
aborts `pnpm exec` locally, which is how it stayed invisible: Vercel tolerates
it, and nothing else in CI ever installed this package.
landing/pnpm-workspace.yaml already answers both with true; this matches it.
---
portal/pnpm-workspace.yaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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