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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,9 +25,7 @@ export default function AccountDetailPage() {
|
||||
record={[
|
||||
{ key: "Account", value: data.account.account_id, copy: true },
|
||||
{ key: "Status", value: data.account.status },
|
||||
...(data.account.paddle_customer_id
|
||||
? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }]
|
||||
: []),
|
||||
...(data.account.paddle_customer_id ? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -35,10 +33,7 @@ export default function AccountDetailPage() {
|
||||
<ul className="grid gap-2">
|
||||
{data.instances.map((i) => (
|
||||
<li key={i.instance_id} className="flex flex-wrap justify-between gap-2">
|
||||
<Link
|
||||
href={`/staff/instances/${i.instance_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="text-accent underline">
|
||||
{i.name || i.instance_id}
|
||||
</Link>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
@@ -71,19 +66,10 @@ export default function AccountDetailPage() {
|
||||
{data.users.map((u) => (
|
||||
<li key={u.user_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span className="font-mono text-[0.82rem]">{u.email}</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{u.verified_at
|
||||
? `verified ${formatDate(u.verified_at)}`
|
||||
: "not verified"}
|
||||
</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">{u.verified_at ? `verified ${formatDate(u.verified_at)}` : "not verified"}</span>
|
||||
</li>
|
||||
))}
|
||||
{data.users.length === 0 && (
|
||||
<li className="text-ink-3">
|
||||
None — this is a cloud account, so its people sign in with their
|
||||
control-plane details.
|
||||
</li>
|
||||
)}
|
||||
{data.users.length === 0 && <li className="text-ink-3">None this is a cloud account, so its people sign in with their control-plane details.</li>}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
@@ -94,9 +80,7 @@ export default function AccountDetailPage() {
|
||||
<span>
|
||||
{e.action} · {e.actor}
|
||||
</span>
|
||||
<span className="tabular-nums text-ink-3">
|
||||
{formatDate(e.created_at)}
|
||||
</span>
|
||||
<span className="tabular-nums text-ink-3">{formatDate(e.created_at)}</span>
|
||||
</li>
|
||||
))}
|
||||
{data.audit.length === 0 && <li className="text-ink-3">Nothing yet.</li>}
|
||||
|
||||
@@ -6,11 +6,7 @@ import { ApiError, api, type Tier } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export function IssuePanel({
|
||||
instanceId,
|
||||
}: {
|
||||
instanceId: string;
|
||||
}) {
|
||||
export function IssuePanel({ instanceId }: { instanceId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [tier, setTier] = useState<Tier>("professional");
|
||||
const [term, setTerm] = useState("annual");
|
||||
@@ -35,28 +31,16 @@ export function IssuePanel({
|
||||
<section className="grid gap-4 border-t border-rule-soft pt-5">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Tier
|
||||
</span>
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as Tier)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Tier</span>
|
||||
<select value={tier} onChange={(e) => setTier(e.target.value as Tier)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Term
|
||||
</span>
|
||||
<select
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Term</span>
|
||||
<select value={term} onChange={(e) => setTerm(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
|
||||
<option value="annual">Annual</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
@@ -67,18 +51,8 @@ export function IssuePanel({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field
|
||||
label="Relink to instance ID"
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
hint="Staff relinks are not capped — the customer cap exists to put you in the loop."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() => relink.mutate()}
|
||||
disabled={!newId.trim()}
|
||||
>
|
||||
<Field label="Relink to instance ID" value={newId} onChange={(e) => setNewId(e.target.value)} hint="Staff relinks are not capped the customer cap exists to put you in the loop." />
|
||||
<Button type="button" variant="line" onClick={() => relink.mutate()} disabled={!newId.trim()}>
|
||||
Relink
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { IssuePanel } from "./IssuePanel";
|
||||
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
current: { label: "Control plane holds the current licence", tone: "text-valid" },
|
||||
stale: {
|
||||
label: "Control plane holds an older blob — the reconciler will repair it",
|
||||
label: "Control plane holds an older blob the reconciler will repair it",
|
||||
tone: "text-warn",
|
||||
},
|
||||
missing: { label: "No matching instance in the control plane", tone: "text-expired" },
|
||||
@@ -44,30 +44,19 @@ export default function StaffInstancePage() {
|
||||
title={data.instance.name || data.instance.instance_id}
|
||||
subtitle={
|
||||
<>
|
||||
<Link
|
||||
href={`/staff/accounts/${data.account.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
<Link href={`/staff/accounts/${data.account.account_id}`} className="text-accent underline">
|
||||
{data.account.name || data.account.account_id}
|
||||
</Link>
|
||||
<span className="text-ink-3">
|
||||
{" "}
|
||||
· {data.instance.deployment} · {data.instance.status}
|
||||
{data.instance.relink_count > 0 &&
|
||||
` · ${data.instance.relink_count} relinks this term`}
|
||||
{data.instance.relink_count > 0 && ` · ${data.instance.relink_count} relinks this term`}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
record={[
|
||||
{ key: "Instance", value: data.instance.instance_id, copy: true },
|
||||
...(data.instance.slug
|
||||
? [{ key: "Slug", value: data.instance.slug }]
|
||||
: []),
|
||||
]}
|
||||
record={[{ key: "Instance", value: data.instance.instance_id, copy: true }, ...(data.instance.slug ? [{ key: "Slug", value: data.instance.slug }] : [])]}
|
||||
/>
|
||||
{data.injection.applicable && inj && (
|
||||
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
|
||||
)}
|
||||
{data.injection.applicable && inj && <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>}
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
@@ -76,10 +65,7 @@ export default function StaffInstancePage() {
|
||||
<IssuePanel instanceId={data.instance.instance_id} />
|
||||
</section>
|
||||
|
||||
<EntitlementSection
|
||||
instanceId={data.instance.instance_id}
|
||||
deployment={data.instance.deployment}
|
||||
/>
|
||||
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,8 +100,7 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (grant: boolean) =>
|
||||
api.staff.setEntitlement(instanceId, { ...choice, grant }),
|
||||
mutationFn: (grant: boolean) => api.staff.setEntitlement(instanceId, { ...choice, grant }),
|
||||
onSuccess: () => {
|
||||
setDraft(null);
|
||||
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
|
||||
@@ -127,38 +112,21 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
<header className="mb-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
|
||||
<p className="text-[0.78rem] text-ink-3">
|
||||
What this instance is allowed. A licence is signed from{" "}
|
||||
<em>granted</em>, never from <em>desired</em>.
|
||||
What this instance is allowed. A licence is signed from <em>granted</em>, never from <em>desired</em>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{ent && data?.pending && (
|
||||
<p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
|
||||
Pending change — currently granted {ent.granted.servers} servers,
|
||||
configured for {ent.desired.servers}
|
||||
{ent.scheduled_change_at
|
||||
? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}`
|
||||
: ""}
|
||||
.
|
||||
Pending change currently granted {ent.granted.servers} servers, configured for {ent.desired.servers}
|
||||
{ent.scheduled_change_at ? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}` : ""}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PlanConfigurator
|
||||
deployment={deployment}
|
||||
value={choice}
|
||||
plans={plans}
|
||||
catalogue={catalogue}
|
||||
onChange={setDraft}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
<PlanConfigurator deployment={deployment} value={choice} plans={plans} catalogue={catalogue} onChange={setDraft} disabled={save.isPending} />
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate(false)}
|
||||
className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40"
|
||||
>
|
||||
<button type="button" disabled={save.isPending} onClick={() => save.mutate(false)} className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40">
|
||||
Save as configured
|
||||
</button>
|
||||
<button
|
||||
@@ -170,15 +138,8 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
Save and grant
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[0.72rem] text-ink-3">
|
||||
Granting takes effect on the next licence issued. It does not issue
|
||||
one.
|
||||
</p>
|
||||
{save.error && (
|
||||
<p className="mt-2 text-[0.82rem] text-expired">
|
||||
{String((save.error as Error).message)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-[0.72rem] text-ink-3">Granting takes effect on the next licence issued. It does not issue one.</p>
|
||||
{save.error && <p className="mt-2 text-[0.82rem] text-expired">{String((save.error as Error).message)}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,9 +35,7 @@ export default function StaffDashboard() {
|
||||
|
||||
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
|
||||
const stale = (unlinked.data ?? []).filter(
|
||||
(i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
|
||||
);
|
||||
const stale = (unlinked.data ?? []).filter((i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48);
|
||||
|
||||
const failed = injection.data?.count ?? 0;
|
||||
const instances = allInstances.data ?? [];
|
||||
@@ -46,11 +44,7 @@ export default function StaffDashboard() {
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Operations"
|
||||
subtitle={
|
||||
failed > 0
|
||||
? "Injection failures come first — those instances are paying for a licence they have not received."
|
||||
: "Nothing failing. Queues below are routine chasing."
|
||||
}
|
||||
subtitle={failed > 0 ? "Injection failures come first those instances are paying for a licence they have not received." : "Nothing failing. Queues below are routine chasing."}
|
||||
actions={
|
||||
<LinkButton variant="line" href="/staff/accounts">
|
||||
Find an account
|
||||
@@ -68,9 +62,7 @@ export default function StaffDashboard() {
|
||||
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
|
||||
label: i.name || i.instance_id,
|
||||
href: `/staff/instances/${i.instance_id}`,
|
||||
meta: i.inject_failed_at
|
||||
? new Date(i.inject_failed_at).toISOString().slice(11, 16)
|
||||
: "",
|
||||
meta: i.inject_failed_at ? new Date(i.inject_failed_at).toISOString().slice(11, 16) : "",
|
||||
}))}
|
||||
/>
|
||||
<Queue
|
||||
@@ -117,8 +109,7 @@ export default function StaffDashboard() {
|
||||
},
|
||||
{
|
||||
label: "Self-hosted",
|
||||
value: instances.filter((i) => i.deployment === "self_hosted")
|
||||
.length,
|
||||
value: instances.filter((i) => i.deployment === "self_hosted").length,
|
||||
},
|
||||
{
|
||||
label: "Awaiting link",
|
||||
@@ -126,10 +117,7 @@ export default function StaffDashboard() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href="/staff/licenses"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/staff/licenses" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
All licences
|
||||
</Link>
|
||||
</RailCard>
|
||||
@@ -139,14 +127,9 @@ export default function StaffDashboard() {
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[0.95rem]">Recent activity</h2>
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
Every licence issued, relinked or reaped, newest first.
|
||||
</p>
|
||||
<p className="text-[0.8rem] text-ink-2">Every licence issued, relinked or reaped, newest first.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/staff/audit"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/staff/audit" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Full audit
|
||||
</Link>
|
||||
</header>
|
||||
@@ -163,9 +146,7 @@ export default function StaffDashboard() {
|
||||
<tbody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<tr key={n} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">
|
||||
{new Date(e.created_at).toISOString().slice(11, 16)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</td>
|
||||
<td className="px-4 py-2.5">{e.action}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">{e.target ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-3">{e.actor}</td>
|
||||
|
||||
@@ -26,15 +26,7 @@ const LIMIT_FIELDS = [
|
||||
* sentinel is a staff screen where nobody can tell whether a plan says
|
||||
* unlimited or nothing at all.
|
||||
*/
|
||||
function AllowanceForm({
|
||||
plan,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
plan: Plan;
|
||||
onSave: (next: Plan) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Plan) => void; saving: boolean }) {
|
||||
const [draft, setDraft] = useState<Plan>(plan);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
|
||||
|
||||
@@ -43,9 +35,7 @@ function AllowanceForm({
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">
|
||||
{f.label}
|
||||
</span>
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">{f.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.base_limits[f.key]}
|
||||
@@ -60,20 +50,14 @@ function AllowanceForm({
|
||||
}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
/>
|
||||
<span className="mt-0.5 block text-[0.72rem] text-ink-3">
|
||||
−1 is unlimited
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[0.72rem] text-ink-3">−1 is unlimited</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">
|
||||
Support level
|
||||
</span>
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">Support level</span>
|
||||
<select
|
||||
value={draft.support_level}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, support_level: e.target.value })
|
||||
}
|
||||
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
>
|
||||
{SUPPORT_LEVELS.map((s) => (
|
||||
@@ -86,25 +70,13 @@ function AllowanceForm({
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.active}
|
||||
onChange={(e) => setDraft({ ...draft, active: e.target.checked })}
|
||||
/>
|
||||
<input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
|
||||
Offered to customers
|
||||
</label>
|
||||
|
||||
<p className="text-[0.78rem] text-ink-3">
|
||||
Changes apply to licences issued from now on. Existing licences
|
||||
snapshotted their plan and are unaffected.
|
||||
</p>
|
||||
<p className="text-[0.78rem] text-ink-3">Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => onSave(draft)}
|
||||
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
|
||||
>
|
||||
<button type="button" disabled={!dirty || saving} onClick={() => onSave(draft)} className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40">
|
||||
{saving ? "Saving…" : "Save allowances"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -131,26 +103,20 @@ export default function PlansPage() {
|
||||
onError: () => setSaving(null),
|
||||
});
|
||||
|
||||
const original = plans.data?.find(
|
||||
(p) => p.deployment === draft?.deployment && p.tier === draft?.tier,
|
||||
);
|
||||
const original = plans.data?.find((p) => p.deployment === draft?.deployment && p.tier === draft?.tier);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Plans"
|
||||
subtitle="The authoritative tier table — six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
|
||||
subtitle="The authoritative tier table six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
|
||||
/>
|
||||
|
||||
{draft && original && (
|
||||
<ConfirmPlanChange
|
||||
plan={original}
|
||||
next={draft}
|
||||
issuedCount={
|
||||
(licenses.data ?? []).filter(
|
||||
(l) => l.tier === draft.tier && l.deployment === draft.deployment,
|
||||
).length
|
||||
}
|
||||
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier && l.deployment === draft.deployment).length}
|
||||
onConfirm={() => {
|
||||
setSaving(`${draft.deployment}/${draft.tier}`);
|
||||
save.mutate(draft);
|
||||
@@ -161,29 +127,18 @@ export default function PlansPage() {
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="space-y-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}
|
||||
</h2>
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<article
|
||||
key={`${p.deployment}/${p.tier}`}
|
||||
className="rounded-lg border border-rule bg-panel p-4"
|
||||
>
|
||||
<article key={`${p.deployment}/${p.tier}`} className="rounded-lg border border-rule bg-panel p-4">
|
||||
<header className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">
|
||||
{p.name}
|
||||
</h3>
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">{p.name}</h3>
|
||||
<span className="font-mono text-[0.75rem] text-ink-3">
|
||||
{p.deployment}/{p.tier}
|
||||
</span>
|
||||
</header>
|
||||
<AllowanceForm
|
||||
plan={p}
|
||||
saving={saving === `${p.deployment}/${p.tier}`}
|
||||
onSave={(next: Plan) => setDraft(next)}
|
||||
/>
|
||||
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
@@ -12,7 +12,7 @@ export type NavLink = { href: string; label: string };
|
||||
* which environment you are in.
|
||||
*
|
||||
* It replaces a brand bar and a separate nav strip. The nav's active state is
|
||||
* derived from the pathname rather than hardcoded — the previous customer nav
|
||||
* derived from the pathname rather than hardcoded the previous customer nav
|
||||
* marked Overview as current on every page, including the ones that weren't it.
|
||||
*
|
||||
* Staff sit on --panel-2 with a chip where the account name goes. web/ is locked
|
||||
@@ -21,24 +21,14 @@ export type NavLink = { href: string; label: string };
|
||||
* that distinction from each other too, and one shade plus one chip buys it
|
||||
* without a second palette.
|
||||
*/
|
||||
export function AppBar({
|
||||
links,
|
||||
context,
|
||||
staff = false,
|
||||
}: {
|
||||
links: NavLink[];
|
||||
context?: React.ReactNode;
|
||||
staff?: boolean;
|
||||
}) {
|
||||
export function AppBar({ links, context, staff = false }: { links: NavLink[]; context?: React.ReactNode; staff?: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isCurrent = (href: string) =>
|
||||
// The section root matches only exactly; deeper routes match by prefix,
|
||||
// so /staff/accounts/:id still lights Accounts while /staff/accounts
|
||||
// does not light Operations.
|
||||
href === "/" || href === "/staff"
|
||||
? pathname === href
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
href === "/" || href === "/staff" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<header className={`border-b border-rule ${staff ? "bg-panel-2" : "bg-panel"}`}>
|
||||
@@ -46,9 +36,7 @@ export function AppBar({
|
||||
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-3 py-2.5">
|
||||
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
HQ
|
||||
</span>
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
|
||||
</span>
|
||||
{context && (
|
||||
<>
|
||||
@@ -70,9 +58,7 @@ export function AppBar({
|
||||
href={l.href}
|
||||
aria-current={on ? "page" : undefined}
|
||||
className={`relative inline-flex shrink-0 items-center px-3 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.08em] md:py-0 ${
|
||||
on
|
||||
? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']"
|
||||
: "text-ink-3 hover:text-ink-2"
|
||||
on ? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']" : "text-ink-3 hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
|
||||
@@ -12,9 +12,7 @@ export function buttonClass(variant: Variant = "solid", disabled = false, classN
|
||||
return clsx(
|
||||
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
|
||||
"transition-[filter,border-color] duration-150 hover:brightness-110",
|
||||
variant === "solid"
|
||||
? "border-accent bg-accent text-accent-ink"
|
||||
: "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
variant === "solid" ? "border-accent bg-accent text-accent-ink" : "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
|
||||
className,
|
||||
);
|
||||
@@ -28,22 +26,10 @@ export function Button({ variant = "solid", className, ...rest }: Props) {
|
||||
|
||||
/*
|
||||
* A link that looks like a button. It exists so a navigation action never has to
|
||||
* be an <a> wrapped around a <button> — invalid markup, and it gives screen
|
||||
* be an <a> wrapped around a <button> invalid markup, and it gives screen
|
||||
* readers two nested controls where the page means one.
|
||||
*/
|
||||
export function LinkButton({
|
||||
href,
|
||||
variant = "solid",
|
||||
external,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
variant?: Variant;
|
||||
external?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function LinkButton({ href, variant = "solid", external, className, children }: { href: string; variant?: Variant; external?: boolean; className?: string; children: React.ReactNode }) {
|
||||
const cls = buttonClass(variant, false, className);
|
||||
return external ? (
|
||||
<a href={href} className={cls}>
|
||||
|
||||
@@ -5,30 +5,12 @@ import { Button } from "./Button";
|
||||
/*
|
||||
* Editing a plan changes what every future customer gets, so the confirmation
|
||||
* names each field rather than asking "are you sure". Existing licences
|
||||
* snapshotted their plan at issue time and are genuinely unaffected — saying so
|
||||
* snapshotted their plan at issue time and are genuinely unaffected saying so
|
||||
* is what stops a well-meaning edit being followed by a panicked reissue.
|
||||
*/
|
||||
export function ConfirmPlanChange({
|
||||
plan,
|
||||
next,
|
||||
issuedCount,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
plan: Plan;
|
||||
next: Plan;
|
||||
issuedCount: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
export function ConfirmPlanChange({ plan, next, issuedCount, onConfirm, onCancel }: { plan: Plan; next: Plan; issuedCount: number; onConfirm: () => void; onCancel: () => void }) {
|
||||
const rows: { field: string; was: string; now: string }[] = [];
|
||||
const fields = [
|
||||
"max_servers",
|
||||
"max_monitors",
|
||||
"max_secret_groups",
|
||||
"max_channels",
|
||||
"audit_retention_days",
|
||||
] as const;
|
||||
const fields = ["max_servers", "max_monitors", "max_secret_groups", "max_channels", "audit_retention_days"] as const;
|
||||
for (const f of fields) {
|
||||
if (plan.base_limits[f] !== next.base_limits[f])
|
||||
rows.push({
|
||||
@@ -63,10 +45,7 @@ export function ConfirmPlanChange({
|
||||
))}
|
||||
{rows.length === 0 && <li className="text-ink-3">Nothing would change.</li>}
|
||||
</ul>
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
This applies to licences issued from now on. The {issuedCount} licences already
|
||||
issued keep what they were signed with until each is reissued.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-3">This applies to licences issued from now on. The {issuedCount} licences already issued keep what they were signed with until each is reissued.</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Change plan
|
||||
|
||||
@@ -21,7 +21,7 @@ const KEY = (id: string) => `vantage-hq-record-open:${id}`;
|
||||
/*
|
||||
* One instance, open or closed.
|
||||
*
|
||||
* Closed it is a row — name, tier, host, term bar, state. Open it adds what the
|
||||
* Closed it is a row name, tier, host, term bar, state. Open it adds what the
|
||||
* licence includes, who can sign in, and the actions. Deliberately ONE component
|
||||
* rather than a card and a detail panel: two components meant a single-instance
|
||||
* account got a third of a row of summary with its substance a click away, and
|
||||
|
||||
@@ -13,16 +13,12 @@ const REASON: Record<License["reason"], string> = {
|
||||
/*
|
||||
* Licences are append-only: a renewal supersedes its predecessor rather than
|
||||
* replacing it. So this is a ledger, not a table. Superseded rows stay visible
|
||||
* and are overprinted the way a cancelled instrument is — hiding them would
|
||||
* and are overprinted the way a cancelled instrument is hiding them would
|
||||
* destroy the only record of why an instance stopped working on a given date.
|
||||
*/
|
||||
export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
if (licenses.length === 0) {
|
||||
return (
|
||||
<p className="text-ink-2">
|
||||
No licence has ever been issued for this instance, so it is read-only.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-ink-2">No licence has ever been issued for this instance, so it is read-only.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -30,22 +26,9 @@ export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
{licenses.map((l) => {
|
||||
const dead = Boolean(l.superseded_by);
|
||||
return (
|
||||
<li
|
||||
key={l.license_id}
|
||||
className={clsx(
|
||||
"grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]",
|
||||
dead && "text-ink-3",
|
||||
)}
|
||||
>
|
||||
<li key={l.license_id} className={clsx("grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]", dead && "text-ink-3")}>
|
||||
<div className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
<b
|
||||
className={clsx(
|
||||
"block text-[0.82rem] font-semibold",
|
||||
dead ? "text-ink-3" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{formatDate(l.issued_at)}
|
||||
</b>
|
||||
<b className={clsx("block text-[0.82rem] font-semibold", dead ? "text-ink-3" : "text-ink")}>{formatDate(l.issued_at)}</b>
|
||||
{formatStamp(l.issued_at)}
|
||||
</div>
|
||||
<div className="grid justify-items-start gap-1.5">
|
||||
@@ -56,20 +39,14 @@ export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
)}
|
||||
<p className="flex flex-wrap items-center gap-2 font-semibold">
|
||||
{l.tier.replace("_", " ")}
|
||||
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">
|
||||
{REASON[l.reason]}
|
||||
</span>
|
||||
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">{REASON[l.reason]}</span>
|
||||
</p>
|
||||
<p className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} ·{" "}
|
||||
{limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
|
||||
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} · {limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
|
||||
{l.superseded_by && (
|
||||
<>
|
||||
{" "}
|
||||
· replaced by{" "}
|
||||
<span className="text-accent underline">
|
||||
{l.superseded_by.slice(0, 8)}
|
||||
</span>
|
||||
· replaced by <span className="text-accent underline">{l.superseded_by.slice(0, 8)}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -4,19 +4,11 @@ import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
/*
|
||||
* A licence blob is signed public data, not a secret — it is useless on any
|
||||
* A licence blob is signed public data, not a secret it is useless on any
|
||||
* instance other than the one it names. So it is safe to show inline, and
|
||||
* showing it is what stops a blocked download from blocking a paying customer.
|
||||
*/
|
||||
export function LicenceDelivery({
|
||||
instanceId,
|
||||
blob,
|
||||
downloadUrl,
|
||||
}: {
|
||||
instanceId: string;
|
||||
blob: string;
|
||||
downloadUrl: string;
|
||||
}) {
|
||||
export function LicenceDelivery({ instanceId, blob, downloadUrl }: { instanceId: string; blob: string; downloadUrl: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
@@ -27,13 +19,11 @@ export function LicenceDelivery({
|
||||
|
||||
const steps = [
|
||||
<>
|
||||
Open <code className="rounded-sm bg-accent-wash px-1">Settings → Licence</code> on your
|
||||
install.
|
||||
Open <code className="rounded-sm bg-accent-wash px-1">Settings → Licence</code> on your install.
|
||||
</>,
|
||||
<>Paste the licence into the box and save.</>,
|
||||
<>
|
||||
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight
|
||||
away — no restart.
|
||||
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight away no restart.
|
||||
</>,
|
||||
];
|
||||
|
||||
@@ -52,18 +42,11 @@ export function LicenceDelivery({
|
||||
{copied ? "Copied" : "Copy to clipboard"}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">
|
||||
{blob}
|
||||
</pre>
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">{blob}</pre>
|
||||
<ol className="grid gap-2">
|
||||
{steps.map((body, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">
|
||||
{i + 1}
|
||||
</span>
|
||||
<li key={i} className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2">
|
||||
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">{i + 1}</span>
|
||||
<span>{body}</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from "@/components/Button";
|
||||
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
|
||||
|
||||
/*
|
||||
* Absent entirely for self-hosted instances — the backend refuses those, and a
|
||||
* Absent entirely for self-hosted instances the backend refuses those, and a
|
||||
* panel that renders controls the server will reject is a panel that lies.
|
||||
*/
|
||||
export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
@@ -26,8 +26,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
|
||||
const fail = (e: unknown) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
|
||||
const grant = useMutation({
|
||||
mutationFn: () => api.grantMember(instanceId, selected, role),
|
||||
@@ -39,8 +38,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
onError: fail,
|
||||
});
|
||||
const changeRole = useMutation({
|
||||
mutationFn: (v: { uid: string; role: InstanceRole }) =>
|
||||
api.setMemberRole(instanceId, v.uid, v.role),
|
||||
mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
});
|
||||
@@ -54,29 +52,21 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
|
||||
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
|
||||
const candidates = (people.data ?? []).filter(
|
||||
(p) => !granted.has(p.user_id) && p.verified_at,
|
||||
);
|
||||
const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at);
|
||||
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-xl">Who can sign in</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Each person here has a real user inside this instance and signs in with their
|
||||
Vantage HQ password.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
|
||||
|
||||
<ul className="grid gap-2">
|
||||
{(members.data ?? []).map((m) => (
|
||||
<li
|
||||
key={m.member_id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2"
|
||||
>
|
||||
<li key={m.member_id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2">
|
||||
<span>{m.email}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{canManage ? (
|
||||
@@ -104,8 +94,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${m.email} from this instance?`))
|
||||
revoke.mutate(m.customer_user_id);
|
||||
if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
@@ -114,9 +103,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{members.data?.length === 0 && (
|
||||
<li className="text-ink-2">Nobody has been added yet.</li>
|
||||
)}
|
||||
{members.data?.length === 0 && <li className="text-ink-2">Nobody has been added yet.</li>}
|
||||
</ul>
|
||||
|
||||
{canManage && (
|
||||
@@ -129,14 +116,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Add someone
|
||||
</span>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Add someone</span>
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
|
||||
<option value="">Choose a person…</option>
|
||||
{candidates.map((p) => (
|
||||
<option key={p.user_id} value={p.user_id}>
|
||||
@@ -146,14 +127,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Role here
|
||||
</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as InstanceRole)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Role here</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
@@ -169,8 +144,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
|
||||
{canManage && pending > 0 && (
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted
|
||||
yet and cannot be added until they do.
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet and cannot be added until they do.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* The deployment failure this repo makes most often, made legible. It names the
|
||||
* variable, the value baked in, and both reasons it fails — unreachable from
|
||||
* variable, the value baked in, and both reasons it fails unreachable from
|
||||
* the browser, or missing from admin's ADMIN_ORIGIN.
|
||||
*/
|
||||
export function NotConnectedPanel({ url }: { url: string }) {
|
||||
@@ -9,19 +9,16 @@ export function NotConnectedPanel({ url }: { url: string }) {
|
||||
<h2 className="text-xl text-expired">Not connected to the licensing service</h2>
|
||||
{url ? (
|
||||
<p className="text-ink-2">
|
||||
This build points at <code className="text-ink">ADMIN_API_URL</code> ={" "}
|
||||
<code className="text-ink">{url}</code>, which did not respond.
|
||||
This build points at <code className="text-ink">ADMIN_API_URL</code> = <code className="text-ink">{url}</code>, which did not respond.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-ink-2">
|
||||
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was
|
||||
built, so there is nowhere to send requests.
|
||||
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was built, so there is nowhere to send requests.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
The value is baked in when the image is built and has to be reachable from your
|
||||
browser, not just from the server. It also has to appear in the licensing
|
||||
service’s <code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
The value is baked in when the image is built and has to be reachable from your browser, not just from the server. It also has to appear in the licensing service’s{" "}
|
||||
<code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,16 +7,10 @@
|
||||
* many instances exist, so the page has a floor.
|
||||
*
|
||||
* It collapses below lg in source order, which puts the main column first on a
|
||||
* phone. Nothing is hidden at any width — if content only fits on a desktop it
|
||||
* phone. Nothing is hidden at any width if content only fits on a desktop it
|
||||
* does not belong in the rail.
|
||||
*/
|
||||
export function PageFrame({
|
||||
children,
|
||||
aside,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
aside?: React.ReactNode;
|
||||
}) {
|
||||
export function PageFrame({ children, aside }: { children: React.ReactNode; aside?: React.ReactNode }) {
|
||||
if (!aside) return <div className="grid gap-5">{children}</div>;
|
||||
|
||||
return (
|
||||
@@ -28,24 +22,12 @@ export function PageFrame({
|
||||
}
|
||||
|
||||
/** One card in the rail. Title is a label, not a heading you read for pleasure. */
|
||||
export function RailCard({
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number | string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function RailCard({ title, count, children }: { title: string; count?: number | string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="grid gap-2.5 rounded border border-rule bg-panel p-3.5">
|
||||
<header className="flex items-baseline justify-between gap-2.5">
|
||||
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">
|
||||
{title}
|
||||
</h2>
|
||||
{count !== undefined && (
|
||||
<b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>
|
||||
)}
|
||||
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">{title}</h2>
|
||||
{count !== undefined && <b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
@@ -59,9 +41,7 @@ export function RailFacts({ rows }: { rows: { label: string; value: React.ReactN
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className="flex justify-between gap-2.5 text-[0.82rem]">
|
||||
<dt className="text-ink-3">{r.label}</dt>
|
||||
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">
|
||||
{r.value}
|
||||
</dd>
|
||||
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
|
||||
/*
|
||||
* One record-line entry. `copy` marks the value as worth lifting to the
|
||||
* clipboard — an instance UUID or a licence ID, the strings people paste into
|
||||
* clipboard an instance UUID or a licence ID, the strings people paste into
|
||||
* support tickets.
|
||||
*/
|
||||
export type RecordField = { key: string; value: string; copy?: boolean };
|
||||
@@ -60,10 +60,7 @@ export function PageHeader({
|
||||
return (
|
||||
<header className="grid gap-3">
|
||||
{back && (
|
||||
<Link
|
||||
href={back.href}
|
||||
className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent"
|
||||
>
|
||||
<Link href={back.href} className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent">
|
||||
← {back.label}
|
||||
</Link>
|
||||
)}
|
||||
@@ -80,12 +77,8 @@ export function PageHeader({
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
|
||||
{record?.map((f) => (
|
||||
<span key={f.key} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
|
||||
{f.key}
|
||||
</span>
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
{f.value}
|
||||
</span>
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
|
||||
{f.copy && <CopyButton value={f.value} />}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -6,18 +6,7 @@ import { Field } from "./Field";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function RelinkPanel({
|
||||
used,
|
||||
max,
|
||||
onRelink,
|
||||
error,
|
||||
}: {
|
||||
instanceId: string;
|
||||
used: number;
|
||||
max: number;
|
||||
onRelink: (newId: string) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
export function RelinkPanel({ used, max, onRelink, error }: { instanceId: string; used: number; max: number; onRelink: (newId: string) => void; error?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const remaining = Math.max(0, max - used);
|
||||
@@ -26,32 +15,14 @@ export function RelinkPanel({
|
||||
return (
|
||||
<section className="grid gap-3 border-t border-rule-soft pt-5">
|
||||
<h2 className="text-xl">Moved to a new server?</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Relinking issues a replacement licence for the new install, covering the rest of
|
||||
your current term.
|
||||
</p>
|
||||
{open && !exhausted && (
|
||||
<Field
|
||||
label="New instance ID"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error}
|
||||
hint="From Settings → Licence on the new install."
|
||||
/>
|
||||
)}
|
||||
<p className="text-[0.82rem] text-ink-2">Relinking issues a replacement licence for the new install, covering the rest of your current term.</p>
|
||||
{open && !exhausted && <Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
|
||||
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
|
||||
>
|
||||
<Button type="button" variant="line" disabled={exhausted || (open && !UUID_RE.test(value.trim()))} onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}>
|
||||
Relink to a new install
|
||||
</Button>
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
{exhausted
|
||||
? "You have used every relink for this term — contact support and we will sort it out."
|
||||
: `${remaining} of ${max} relinks left this term`}
|
||||
{exhausted ? "You have used every relink for this term contact support and we will sort it out." : `${remaining} of ${max} relinks left this term`}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useTheme(): [ThemePref, (p: ThemePref) => void] {
|
||||
// Starts at "system" on both server and first client render so hydration
|
||||
// matches; the real value lands in the effect below. The inline script in
|
||||
// app/layout.tsx has already painted the correct colours by then, so there
|
||||
// is no flash — only this control's own highlight settles a tick late.
|
||||
// is no flash only this control's own highlight settles a tick late.
|
||||
const [pref, setPref] = useState<ThemePref>("system");
|
||||
|
||||
useEffect(() => setPref(readTheme()), []);
|
||||
@@ -51,6 +51,4 @@ export function useTheme(): [ThemePref, (p: ThemePref) => void] {
|
||||
* Runs before first paint, so a dark-preferring user never sees a white flash.
|
||||
* Inlined as a string because it has to execute ahead of React.
|
||||
*/
|
||||
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
|
||||
KEY,
|
||||
)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
|
||||
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(KEY)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
|
||||
|
||||
Reference in New Issue
Block a user