feat: Updated marketing site
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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’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’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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user