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}

)}