Compare commits

...
3 Commits
Author SHA1 Message Date
mrhid6 20e57d19c7 feat: Updated pricing page
Server Deploy / deploy (push) Successful in 2m46s
2026-07-27 22:08:17 +01:00
mrhid6 d5c8d0d0f2 fix: Fixed site mobile width 2026-07-27 21:58:20 +01:00
mrhid6 2869e63d0a feat: Move manage billing button to billing page 2026-07-27 21:47:50 +01:00
8 changed files with 230 additions and 193 deletions
+7 -3
View File
@@ -5,6 +5,7 @@ import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import { formatDate } from "@/lib/format";
export default function BillingPage() {
@@ -51,9 +52,12 @@ export default function BillingPage() {
</RailCard>
<RailCard title="Need a change?">
<p className="text-[0.82rem] text-ink-2">
To change a card, download an invoice or cancel, email support and
we will send you a billing link. Self-service billing arrives with
card payments.
Change a card, download an invoice or cancel from the billing
portal. It covers every subscription on this account.
</p>
<ManageBillingButton />
<p className="text-[0.82rem] text-ink-2">
Anything else, email support.
</p>
<a
href="mailto:support@hostxtra.co.uk"
-3
View File
@@ -5,7 +5,6 @@ import clsx from "clsx";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api, type Instance, type License } from "@/lib/api";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
import { StatePill } from "./StatePill";
import { Button, LinkButton } from "./Button";
@@ -195,8 +194,6 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
{renew.isPending ? "Renewing…" : "Renew"}
</Button>
)}
{instance.tier && instance.tier !== "free" && <ManageBillingButton />}
</div>
</div>
</article>
-22
View File
@@ -1,9 +1,4 @@
services:
# Adds to the base `server` service defined in docker-compose.yml —
# only the cloud deployment reaps abandoned Free instances.
server:
environment:
FREE_INSTANCE_REAP_AFTER: "336h"
site:
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
restart: unless-stopped
@@ -18,7 +13,6 @@ services:
- 8082:8082
environment:
PORT: "8082"
MONGO_URI: ${MONGO_URI:-}
PUBLIC_URL: ${PUBLIC_URL:-}
SITE_ORIGIN: ${SITE_ORIGIN:-}
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
@@ -42,12 +36,6 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY:-}
PUBLIC_URL: ${ADMIN_PUBLIC_URL:-}
# Comma-separated, and it needs BOTH browser origins that call admin:
# vantage-hq (the console) and the marketing site, whose /start form
# posts account signups here. An origin missing from this list is not
# a 403 — admin omits the CORS header entirely and the browser blocks
# the preflight, so the form fails with nothing in admin's logs.
# ADMIN_ORIGIN="https://vantage-hq.hostxtra.co.uk,https://vantage.hostxtra.co.uk"
ADMIN_ORIGIN: ${ADMIN_ORIGIN:-}
TRUST_PROXY: ${TRUST_PROXY:-true}
SMTP_HOST: ${SMTP_HOST:-}
@@ -57,23 +45,13 @@ services:
SMTP_FROM: ${SMTP_FROM:-}
APP_LOGIN_URL: ${APP_LOGIN_URL:-}
FREE_INSTANCE_REAP_AFTER: "336h"
# Paddle billing. PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET are
# boot-REQUIRED — an unverified webhook endpoint is one anyone can
# issue licences through. PADDLE_ENV selects which catalogue price
# IDs are served (sandbox|production).
PADDLE_ENV: ${PADDLE_ENV:-sandbox}
PADDLE_API_KEY: ${PADDLE_API_KEY:-}
PADDLE_WEBHOOK_SECRET: ${PADDLE_WEBHOOK_SECRET:-}
# The staff and customer console, served at vantage-hq.hostxtra.co.uk.
# ADMIN_API_URL is baked into the image at build time, not read here, so
# changing it needs a rebuild rather than a restart — and it must appear in
# admin's ADMIN_ORIGIN above or the browser blocks every request.
adminsite:
image: gitea.hostxtra.co.uk/mrhid6/vantage/adminsite:latest
restart: unless-stopped
ports:
# 3000 is web, 3003 is the marketing site; this takes 3004.
- 3004:3000
depends_on:
- admin
+60 -4
View File
@@ -217,10 +217,13 @@ code {
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
}
/* minmax(0, …) rather than a bare 1fr: a plain fr track is min-content floored,
so one unshrinkable child (a form control, a long mono string) widens the
column past the rail and scrolls the whole page sideways. */
@media (max-width: 840px) {
.split,
.split--even {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
}
}
@@ -293,8 +296,7 @@ code {
}
@media (max-width: 900px) {
.nav__links,
.nav__cta {
.nav__links {
display: none;
}
@@ -314,7 +316,10 @@ code {
padding-block: 0.5rem 1rem;
}
.drawer a {
/* :not(.btn) matters. Two class selectors outrank .btn--solid's one, so without
it the drawer's call to action takes --ink-2 on the accent fill and the row
rules meant for the links. */
.drawer a:not(.btn) {
text-decoration: none;
color: var(--ink-2);
padding: 0.6rem 0;
@@ -852,6 +857,21 @@ code {
font-size: 0.93rem;
}
/* The key column costs a fixed 8ch or more, which on a phone leaves the prose
in a strip barely wide enough to read. Below this width the key becomes a
label above its own paragraph instead. */
@media (max-width: 560px) {
.spec {
grid-template-columns: 1fr;
gap: 0.3rem;
}
.spec__k {
padding-top: 0;
min-width: 0;
}
}
/* ---------- pricing ---------- */
.plans {
@@ -911,6 +931,15 @@ code {
min-height: 3.2em;
}
/* The term line under the headline price. A metered plan has a second and third
number — the annual equivalent and the per-server rate — and they are
qualifications of the headline rather than features, so they sit with it. */
.plan__t {
font-size: 0.82rem;
color: var(--ink-3);
margin-top: -0.5rem;
}
.plan ul {
list-style: none;
margin: 0;
@@ -945,6 +974,28 @@ code {
margin-top: 2.4rem;
}
/* The comparison table is wider than a phone by design, and a table that scrolls
with no scrollbar drawn looks like a table with two columns. Say so. */
.scroll__hint {
display: none;
font-family: var(--mono);
font-size: var(--s--1);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-3);
margin-top: 2.4rem;
}
@media (max-width: 700px) {
.scroll__hint {
display: block;
}
.scroll__hint + .scroll {
margin-top: 0.7rem;
}
}
table.cmp {
width: 100%;
border-collapse: collapse;
@@ -1015,6 +1066,10 @@ table.cmp {
font-size: 0.79rem;
}
/* min-width: 0 is load-bearing on mobile. A bare <input> carries an intrinsic
width of roughly 20 characters, and width: 100% does not lower it, so the
control sets the min-content width of every ancestor and drags the page wider
than the viewport. */
.field input,
.field select,
.field textarea {
@@ -1026,6 +1081,7 @@ table.cmp {
border-radius: 3px;
padding: 0.6rem 0.72rem;
width: 100%;
min-width: 0;
}
.field textarea {
+163 -47
View File
@@ -4,21 +4,27 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pricing",
description:
"Priced per managed server. People, keys, workflows and secrets are free. Free for 3 servers, £4 per server per month, or £290 a year self-hosted.",
"Three tiers, cloud or self-hosted, priced per managed server. Free for 3 servers. Professional from £19 a month, Enterprise from £49 a month, £5 per extra server.",
};
/* Prices and allowances are the shipped catalogue, not marketing rounding:
* allowances come from shared/license/plans.go and the amounts from the Paddle
* catalogue. Annual is ten times monthly everywhere, which is the "two months
* free" the portal's own term picker states. */
const COMPARISON: [string, string, string, string][] = [
["Managed servers", "3", "Unlimited", "Unlimited"],
["Members", "1", "Unlimited", "Unlimited"],
["Managed servers", "3", "3, then £5 each", "10, then £5 each"],
["People", "Unlimited", "Unlimited", "Unlimited"],
["SSH key assignment", "Yes", "Yes", "Yes"],
["Workflows and step library", "Yes", "Yes", "Yes"],
["Monitors", "3", "Unlimited", "Unlimited"],
["Secrets vault", "No", "Yes", "Yes"],
["Browser console", "No", "Yes", "Yes"],
["OIDC single sign-on", "No", "Yes", "Yes"],
["Audit history", "30 days", "Forever", "Forever"],
["Runs on your hardware", "No", "No", "Yes"],
["Support", "Community", "Next business day", "Priority"],
["Secret groups", "1", "Unlimited", "Unlimited"],
["Notification channels", "1", "Unlimited", "Unlimited"],
["Audit history", "30 days", "365 days", "Forever"],
["Browser console", "Not Available", "Add-on", "Add-on"],
["Single sign-on", "Not Available", "Add-on", "Add-on"],
["Support", "Community", "Email, 24/5", "Email and phone, 24/7"],
["Cloud term", "Annual, £0", "Monthly or annual", "Monthly or annual"],
["Self-hosted term", "Annual, £0", "Annual", "Annual"],
];
export default function PricingPage() {
@@ -32,30 +38,36 @@ export default function PricingPage() {
maxWidth: "17ch",
}}
>
Per managed server. Nothing else counts.
Pick a tier, then pay per server.
</h1>
<p className="lede">
People are free. Keys, workflows, monitors and secrets are free.
You pay for servers running an agent, because that is the only
number that grows with you.
You pay for a tier and for the servers running an agent, because
that is the only number that grows with you. Every tier runs
either in our cloud or on your own hardware, for the same money.
</p>
<div className="plans">
<div className="plan">
<div>
<div className="plan__n">Solo</div>
<div className="plan__n">Free</div>
<p className="plan__d">
A homelab, a couple of VPSes, and the keys on your
own laptop.
</p>
</div>
<div className="plan__p">
£0 <span>forever</span>
£0 <span>3 servers</span>
</div>
<p className="plan__t">
Cloud or self-hosted. Renewed once a year from the
portal, one free instance per deployment.
</p>
<ul>
<li>Up to 3 servers</li>
<li>Keys, workflows and monitors</li>
<li>One member, one instance</li>
<li>3 servers and 3 monitors</li>
<li>Keys, workflows and the step library</li>
<li>One secret group, one alert channel</li>
<li>30 days of audit history</li>
<li>Community support</li>
</ul>
<Link className="btn btn--line" href="/start">
@@ -72,62 +84,107 @@ export default function PricingPage() {
</p>
</div>
<div className="plan__p">
£4 <span>/ server / month</span>
£19 <span>/ month, 3 servers</span>
</div>
<p className="plan__t">
£5 per extra server. £190 a year saves two months.
Self-hosted is £190 a year.
</p>
<ul>
<li>Unlimited servers and members</li>
<li>Owner, admin and member roles</li>
<li>OIDC single sign-on</li>
<li>Browser console and secrets vault</li>
<li>Full audit history</li>
<li>Email support, next business day</li>
<li>3 servers included, add as many as you like</li>
<li>Unlimited monitors, secrets and channels</li>
<li>365 days of audit history</li>
<li>Browser console and single sign-on as add-ons</li>
<li>Email support, 24/5</li>
</ul>
<Link className="btn btn--solid" href="/start">
Start 14-day trial
Get started
</Link>
</div>
<div className="plan">
<div>
<div className="plan__n">Self-hosted</div>
<div className="plan__n">Enterprise</div>
<p className="plan__d">
The whole stack on your metal, behind your own
boundary.
A fleet, a compliance question, and someone who
wants a phone number.
</p>
</div>
<div className="plan__p">
£290 <span>/ year, per install</span>
£49 <span>/ month, 10 servers</span>
</div>
<p className="plan__t">
£5 per extra server. £490 a year saves two months.
Self-hosted is £490 a year.
</p>
<ul>
<li>Everything in Professional, no server cap</li>
<li>Your MongoDB, Redis and certificates</li>
<li>Mirror agent releases internally</li>
<li>Priority support and upgrade notes</li>
<li>10 servers included, add as many as you like</li>
<li>Everything in Professional</li>
<li>Audit history kept forever</li>
<li>Email and phone support, 24/7</li>
<li>Buy it yourself, no sales call</li>
</ul>
<Link className="btn btn--line" href="/contact">
Talk to us
<Link className="btn btn--line" href="/start">
Get started
</Link>
</div>
</div>
<div className="scroll">
<div
className="specs"
style={{ marginTop: "2.6rem", maxWidth: "66ch" }}
>
<span className="tag">
Add-ons, on Professional and Enterprise
</span>
<div className="spec">
<span className="spec__k">£5 / MO</span>
<div>
<h3>Browser console</h3>
<p>
SSH, RDP and VNC sessions in the browser, audited
like everything else. £50 a year.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">£9 / MO</span>
<div>
<h3>Single sign-on</h3>
<p>
Connect your own OIDC provider to an instance. £90 a
year.
</p>
</div>
</div>
</div>
<p className="scroll__hint">
Scroll the table sideways for all three tiers
</p>
<div
className="scroll"
tabIndex={0}
role="region"
aria-label="Plan comparison"
>
<table className="cmp">
<thead>
<tr>
<th>Capability</th>
<th>Solo</th>
<th>Free</th>
<th>Professional</th>
<th>Self-hosted</th>
<th>Enterprise</th>
</tr>
</thead>
<tbody>
{COMPARISON.map(
([capability, solo, professional, selfHosted]) => (
([capability, free, professional, enterprise]) => (
<tr key={capability}>
<td>{capability}</td>
<td>{solo}</td>
<td>{free}</td>
<td>{professional}</td>
<td>{selfHosted}</td>
<td>{enterprise}</td>
</tr>
),
)}
@@ -143,19 +200,69 @@ export default function PricingPage() {
<div>
<h3>What counts as a server</h3>
<p>
One running agent, one server. Remove a box and
it stops billing that day.
One running agent, one server. You choose the
cap rather than being metered on what you ran,
so the invoice is the same number you set.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">ADDING</span>
<div>
<h3>Going up mid-term</h3>
<p>
Raise the server count and the new cap applies
straight away, prorated for the rest of the
period. Your renewal date does not move.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">REDUCING</span>
<div>
<h3>Going down</h3>
<p>
A reduction takes effect at your next renewal.
You keep what you have already paid for until
then, and we show you the date it changes.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">LIMITS</span>
<div>
<h3>Going over on Solo</h3>
<h3>Reaching the cap</h3>
<p>
Nothing is deleted. A fourth agent registers and
heartbeats, but stops syncing keys until you
upgrade or remove a server.
Nothing is deleted. An agent beyond the cap
still registers and heartbeats, but stops
syncing keys until you raise the cap or remove a
server.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">CANCELLING</span>
<div>
<h3>What happens when you stop paying</h3>
<p>
Your licence runs to the end of the period you
paid for, then the instance goes read-only.
Monitors keep checking, alerts keep firing and
agents keep their keys you just cannot change
anything until you renew.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">SELF-HOSTED</span>
<div>
<h3>Why self-hosted is annual only</h3>
<p>
A self-hosted licence is a signed file your
install checks offline, with no phone home. That
means we cannot withdraw one early, so the term
is the limit and we would rather bound it at a
year than sell it twelve times over.
</p>
</div>
</div>
@@ -173,6 +280,15 @@ export default function PricingPage() {
</div>
</div>
</div>
<p className="prose" style={{ marginTop: "2.4rem" }}>
Prices exclude VAT, which is calculated at checkout. Billing is
handled by Paddle as merchant of record.{" "}
<Link href="/contact" style={{ color: "var(--accent)" }}>
Ask us anything
</Link>{" "}
before you buy.
</p>
</section>
);
}
-4
View File
@@ -48,10 +48,6 @@ export function Nav() {
>
{open ? "Close" : "Menu"}
</button>
<Link className="btn btn--solid btn--sm nav__cta" href="/start">
Get started
</Link>
</div>
</header>
-18
View File
@@ -13,32 +13,14 @@ import (
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/sitesvc/internal/api"
"github.com/mrhid6/vantage/sitesvc/internal/mail"
"github.com/mrhid6/vantage/sitesvc/internal/store"
)
func main() {
godotenv.Load()
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017/vantage")
addr := ":" + getEnv("PORT", "8082")
if err := store.Connect(mongoURI); err != nil {
log.Fatalf("failed to connect to MongoDB: %v", err)
}
log.Printf("connected to MongoDB (database %q)", store.DatabaseName())
guardCtx, guardCancel := context.WithTimeout(context.Background(), 10*time.Second)
guardErr := store.RequireMigratedDatabase(guardCtx)
guardCancel()
if guardErr != nil {
log.Fatalf("database check failed: %v", guardErr)
}
if err := store.EnsureIndexes(); err != nil {
log.Fatalf("failed to ensure indexes: %v", err)
}
mailCfg := mail.FromEnv()
if mailCfg.Enabled() {
log.Printf("smtp enabled (%s) contact form delivers to %s", mailCfg.Host, mailCfg.To)
-92
View File
@@ -1,92 +0,0 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/mrhid6/vantage/shared/indexes"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/x/mongo/driver/connstring"
)
var database *mongo.Database
func Connect(uri string) error {
cs, err := connstring.ParseAndValidate(uri)
if err != nil {
return fmt.Errorf("parse MONGO_URI: %w", err)
}
if cs.Database == "" {
return errors.New("MONGO_URI must name a database, e.g. mongodb://host:27017/vantage")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
return err
}
if err := client.Ping(ctx, nil); err != nil {
return err
}
database = client.Database(cs.Database)
return nil
}
func DatabaseName() string {
if database == nil {
return ""
}
return database.Name()
}
func col(name string) *mongo.Collection { return database.Collection(name) }
func EnsureIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// users.email and instances.slug are declared in the shared module so both
// services agree. Re-declaring at boot means sitesvc does not depend on the
// control plane having started first.
return indexes.EnsureCoreIndexes(ctx, database)
}
// RequireMigratedDatabase refuses to start against a control-plane database
// that has not run migration 0004.
//
// Provisioning into `orgs` while the control plane reads `instances` would
// create tenants nobody can see — the exact skew failure the shared module was
// built to prevent. Failing to start is strictly better.
func RequireMigratedDatabase(ctx context.Context) error {
names, err := database.ListCollectionNames(ctx, bson.M{})
if err != nil {
return fmt.Errorf("list collections: %w", err)
}
var hasInstances, hasOrgs bool
for _, n := range names {
switch n {
case "instances":
hasInstances = true
case "orgs":
hasOrgs = true
}
}
// A brand-new database has neither. That is fine — whichever service starts
// first creates `instances`.
if !hasInstances && !hasOrgs {
return nil
}
if !hasInstances {
return errors.New("instances collection not found; deploy the control plane first")
}
return nil
}