feat: Marketing site
Server Deploy / deploy (push) Successful in 5m51s

This commit is contained in:
2026-07-22 16:50:13 +01:00
parent 7a3b8cb700
commit 693d59a3e2
51 changed files with 10656 additions and 12 deletions
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { Honeypot } from "@/components/Honeypot";
import { submitContact, type SubmitResult } from "@/lib/submit";
export function ContactForm() {
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
const sending = result.state === "sending";
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setResult({ state: "sending" });
setResult(
await submitContact({
name: String(data.get("name") ?? ""),
email: String(data.get("email") ?? ""),
servers: String(data.get("servers") ?? ""),
topic: String(data.get("topic") ?? ""),
message: String(data.get("message") ?? ""),
website: String(data.get("website") ?? ""),
})
);
}
if (result.state === "sent") {
return (
<div role="status">
<h2 style={{ fontSize: "var(--s-1)" }}>Message sent.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
We reply within one business day. If it is urgent, email support@hostxtra.co.uk directly.
</p>
</div>
);
}
const fieldError = (name: string) => result.fields?.[name];
return (
<form className="form" onSubmit={onSubmit} noValidate>
<Honeypot />
<div className="field">
<label htmlFor="c-name">Your name</label>
<input id="c-name" name="name" type="text" autoComplete="name" required aria-describedby="c-name-err" />
{fieldError("name") && (
<small id="c-name-err" className="field__err">
{fieldError("name")}
</small>
)}
</div>
<div className="field">
<label htmlFor="c-email">Work email</label>
<input id="c-email" name="email" type="email" autoComplete="email" required aria-describedby="c-email-err" />
{fieldError("email") && (
<small id="c-email-err" className="field__err">
{fieldError("email")}
</small>
)}
</div>
<div className="field">
<label htmlFor="c-servers">Roughly how many servers?</label>
<select id="c-servers" name="servers" defaultValue="425">
<option>13</option>
<option>425</option>
<option>26100</option>
<option>More than 100</option>
</select>
</div>
<div className="field">
<label htmlFor="c-topic">What is this about?</label>
<select id="c-topic" name="topic" defaultValue="Evaluating Vantage">
<option>Evaluating Vantage</option>
<option>Self-hosted licensing</option>
<option>Migrating from something else</option>
<option>Security disclosure</option>
</select>
</div>
<div className="field">
<label htmlFor="c-message">What are you trying to solve?</label>
<textarea
id="c-message"
name="message"
required
placeholder="We inherit client servers and can never prove who still has access…"
aria-describedby="c-message-err"
/>
{fieldError("message") && (
<small id="c-message-err" className="field__err">
{fieldError("message")}
</small>
)}
</div>
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
{sending ? "Sending…" : "Send message"}
</button>
{result.state === "error" && result.message && (
<p className="field__err" role="alert">
{result.message}
</p>
)}
</form>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import { NAV_LINKS } from "@/components/nav-links";
export function Footer() {
return (
<footer className="foot">
<div className="rail foot__in">
<p>Vantage self-hosted fleet control for people who own their servers.</p>
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href}>
{link.label}
</Link>
))}
<Link href="/start">Create organisation</Link>
</div>
</footer>
);
}
+14
View File
@@ -0,0 +1,14 @@
/*
* A field no person ever sees or tabs into, but an automated form-filler will
* happily complete. The server treats any value here as a bot. Hidden with
* inline styles rather than a utility class so it stays hidden even if the
* stylesheet fails to load.
*/
export function Honeypot() {
return (
<div style={{ position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" }} aria-hidden="true">
<label htmlFor="website">Website</label>
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" defaultValue="" />
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
/*
* The hero's signature element: a fleet panel that plays one honest cycle of
* what the product actually does — a workflow runs three steps, a TLS monitor
* fails and opens an incident, a key revocation lands — then rests. It is a
* dramatisation, not live data, so nothing here talks to an API.
*/
type LogLine = { time: string; body: React.ReactNode };
type Beat = {
at: number;
line: LogLine;
effect?: "incident" | "runDone" | "revoked";
};
const BEATS: Beat[] = [
{ at: 600, line: { time: "14:22:02", body: "running · step 1/3 · pull image" } },
{ at: 1500, line: { time: "14:22:04", body: "running · step 2/3 · migrate database" } },
{
at: 2600,
line: { time: "14:22:07", body: <><span className="ok">ok</span> · migrate database · exit 0</> },
},
{
at: 3400,
line: { time: "14:22:08", body: "running · step 3/3 · restart service" },
effect: "incident",
},
{
at: 4300,
line: { time: "14:22:10", body: <><span className="er">monitor</span> · edge-gw-02 tls · connection refused</> },
},
{
at: 5200,
line: { time: "14:22:11", body: <><span className="ok">ok</span> · restart service · exit 0</> },
effect: "runDone",
},
{
at: 6000,
line: { time: "14:22:12", body: <>run finished · <span className="ok">success</span> · 3 steps · 1 server</> },
effect: "revoked",
},
];
const FIRST_LINE: LogLine = { time: "14:22:01", body: "queued · deploy-app · 1 server" };
const MAX_LINES = 7;
export function InstrumentPanel() {
const [lines, setLines] = useState<LogLine[]>([FIRST_LINE]);
const [incident, setIncident] = useState(false);
const [runActive, setRunActive] = useState(true);
const [revoked, setRevoked] = useState(false);
const [resting, setResting] = useState(false);
useEffect(() => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const apply = (effect: Beat["effect"]) => {
if (effect === "incident") setIncident(true);
if (effect === "runDone") setRunActive(false);
if (effect === "revoked") setRevoked(true);
};
// Reduced motion gets the finished state immediately rather than no state.
if (reduced) {
setLines([FIRST_LINE, ...BEATS.map((b) => b.line)].slice(-MAX_LINES));
BEATS.forEach((b) => apply(b.effect));
setResting(true);
return;
}
const timers = BEATS.map((beat) =>
window.setTimeout(() => {
setLines((prev) => [...prev, beat.line].slice(-MAX_LINES));
apply(beat.effect);
}, beat.at)
);
timers.push(window.setTimeout(() => setResting(true), 6600));
return () => timers.forEach(window.clearTimeout);
}, []);
return (
<div className="instrument">
<div className="rail">
<div className="instrument__bar">
<span>
<b>northgate</b> · fleet
</span>
<span>12 servers</span>
<span>{incident ? "10 up" : "11 up"}</span>
<span>{incident ? "2 down" : "1 down"}</span>
<span>3 monitors</span>
<span>{runActive ? "1 run active" : "no runs active"}</span>
<span className="instrument__clock">14:22:12 UTC</span>
</div>
<div className="panes">
<section className="pane" aria-label="Fleet status">
<h2 className="pane__h">
Fleet <span>{revoked ? "key revoked · 1 server updated" : "agents polling"}</span>
</h2>
<Row host="proxmox-node-1" sub="4 keys · 12% cpu" state="up" label="Active" />
<Row host="db-primary" sub="3 keys · 61% cpu" state="up" label="Active" />
<Row
host="edge-gw-02"
sub={incident ? "2 keys · tls refused" : "2 keys · tls 41d"}
state={incident ? "down" : "up"}
label={incident ? "Incident" : "Active"}
/>
<Row host="win-build-01" sub="agent 1.4.1 · update ready" state="pend" label="Pending" />
<Row
host="app-worker-03"
sub={revoked ? "4 keys · 1 revoked" : "5 keys · idle"}
state="up"
label="Active"
/>
</section>
<section className="pane" aria-label="Workflow run">
<h2 className="pane__h">
Run <span>run_8f31c2</span>
</h2>
<div className="stream" aria-live="polite">
{lines.map((line, i) => (
<div key={`${line.time}-${i}`}>
<span className="t">{line.time}</span> {line.body}
</div>
))}
{resting && (
<div>
<span className="caret">_</span>
</div>
)}
</div>
</section>
</div>
</div>
</div>
);
}
function Row({
host,
sub,
state,
label,
}: {
host: string;
sub: string;
state: "up" | "down" | "pend";
label: string;
}) {
return (
<div className="frow">
<span className={state === "up" ? "dot" : `dot dot--${state}`} />
<span className="frow__host">{host}</span>
<span className="frow__sub">{sub}</span>
<span className={`chip chip--${state}`}>{label}</span>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
// Traced from web/public/images/vantage_logo.svg. The fill is currentColor so
// the mark follows the --logo token in both themes.
export function Logo({ className }: { className?: string }) {
return (
<svg viewBox="246 207 533 610" className={className} aria-hidden="true" focusable="false">
<g transform="translate(0,1024) scale(0.1,-0.1)" fill="currentColor" stroke="none">
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187 -702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559 1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459 272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84 -1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297 -331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0 -1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204 -447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256 -13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333 805 476 1149 677 1156 677 4 0 56 -29 116 -64z M4520 6930 c-157 -93 -432 -256 -612 -362 -180 -105 -325 -195 -322 -199 2 -4 29 -21 59 -38 l55 -31 177 0 178 0 235 140 c129 77 299 177 377 222 l143 83 0 178 c0 97 -1 177 -2 177 -2 -1 -131 -77 -288 -170z M5430 6927 c0 -128 3 -177 13 -185 6 -5 77 -48 157 -95 80 -46 245 -143 366 -216 l221 -131 177 0 176 0 55 31 c30 17 57 35 60 39 3 5 -32 30 -77 56 -79 45 -673 396 -988 583 -80 47 -148 88 -152 89 -5 2 -8 -75 -8 -171z M5050 6562 c-424 -251 -560 -334 -560 -342 0 -5 33 -79 73 -165 52 -112 76 -154 87 -152 8 2 118 65 243 140 l228 135 77 -45 c251 -150 397 -233 404 -231 7 3 122 238 148 304 8 20 -15 36 -303 205 -171 101 -316 185 -321 187 -6 1 -40 -15 -76 -36z M3760 6109 c0 -6 187 -396 417 -867 229 -471 483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160 160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73 -158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838 l-182 390 -287 3 c-202 2 -286 -1 -286 -9z M3270 5136 c0 -382 -3 -701 -6 -710 -4 -9 -1 -16 6 -16 9 0 136 72 278 157 l22 13 0 539 0 539 -142 82 c-79 46 -146 85 -150 87 -5 2 -8 -308 -8 -691z M6880 5779 c-47 -28 -113 -66 -147 -86 l-63 -35 0 -537 0 -538 142 -84 c78 -46 147 -85 153 -87 7 -2 10 221 10 708 0 390 -2 710 -5 710 -3 0 -43 -23 -90 -51z M3851 4935 l-1 -550 183 -107 c100 -60 268 -159 372 -222 105 -63 191 -113 193 -111 4 3 -274 573 -288 590 -6 8 -32 26 -56 40 l-44 26 0 76 0 76 -112 231 c-62 126 -143 291 -180 365 l-67 136 0 -550z M6380 5471 c0 -5 -70 -152 -156 -327 -203 -414 -194 -393 -194 -473 l0 -68 -51 -34 c-50 -34 -52 -38 -190 -323 -77 -159 -138 -290 -136 -292 2 -3 127 69 278 158 151 90 316 188 367 218 l92 54 0 548 c0 301 -2 548 -5 548 -3 0 -5 -4 -5 -9z M3722 3952 l-144 -86 49 -28 c26 -16 111 -66 188 -112 136 -80 526 -311 830 -492 83 -49 153 -91 158 -92 4 -2 6 76 5 174 l-3 178 -70 41 c-38 23 -246 146 -461 273 -216 128 -396 232 -400 231 -5 -1 -73 -40 -152 -87z M6310 4011 c-25 -16 -124 -74 -220 -131 -96 -57 -284 -168 -417 -247 l-243 -145 0 -174 c0 -96 2 -174 5 -174 2 0 37 20 77 44 40 24 195 116 343 204 149 87 369 217 490 289 121 72 241 142 268 157 26 16 47 29 47 31 0 6 -292 176 -301 174 -2 0 -24 -13 -49 -28z" />
</g>
</svg>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { Logo } from "@/components/Logo";
import { NAV_LINKS } from "@/components/nav-links";
import { ThemeToggle } from "@/components/ThemeToggle";
export function Nav() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
// A route change should never leave the drawer hanging open behind the new page.
useEffect(() => {
setOpen(false);
}, [pathname]);
function current(href: string) {
return pathname === href || pathname === `${href}/` ? "page" : undefined;
}
return (
<>
<header className="nav">
<div className="rail nav__in">
<Link className="brand" href="/">
<Logo />
<b>Vantage</b>
</Link>
<nav className="nav__links" aria-label="Main">
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} aria-current={current(link.href)}>
{link.label}
</Link>
))}
</nav>
<ThemeToggle />
<button
type="button"
className="icon-btn nav__menu"
aria-expanded={open}
aria-controls="nav-drawer"
onClick={() => setOpen((v) => !v)}
>
{open ? "Close" : "Menu"}
</button>
<Link className="btn btn--solid btn--sm nav__cta" href="/start">
Create organisation
</Link>
</div>
</header>
{open && (
<div className="drawer" id="nav-drawer">
<div className="rail">
<nav aria-label="Main, mobile">
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} aria-current={current(link.href)}>
{link.label}
</Link>
))}
<Link className="btn btn--solid" href="/start">
Create organisation
</Link>
</nav>
</div>
</div>
)}
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { Honeypot } from "@/components/Honeypot";
import { submitSignup, type SubmitResult } from "@/lib/submit";
const MIN_PASSWORD = 12;
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export function OrgForm() {
const [slug, setSlug] = useState("");
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
const sending = result.state === "sending";
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setResult({ state: "sending" });
setResult(
await submitSignup({
org_name: String(data.get("org_name") ?? ""),
email: String(data.get("email") ?? ""),
password: String(data.get("password") ?? ""),
website: String(data.get("website") ?? ""),
})
);
}
if (result.state === "sent") {
return (
<div role="status">
<h2 style={{ fontSize: "var(--s-1)" }}>Check your email.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
We sent a confirmation link. Open it and <b>{slug || "your organisation"}</b> is created with you as its
owner. The link works once and expires in 24 hours.
</p>
<p style={{ color: "var(--ink-3)", marginTop: "0.75rem", fontSize: "0.88rem" }}>
Nothing exists until you confirm if the email does not arrive, start again or contact
support@hostxtra.co.uk.
</p>
</div>
);
}
const fieldError = (name: string) => result.fields?.[name];
return (
<form className="form" onSubmit={onSubmit} noValidate>
<Honeypot />
<div className="field">
<label htmlFor="o-org">Organisation name</label>
<input
id="o-org"
name="org_name"
type="text"
placeholder="Northgate Systems"
required
onChange={(e) => setSlug(slugify(e.target.value))}
aria-describedby="o-org-err"
/>
<span className="hostline">
<b>{slug || "your-org"}</b>.vantage.sh
</span>
{fieldError("org_name") && (
<small id="o-org-err" className="field__err">
{fieldError("org_name")}
</small>
)}
</div>
<div className="field">
<label htmlFor="o-email">Owner email</label>
<input id="o-email" name="email" type="email" autoComplete="email" required aria-describedby="o-email-err" />
<small>You become the first owner and can invite the rest of the team afterwards.</small>
{fieldError("email") && (
<small id="o-email-err" className="field__err">
{fieldError("email")}
</small>
)}
</div>
<div className="field">
<label htmlFor="o-pass">Password</label>
<input
id="o-pass"
name="password"
type="password"
autoComplete="new-password"
minLength={MIN_PASSWORD}
required
aria-describedby="o-pass-err"
/>
<small>At least {MIN_PASSWORD} characters. Use a manager you are about to manage SSH keys with it.</small>
{fieldError("password") && (
<small id="o-pass-err" className="field__err">
{fieldError("password")}
</small>
)}
</div>
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
{sending ? "Sending…" : "Create organisation"}
</button>
{result.state === "error" && result.message && (
<p className="field__err" role="alert">
{result.message}
</p>
)}
</form>
);
}
+16
View File
@@ -0,0 +1,16 @@
// Applies the stored theme before first paint. Without this the page renders in
// the OS theme for a frame and then snaps to the stored one.
const script = `
(function(){
try {
var t = localStorage.getItem("vantage-theme");
if (t === "light" || t === "dark") {
document.documentElement.setAttribute("data-theme", t);
}
} catch (e) {}
})();
`;
export function ThemeScript() {
return <script dangerouslySetInnerHTML={{ __html: script }} />;
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useState } from "react";
const STORAGE_KEY = "vantage-theme";
type Theme = "light" | "dark";
function currentTheme(): Theme {
const set = document.documentElement.getAttribute("data-theme");
if (set === "light" || set === "dark") return set;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme | null>(null);
useEffect(() => {
setTheme(currentTheme());
}, []);
function toggle() {
const next: Theme = currentTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
window.localStorage.setItem(STORAGE_KEY, next);
setTheme(next);
}
// Until mounted the rendered label would disagree with the server output, so
// the button carries a neutral label on first paint.
const label = theme === null ? "Theme" : theme === "dark" ? "Light" : "Dark";
return (
<button type="button" className="icon-btn" onClick={toggle} aria-label={`Switch to ${label.toLowerCase()} theme`}>
{label}
</button>
);
}
+6
View File
@@ -0,0 +1,6 @@
export const NAV_LINKS = [
{ href: "/platform", label: "Platform" },
{ href: "/pricing", label: "Pricing" },
{ href: "/about", label: "About" },
{ href: "/contact", label: "Contact" },
];