feat: Updated marketing site

This commit is contained in:
mrhid6 committed 2026-07-28 12:46:28 +01:00
1 parent febe48a974
commit f10e61cde4
39 files changed
+599 -1050

No files matched your search

+10 -35
View File
@@ -20,19 +20,14 @@ export default function BillingPage() {
// Each subscription names the instance it pays for, because tier and term
// are per-licence rather than per-account. Resolving the name here is the
// difference between "professional · annual" and knowing which install that is.
const nameFor = (instanceId?: string) =>
account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
return (
<div className="grid gap-6">
<PageHeader
title="Billing"
subtitle="One subscription per instance — each carries its own tier and term."
record={
account.data
? [{ key: "Billing", value: account.data.account.billing_email }]
: undefined
}
subtitle="One subscription per instance each carries its own tier and term."
record={account.data ? [{ key: "Billing", value: account.data.account.billing_email }] : undefined}
/>
<PageFrame
@@ -51,18 +46,10 @@ export default function BillingPage() {
/>
</RailCard>
<RailCard title="Need a change?">
<p className="text-[0.82rem] text-ink-2">
Change a card, download an invoice or cancel from the billing
portal. It covers every subscription on this account.
</p>
<p className="text-[0.82rem] text-ink-2">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"
className="text-[0.82rem] font-semibold text-accent underline"
>
<p className="text-[0.82rem] text-ink-2">Anything else, email support.</p>
<a href="mailto:support@hostxtra.co.uk" className="text-[0.82rem] font-semibold text-accent underline">
support@hostxtra.co.uk
</a>
</RailCard>
@@ -70,10 +57,7 @@ export default function BillingPage() {
}
>
{rows.length === 0 ? (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
You have no subscriptions. Cloud instances and self-hosted licences are
both bought from the pricing page.
</p>
<p className="rounded border border-rule bg-panel p-5 text-ink-2">You have no subscriptions. Cloud instances and self-hosted licences are both bought from the pricing page.</p>
) : (
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left">
@@ -88,21 +72,12 @@ export default function BillingPage() {
</thead>
<tbody>
{rows.map((s) => (
<tr
key={s.subscription_id}
className="border-b border-rule-soft last:border-0"
>
<td className="px-4 py-3">
{nameFor(s.instance_id) ?? (
<span className="text-ink-3">Not linked yet</span>
)}
</td>
<tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-3">{nameFor(s.instance_id) ?? <span className="text-ink-3">Not linked yet</span>}</td>
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{s.term}</td>
<td className="px-4 py-3">{s.status}</td>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(s.current_period_end)}
</td>
<td className="px-4 py-3 font-mono tabular-nums">{formatDate(s.current_period_end)}</td>
</tr>
))}
</tbody>
@@ -11,7 +11,7 @@ export default function LinkPage() {
const qc = useQueryClient();
// This page only claims a PAID placeholder's real install UUID. Self-hosted
// Free is created on the purchase page, so with no placeholder to claim there
// is nothing to do here — send them there.
// is nothing to do here send them there.
const claimId = useSearchParams().get("claim") ?? undefined;
useEffect(() => {
+3 -7
View File
@@ -6,8 +6,8 @@ import { RequireKind } from "@/lib/session";
import { AppBar, type NavLink } from "@/components/AppBar";
/*
* Three destinations, not five. Settings moved into the account menu — it is
* your password, not a place — and Instances went with it, because Overview
* Three destinations, not five. Settings moved into the account menu it is
* your password, not a place and Instances went with it, because Overview
* already lists them and a second door to the same room is just a second thing
* to keep in sync. Linking an install is an action, so it is a button on
* Overview rather than a permanent nav entry.
@@ -22,11 +22,7 @@ function AccountName() {
// Shares the ["account"] key with Overview, so this costs no extra request.
const { data } = useQuery({ queryKey: ["account"], queryFn: api.account });
if (!data) return null;
return (
<span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">
{data.account.name}
</span>
);
return <span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">{data.account.name}</span>;
}
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
+18 -64
View File
@@ -35,14 +35,12 @@ export default function OverviewPage() {
const live = data.instances.filter((i) => i.status !== "deleted");
// Work the customer has to do, gathered across every instance. This is the
// only account-level view of it — each record only knows about itself.
// only account-level view of it each record only knows about itself.
const attention = live.flatMap((i) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
if (state === "none")
return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
if (state === "expired")
return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
if (state === "none") return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
if (state === "expired") return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
if (state === "warn")
return [
{
@@ -57,40 +55,26 @@ export default function OverviewPage() {
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
const subtitle =
live.length === 0
? "Nothing here yet."
: `${live.length} ${live.length === 1 ? "instance" : "instances"}${
attention.length ? ` · ${attention.length} needing attention` : " · all licensed"
}`;
live.length === 0 ? "Nothing here yet." : `${live.length} ${live.length === 1 ? "instance" : "instances"}${attention.length ? ` · ${attention.length} needing attention` : " · all licensed"}`;
return (
<div className="grid gap-6">
<PageHeader
title="Overview"
subtitle={subtitle}
actions={
live.length > 0 ? (
<LinkButton href="/purchase">Buy a plan</LinkButton>
) : undefined
}
actions={live.length > 0 ? <LinkButton href="/purchase">Buy a plan</LinkButton> : undefined}
record={[
{ key: "Account", value: data.account.account_id, copy: true },
{ key: "Billing", value: data.account.billing_email },
]}
status={
attention.length === 0 && live.length > 0 ? (
<StatePill state="valid" />
) : undefined
}
status={attention.length === 0 && live.length > 0 ? <StatePill state="valid" /> : undefined}
/>
{live.length === 0 ? (
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">No instances yet</h2>
<p className="text-ink-2">
Create a free cloud instance and we host it, with your licence applied
automatically. Or run Vantage on your own server and get its licence — free
or paid — from the purchase page.
Create a free cloud instance and we host it, with your licence applied automatically. Or run Vantage on your own server and get its licence free or paid from the purchase page.
</p>
<div className="flex flex-wrap gap-2.5">
<LinkButton href="/purchase">Buy a plan</LinkButton>
@@ -104,16 +88,9 @@ export default function OverviewPage() {
<RailCard title="Needs you" count={attention.length}>
<ul className="grid gap-2">
{attention.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
>
<li key={a.id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
<span>{a.text}</span>
{a.note && (
<span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">
{a.note}
</span>
)}
{a.note && <span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">{a.note}</span>}
</li>
))}
</ul>
@@ -123,27 +100,18 @@ export default function OverviewPage() {
<RailCard title="Your team" count={people.data?.length}>
<ul className="grid gap-2">
{(people.data ?? []).slice(0, 5).map((p) => (
<li
key={p.user_id}
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
>
<li key={p.user_id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
<span className="truncate">{p.email}</span>
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">
{p.account_role}
</span>
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">{p.account_role}</span>
</li>
))}
</ul>
{pending > 0 && (
<p className="border-t border-rule-soft pt-2 text-[0.78rem] text-warn">
{pending} {pending === 1 ? "invitation" : "invitations"} not
accepted yet
{pending} {pending === 1 ? "invitation" : "invitations"} not accepted yet
</p>
)}
<Link
href="/users"
className="text-[0.82rem] font-semibold text-accent underline"
>
<Link href="/users" className="text-[0.82rem] font-semibold text-accent underline">
Manage people
</Link>
</RailCard>
@@ -151,7 +119,7 @@ export default function OverviewPage() {
{/*
* Account-level facts only. Tier, limits and renewal date
* belong to a licence, and a licence belongs to one
* instance — an account holding a Free cloud instance and
* instance an account holding a Free cloud instance and
* a Professional self-hosted one has no single plan.
*/}
<RailCard title="Account">
@@ -165,23 +133,14 @@ export default function OverviewPage() {
},
]}
/>
<Link
href="/billing"
className="text-[0.82rem] font-semibold text-accent underline"
>
<Link href="/billing" className="text-[0.82rem] font-semibold text-accent underline">
Billing history
</Link>
</RailCard>
<RailCard title="Running Vantage yourself?">
<p className="text-[0.82rem] text-ink-2">
Get a licence for your own install — free or paid — from the
purchase page. It keeps its own users.
</p>
<Link
href="/purchase"
className="text-[0.82rem] font-semibold text-accent underline"
>
<p className="text-[0.82rem] text-ink-2">Get a licence for your own install free or paid from the purchase page. It keeps its own users.</p>
<Link href="/purchase" className="text-[0.82rem] font-semibold text-accent underline">
Get a licence
</Link>
</RailCard>
@@ -199,12 +158,7 @@ export default function OverviewPage() {
// Open when it is the only one, or when it is the
// first thing that needs a decision. A saved toggle
// beats this from then on.
defaultOpen={
live.length === 1 ||
(state !== "valid" &&
attention[0]?.id === i.instance_id) ||
(attention.length === 0 && n === 0)
}
defaultOpen={live.length === 1 || (state !== "valid" && attention[0]?.id === i.instance_id) || (attention.length === 0 && n === 0)}
/>
);
})}
@@ -88,7 +88,7 @@ export function PurchaseForm() {
const rows = useMemo(() => (options?.catalogue ?? []).filter((r) => r.deployment === dep && r.tier === choice.tier), [options, dep, choice.tier]);
// Real line items for the current configuration — the same builder the
// Real line items for the current configuration the same builder the
// checkout uses, so the summary can never disagree with the overlay.
const items = useMemo(() => (options ? lineItemsFor(options, choice, dep) : []), [options, choice, dep]);
@@ -321,8 +321,7 @@ export function PurchaseForm() {
<Block n={3} label="Your install">
<div className="grid gap-3 rounded border border-rule bg-panel p-4">
<p className="text-[0.86rem] text-ink-2">
Install Vantage on your own server first, then paste the instance ID it
reports. We register it and issue your Free licence — nothing to pay.
Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence nothing to pay.
</p>
<label className="grid gap-1">
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance ID</span>
@@ -332,10 +331,7 @@ export function PurchaseForm() {
placeholder="00000000-0000-0000-0000-000000000000"
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
/>
<span className="text-[0.72rem] text-ink-3">
Find this on your install&rsquo;s Settings → Licence page, or the setup
screen just after first sign-in.
</span>
<span className="text-[0.72rem] text-ink-3">Find this on your install&rsquo;s Settings → Licence page, or the setup screen just after first sign-in.</span>
</label>
</div>
</Block>
@@ -423,7 +419,7 @@ export function PurchaseForm() {
)}
{pending?.deployment === "cloud" && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears — no further steps.</p>
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears no further steps.</p>
<Link href={`/instances/${pending.instanceId}`} className="font-semibold text-accent underline">
Go to your instance
</Link>
@@ -554,7 +550,7 @@ function TierCard({
return (
<FeatureLine key={key} on={st !== "absent"}>
{featureLabel(key)}
{st === "included" ? " — included" : st === "addon" ? " — add-on" : " — not available"}
{st === "included" ? " included" : st === "addon" ? " add-on" : " not available"}
</FeatureLine>
);
})}