This commit is contained in:
@@ -4,17 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
lineItemsFor,
|
||||
type CatalogueRow,
|
||||
type CheckoutOptions,
|
||||
type Deployment,
|
||||
type Plan,
|
||||
type Term,
|
||||
type Tier,
|
||||
} from "@/lib/api";
|
||||
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
|
||||
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
|
||||
|
||||
/* Tiers in the order a customer reads them, cheapest first. */
|
||||
@@ -24,11 +14,11 @@ const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
|
||||
* one place the customer-facing wording lives. */
|
||||
const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
sso: "Single sign-on",
|
||||
oidc: "Single sign-on",
|
||||
};
|
||||
const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
sso: "OIDC sign-in for your whole team",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
};
|
||||
function featureLabel(key: string) {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
@@ -45,13 +35,7 @@ interface Choice {
|
||||
* absent. Drives both the tier cards and the configurator toggles. */
|
||||
type FeatureState = "included" | "addon" | "absent";
|
||||
|
||||
function featureStateFor(
|
||||
plan: Plan | undefined,
|
||||
rows: CatalogueRow[],
|
||||
env: string,
|
||||
term: Term,
|
||||
key: string,
|
||||
): FeatureState {
|
||||
function featureStateFor(plan: Plan | undefined, rows: CatalogueRow[], env: string, term: Term, key: string): FeatureState {
|
||||
if (plan?.base_features.includes(key)) return "included";
|
||||
const row = rows.find((r) => r.kind === "feature" && r.feature_key === key);
|
||||
const priced = Boolean(row?.price_ids?.[env]?.[term]);
|
||||
@@ -95,31 +79,16 @@ export function PurchaseForm() {
|
||||
return [...keys];
|
||||
}, [options, dep]);
|
||||
|
||||
const activePlans = useMemo(
|
||||
() =>
|
||||
(options?.plans ?? [])
|
||||
.filter((p) => p.deployment === dep && p.active)
|
||||
.sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)),
|
||||
[options, dep],
|
||||
);
|
||||
const activePlans = useMemo(() => (options?.plans ?? []).filter((p) => p.deployment === dep && p.active).sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)), [options, dep]);
|
||||
const plan = activePlans.find((p) => p.tier === choice.tier);
|
||||
const baseServers = plan?.base_limits.max_servers ?? 0;
|
||||
const unlimited = baseServers === -1;
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(options?.catalogue ?? []).filter(
|
||||
(r) => r.deployment === dep && r.tier === choice.tier,
|
||||
),
|
||||
[options, dep, choice.tier],
|
||||
);
|
||||
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
|
||||
// checkout uses, so the summary can never disagree with the overlay.
|
||||
const items = useMemo(
|
||||
() => (options ? lineItemsFor(options, choice, dep) : []),
|
||||
[options, choice, dep],
|
||||
);
|
||||
const items = useMemo(() => (options ? lineItemsFor(options, choice, dep) : []), [options, choice, dep]);
|
||||
|
||||
// Real, localised prices from Paddle for those items.
|
||||
const [receiptPrice, setReceiptPrice] = useState<PricePreview | null>(null);
|
||||
@@ -139,26 +108,22 @@ export function PurchaseForm() {
|
||||
if (!options) return;
|
||||
const baseItems: { priceId: string; quantity: number; tier: Tier }[] = [];
|
||||
for (const p of activePlans) {
|
||||
const row = options.catalogue.find(
|
||||
(r) => r.deployment === dep && r.tier === p.tier && r.kind === "base",
|
||||
);
|
||||
const row = options.catalogue.find((r) => r.deployment === dep && r.tier === p.tier && r.kind === "base");
|
||||
const id = row?.price_ids?.[options.env]?.[choice.term];
|
||||
if (id) baseItems.push({ priceId: id, quantity: 1, tier: p.tier });
|
||||
}
|
||||
let live = true;
|
||||
previewPrices(baseItems.map(({ priceId, quantity }) => ({ priceId, quantity }))).then(
|
||||
(p) => {
|
||||
if (!live) return;
|
||||
const next: Record<string, string> = {};
|
||||
if (p) {
|
||||
for (const bi of baseItems) {
|
||||
const line = p.lines[bi.priceId];
|
||||
if (line) next[bi.tier] = line.total;
|
||||
}
|
||||
previewPrices(baseItems.map(({ priceId, quantity }) => ({ priceId, quantity }))).then((p) => {
|
||||
if (!live) return;
|
||||
const next: Record<string, string> = {};
|
||||
if (p) {
|
||||
for (const bi of baseItems) {
|
||||
const line = p.lines[bi.priceId];
|
||||
if (line) next[bi.tier] = line.total;
|
||||
}
|
||||
setBasePrices(next);
|
||||
},
|
||||
);
|
||||
}
|
||||
setBasePrices(next);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
@@ -169,17 +134,13 @@ export function PurchaseForm() {
|
||||
const createFree = useMutation({
|
||||
mutationFn: () => api.createInstance(name.trim()),
|
||||
onSuccess: () => router.push("/"),
|
||||
onError: (e) =>
|
||||
setError(e instanceof ApiError ? e.message : "Could not create the instance."),
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the instance."),
|
||||
});
|
||||
|
||||
const startCheckout = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = name.trim();
|
||||
const r =
|
||||
dep === "cloud"
|
||||
? await api.createCloudCheckout(trimmed)
|
||||
: await api.createSelfHosted(trimmed);
|
||||
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHosted(trimmed);
|
||||
return r.instance_id;
|
||||
},
|
||||
onSuccess: async (instanceId) => {
|
||||
@@ -190,8 +151,7 @@ export function PurchaseForm() {
|
||||
customData: { account_id: accountId, instance_id: instanceId },
|
||||
});
|
||||
},
|
||||
onError: (e) =>
|
||||
setError(e instanceof ApiError ? e.message : "Could not start checkout."),
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start checkout."),
|
||||
});
|
||||
|
||||
const claim = useMutation({
|
||||
@@ -277,9 +237,7 @@ export function PurchaseForm() {
|
||||
headline={p.tier === "free" ? "£0" : basePrices[p.tier]}
|
||||
cycleLabel={cycleShort(dep, choice.term)}
|
||||
featureKeys={featureKeys}
|
||||
catalogue={options.catalogue.filter(
|
||||
(r) => r.deployment === dep && r.tier === p.tier,
|
||||
)}
|
||||
catalogue={options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier)}
|
||||
env={options.env}
|
||||
term={choice.term}
|
||||
onSelect={() =>
|
||||
@@ -287,20 +245,12 @@ export function PurchaseForm() {
|
||||
...c,
|
||||
tier: p.tier,
|
||||
// Moving tier moves the floor; clamp up.
|
||||
servers: Math.max(
|
||||
c.servers,
|
||||
p.base_limits.max_servers === -1
|
||||
? c.servers
|
||||
: p.base_limits.max_servers,
|
||||
),
|
||||
servers: Math.max(c.servers, p.base_limits.max_servers === -1 ? c.servers : p.base_limits.max_servers),
|
||||
// Drop add-ons the new tier does not sell.
|
||||
features: c.features.filter((k) => {
|
||||
const st = featureStateFor(
|
||||
p,
|
||||
options.catalogue.filter(
|
||||
(r) =>
|
||||
r.deployment === dep && r.tier === p.tier,
|
||||
),
|
||||
options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier),
|
||||
options.env,
|
||||
c.term,
|
||||
k,
|
||||
@@ -318,61 +268,30 @@ export function PurchaseForm() {
|
||||
<Block n={dep === "cloud" ? 4 : 3} label="Configure">
|
||||
<div className="rounded border border-rule bg-panel p-4">
|
||||
{/* servers */}
|
||||
<Row
|
||||
title="Managed servers"
|
||||
desc={
|
||||
unlimited
|
||||
? "Unlimited servers included in this plan"
|
||||
: `${baseServers} included`
|
||||
}
|
||||
>
|
||||
<Row title="Managed servers" desc={unlimited ? "Unlimited servers included in this plan" : `${baseServers} included`}>
|
||||
{unlimited ? (
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">
|
||||
Unlimited
|
||||
</span>
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Unlimited</span>
|
||||
) : (
|
||||
<Stepper
|
||||
value={choice.servers}
|
||||
min={baseServers}
|
||||
max={500}
|
||||
onChange={(servers) => setChoice((c) => ({ ...c, servers }))}
|
||||
/>
|
||||
<Stepper value={choice.servers} min={baseServers} max={500} onChange={(servers) => setChoice((c) => ({ ...c, servers }))} />
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{/* features */}
|
||||
{featureKeys.map((key) => {
|
||||
const st = featureStateFor(
|
||||
plan,
|
||||
rows,
|
||||
options.env,
|
||||
choice.term,
|
||||
key,
|
||||
);
|
||||
const st = featureStateFor(plan, rows, options.env, choice.term, key);
|
||||
return (
|
||||
<Row
|
||||
key={key}
|
||||
title={featureLabel(key)}
|
||||
desc={FEATURE_DESC[key] ?? ""}
|
||||
dim={st === "absent"}
|
||||
>
|
||||
<Row key={key} title={featureLabel(key)} desc={FEATURE_DESC[key] ?? ""} dim={st === "absent"}>
|
||||
{st === "included" ? (
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">
|
||||
Included
|
||||
</span>
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
|
||||
) : st === "absent" ? (
|
||||
<span className="font-mono text-[0.76rem] text-ink-3">
|
||||
Not in this plan
|
||||
</span>
|
||||
<span className="font-mono text-[0.76rem] text-ink-3">Not in this plan</span>
|
||||
) : (
|
||||
<Toggle
|
||||
checked={choice.features.includes(key)}
|
||||
onChange={(on) =>
|
||||
setChoice((c) => ({
|
||||
...c,
|
||||
features: on
|
||||
? [...c.features, key]
|
||||
: c.features.filter((f) => f !== key),
|
||||
features: on ? [...c.features, key] : c.features.filter((f) => f !== key),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
@@ -387,14 +306,10 @@ export function PurchaseForm() {
|
||||
{selfHostedFree && (
|
||||
<Block n={3} label="Free self-hosted">
|
||||
<div className="rounded border border-accent/25 bg-accent-wash p-4 text-[0.86rem] text-ink-2">
|
||||
<strong className="text-ink">Free self-hosted starts with your install.</strong>{" "}
|
||||
Install Vantage on your own server, link the instance ID it reports, then
|
||||
claim your free licence — there is nothing to pay for here.
|
||||
<strong className="text-ink">Free self-hosted starts with your install.</strong> Install Vantage on your own server, link the instance ID it reports, then claim your free
|
||||
licence — there is nothing to pay for here.
|
||||
<div className="mt-3">
|
||||
<Link
|
||||
href="/instances/link"
|
||||
className="font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/instances/link" className="font-semibold text-accent underline">
|
||||
Link an install
|
||||
</Link>
|
||||
</div>
|
||||
@@ -408,27 +323,16 @@ export function PurchaseForm() {
|
||||
<div className="overflow-hidden rounded-[14px] border border-rule bg-panel shadow-[var(--shadow)]">
|
||||
<div className="flex items-center justify-between border-b border-rule-soft px-4 py-3.5">
|
||||
<h3 className="text-[0.95rem] font-semibold">Order summary</h3>
|
||||
<span className="rounded border border-rule px-1.5 py-0.5 font-mono text-[0.62rem] uppercase tracking-[0.07em] text-ink-3">
|
||||
{dep === "cloud" ? "Cloud" : "Self-hosted"}
|
||||
</span>
|
||||
<span className="rounded border border-rule px-1.5 py-0.5 font-mono text-[0.62rem] uppercase tracking-[0.07em] text-ink-3">{dep === "cloud" ? "Cloud" : "Self-hosted"}</span>
|
||||
</div>
|
||||
|
||||
<Receipt
|
||||
options={options}
|
||||
dep={dep}
|
||||
choice={choice}
|
||||
plan={plan}
|
||||
items={items}
|
||||
price={receiptPrice}
|
||||
/>
|
||||
<Receipt options={options} dep={dep} choice={choice} plan={plan} items={items} price={receiptPrice} />
|
||||
|
||||
{/* name + CTA */}
|
||||
<div className="grid gap-3 border-t border-rule px-4 py-4">
|
||||
{!pending && !selfHostedFree && (
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">
|
||||
Instance name
|
||||
</span>
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance name</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
@@ -443,9 +347,7 @@ export function PurchaseForm() {
|
||||
{/* Phase A: choose an action for the configuration. */}
|
||||
{!pending &&
|
||||
(selfHostedFree ? (
|
||||
<p className="text-[0.8rem] text-ink-3">
|
||||
Free self-hosted is claimed after linking your install.
|
||||
</p>
|
||||
<p className="text-[0.8rem] text-ink-3">Free self-hosted is claimed after linking your install.</p>
|
||||
) : cloudFree ? (
|
||||
<Cta
|
||||
label={createFree.isPending ? "Creating…" : "Create free instance"}
|
||||
@@ -458,17 +360,8 @@ export function PurchaseForm() {
|
||||
/>
|
||||
) : (
|
||||
<Cta
|
||||
label={
|
||||
startCheckout.isPending
|
||||
? "Starting…"
|
||||
: "Continue to payment"
|
||||
}
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
items.length === 0 ||
|
||||
!accountId ||
|
||||
startCheckout.isPending
|
||||
}
|
||||
label={startCheckout.isPending ? "Starting…" : "Continue to payment"}
|
||||
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
startCheckout.mutate();
|
||||
@@ -479,10 +372,7 @@ export function PurchaseForm() {
|
||||
{/* Phase B: after the checkout has been opened. */}
|
||||
{pending?.deployment === "self_hosted" && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
Once payment clears, paste the instance ID your install reports
|
||||
(Settings → Licence) to receive your licence.
|
||||
</p>
|
||||
<p className="text-[0.8rem] text-ink-2">Once payment clears, paste the instance ID your install reports (Settings → Licence) to receive your licence.</p>
|
||||
<input
|
||||
value={uuid}
|
||||
onChange={(e) => setUuid(e.target.value)}
|
||||
@@ -501,14 +391,8 @@ 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>
|
||||
<Link
|
||||
href={`/instances/${pending.instanceId}`}
|
||||
className="font-semibold text-accent underline"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
@@ -517,11 +401,7 @@ export function PurchaseForm() {
|
||||
|
||||
<div className="flex items-start gap-2 border-t border-rule-soft px-4 py-3 text-[0.72rem] text-ink-3">
|
||||
<LockIcon />
|
||||
<span>
|
||||
{paid
|
||||
? "Secure checkout by Paddle, our reseller of record. VAT is added at checkout where applicable."
|
||||
: "No payment details required for the Free plan."}
|
||||
</span>
|
||||
<span>{paid ? "Secure checkout by Paddle, our reseller of record. VAT is added at checkout where applicable." : "No payment details required for the Free plan."}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -556,15 +436,7 @@ interface SegOption {
|
||||
sub: string;
|
||||
}
|
||||
|
||||
function Seg({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: SegOption[];
|
||||
}) {
|
||||
function Seg({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: SegOption[] }) {
|
||||
return (
|
||||
<div className="flex gap-1 rounded-[9px] border border-rule bg-panel-2 p-1">
|
||||
{options.map((o) => {
|
||||
@@ -575,37 +447,21 @@ function Seg({
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={`flex flex-1 items-center gap-3 rounded-[7px] px-4 py-3 text-left transition-colors ${
|
||||
on ? "bg-panel text-ink shadow-[var(--shadow)]" : "text-ink-2"
|
||||
}`}
|
||||
className={`flex flex-1 items-center gap-3 rounded-[7px] px-4 py-3 text-left transition-colors ${on ? "bg-panel text-ink shadow-[var(--shadow)]" : "text-ink-2"}`}
|
||||
>
|
||||
<span
|
||||
className={`grid h-[34px] w-[34px] flex-none place-items-center rounded-lg border ${
|
||||
on
|
||||
? "border-accent/40 bg-accent-wash text-accent"
|
||||
: "border-rule bg-panel text-ink-3"
|
||||
on ? "border-accent/40 bg-accent-wash text-accent" : "border-rule bg-panel text-ink-3"
|
||||
}`}
|
||||
>
|
||||
{o.icon}
|
||||
</span>
|
||||
<span className="flex flex-col leading-tight">
|
||||
<span className="text-[0.92rem] font-bold">{o.title}</span>
|
||||
<span
|
||||
className={`text-[0.72rem] font-medium ${
|
||||
on ? "text-accent" : "text-ink-3"
|
||||
}`}
|
||||
>
|
||||
{o.sub}
|
||||
</span>
|
||||
<span className={`text-[0.72rem] font-medium ${on ? "text-accent" : "text-ink-3"}`}>{o.sub}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`relative ml-auto h-[18px] w-[18px] flex-none rounded-full border-2 ${
|
||||
on ? "border-accent bg-accent" : "border-rule"
|
||||
}`}
|
||||
>
|
||||
{on && (
|
||||
<span className="absolute inset-[3px] rounded-full bg-accent-ink" />
|
||||
)}
|
||||
<span className={`relative ml-auto h-[18px] w-[18px] flex-none rounded-full border-2 ${on ? "border-accent bg-accent" : "border-rule"}`}>
|
||||
{on && <span className="absolute inset-[3px] rounded-full bg-accent-ink" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -636,41 +492,28 @@ function TierCard({
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const base = plan.base_limits.max_servers;
|
||||
const servers =
|
||||
base === -1 ? "Unlimited servers" : `${base} server${base === 1 ? "" : "s"} included`;
|
||||
const servers = base === -1 ? "Unlimited servers" : `${base} server${base === 1 ? "" : "s"} included`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={`relative flex flex-col gap-3 rounded-xl border bg-panel p-4 text-left transition-[border-color,box-shadow] ${
|
||||
selected
|
||||
? "border-accent shadow-[0_0_0_1px_var(--accent)]"
|
||||
: "border-rule hover:border-accent/50"
|
||||
selected ? "border-accent shadow-[0_0_0_1px_var(--accent)]" : "border-rule hover:border-accent/50"
|
||||
}`}
|
||||
>
|
||||
{plan.tier === "professional" && (
|
||||
<span className="absolute -top-2 right-3 rounded-full bg-accent px-2 py-0.5 text-[0.6rem] font-bold uppercase tracking-[0.08em] text-accent-ink">
|
||||
Most popular
|
||||
</span>
|
||||
<span className="absolute -top-2 right-3 rounded-full bg-accent px-2 py-0.5 text-[0.6rem] font-bold uppercase tracking-[0.08em] text-accent-ink">Most popular</span>
|
||||
)}
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="text-[1.05rem] font-extrabold tracking-[-0.02em]">{plan.name}</span>
|
||||
<span
|
||||
className={`relative h-4 w-4 flex-none rounded-full border-2 ${
|
||||
selected ? "border-accent bg-accent" : "border-rule"
|
||||
}`}
|
||||
>
|
||||
<span className={`relative h-4 w-4 flex-none rounded-full border-2 ${selected ? "border-accent bg-accent" : "border-rule"}`}>
|
||||
{selected && <span className="absolute inset-[3px] rounded-full bg-accent-ink" />}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="text-[1.5rem] font-extrabold tracking-[-0.03em] tabular-nums">
|
||||
{headline ?? "—"}
|
||||
</span>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
{plan.tier === "free" ? "forever" : cycleLabel}
|
||||
</span>
|
||||
<span className="text-[1.5rem] font-extrabold tracking-[-0.03em] tabular-nums">{headline ?? "—"}</span>
|
||||
<span className="text-[0.72rem] text-ink-3">{plan.tier === "free" ? "forever" : cycleLabel}</span>
|
||||
</span>
|
||||
<ul className="grid gap-1.5 text-[0.8rem] text-ink-2">
|
||||
<FeatureLine on>{servers}</FeatureLine>
|
||||
@@ -679,11 +522,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>
|
||||
);
|
||||
})}
|
||||
@@ -709,10 +548,7 @@ function supportLabel(level: string) {
|
||||
function FeatureLine({ on, children }: { on: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<li className={`flex items-start gap-2 ${on ? "" : "text-ink-3"}`}>
|
||||
<span
|
||||
className={`mt-0.5 flex-none ${on ? "text-valid" : "text-ink-3"}`}
|
||||
aria-hidden
|
||||
>
|
||||
<span className={`mt-0.5 flex-none ${on ? "text-valid" : "text-ink-3"}`} aria-hidden>
|
||||
{on ? (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
@@ -728,23 +564,9 @@ function FeatureLine({ on, children }: { on: boolean; children: React.ReactNode
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
title,
|
||||
desc,
|
||||
dim,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
desc: string;
|
||||
dim?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
function Row({ title, desc, dim, children }: { title: string; desc: string; dim?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between gap-4 border-b border-rule-soft py-3.5 first:pt-0 last:border-0 last:pb-0 ${
|
||||
dim ? "opacity-55" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between gap-4 border-b border-rule-soft py-3.5 first:pt-0 last:border-0 last:pb-0 ${dim ? "opacity-55" : ""}`}>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-[0.9rem] font-semibold">{title}</h4>
|
||||
{desc && <p className="text-[0.78rem] text-ink-3">{desc}</p>}
|
||||
@@ -754,17 +576,7 @@ function Row({
|
||||
);
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
function Stepper({ value, min, max, onChange }: { value: number; min: number; max: number; onChange: (v: number) => void }) {
|
||||
const clamp = (v: number) => Math.min(max, Math.max(min, v));
|
||||
return (
|
||||
<div className="inline-flex items-center overflow-hidden rounded-lg border border-rule">
|
||||
@@ -804,15 +616,9 @@ function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative h-6 w-[42px] flex-none rounded-full transition-colors ${
|
||||
checked ? "bg-accent" : "bg-rule"
|
||||
}`}
|
||||
className={`relative h-6 w-[42px] flex-none rounded-full transition-colors ${checked ? "bg-accent" : "bg-rule"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-[3px] h-[18px] w-[18px] rounded-full bg-white shadow transition-[left] ${
|
||||
checked ? "left-[21px]" : "left-[3px]"
|
||||
}`}
|
||||
/>
|
||||
<span className={`absolute top-[3px] h-[18px] w-[18px] rounded-full bg-white shadow transition-[left] ${checked ? "left-[21px]" : "left-[3px]"}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -838,9 +644,7 @@ function Receipt({
|
||||
<div className="flex items-center justify-between gap-3 py-3 text-[0.85rem]">
|
||||
<span className="text-ink-2">
|
||||
{plan?.name ?? "Free"} plan
|
||||
<small className="block text-[0.72rem] text-ink-3">
|
||||
{plan?.base_limits.max_servers ?? 1} server · community support
|
||||
</small>
|
||||
<small className="block text-[0.72rem] text-ink-3">{plan?.base_limits.max_servers ?? 1} server · community support</small>
|
||||
</span>
|
||||
<span className="font-mono font-semibold tabular-nums text-valid">£0</span>
|
||||
</div>
|
||||
@@ -851,9 +655,7 @@ function Receipt({
|
||||
// Label each real line item from the catalogue, and price it from Paddle.
|
||||
const base = plan?.base_limits.max_servers ?? 0;
|
||||
const extra = base === -1 ? 0 : Math.max(0, choice.servers - base);
|
||||
const rows = options.catalogue.filter(
|
||||
(r) => r.deployment === dep && r.tier === choice.tier,
|
||||
);
|
||||
const rows = options.catalogue.filter((r) => r.deployment === dep && r.tier === choice.tier);
|
||||
const idFor = (predicate: (r: CatalogueRow) => boolean) => {
|
||||
const row = rows.find(predicate);
|
||||
return row?.price_ids?.[options.env]?.[choice.term] ?? "";
|
||||
@@ -884,27 +686,18 @@ function Receipt({
|
||||
<div className="px-4">
|
||||
<div className="grid">
|
||||
{lines.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex justify-between gap-3 border-b border-dashed border-rule-soft py-2.5 text-[0.85rem] last:border-0"
|
||||
>
|
||||
<div key={i} className="flex justify-between gap-3 border-b border-dashed border-rule-soft py-2.5 text-[0.85rem] last:border-0">
|
||||
<span className="text-ink-2">
|
||||
{l.label}
|
||||
{l.sub && (
|
||||
<small className="block text-[0.72rem] text-ink-3">{l.sub}</small>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono font-semibold tabular-nums">
|
||||
{l.value ?? "—"}
|
||||
{l.sub && <small className="block text-[0.72rem] text-ink-3">{l.sub}</small>}
|
||||
</span>
|
||||
<span className="font-mono font-semibold tabular-nums">{l.value ?? "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline justify-between border-t border-rule pt-3">
|
||||
<span className="text-[0.85rem]">Total</span>
|
||||
<span className="text-[1.4rem] font-extrabold tabular-nums">
|
||||
{priced && price?.total ? price.total : "—"}
|
||||
</span>
|
||||
<span className="text-[1.4rem] font-extrabold tabular-nums">{priced && price?.total ? price.total : "—"}</span>
|
||||
</div>
|
||||
<p className="pb-3 pt-0.5 text-[0.72rem] text-ink-3">
|
||||
{priced
|
||||
@@ -921,26 +714,14 @@ function Receipt({
|
||||
);
|
||||
}
|
||||
|
||||
function Cta({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = "solid",
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: "solid" | "line";
|
||||
}) {
|
||||
function Cta({ label, onClick, disabled, variant = "solid" }: { label: string; onClick: () => void; disabled?: boolean; variant?: "solid" | "line" }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`rounded-[9px] px-3 py-3 text-[0.9rem] font-bold transition-[filter] hover:brightness-[1.06] disabled:opacity-40 disabled:hover:brightness-100 ${
|
||||
variant === "solid"
|
||||
? "bg-accent text-accent-ink"
|
||||
: "border border-accent bg-panel text-accent"
|
||||
variant === "solid" ? "bg-accent text-accent-ink" : "border border-accent bg-panel text-accent"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
|
||||
Reference in New Issue
Block a user