fix: Fixed oidc purchase description
Server Deploy / deploy (push) Successful in 37s

This commit is contained in:
2026-07-27 15:26:34 +01:00
parent 4ad68e3ac4
commit fdfe8e8e46
@@ -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}