feat(adminsite): self-hosted purchase, checkout overlay, billing portal, client line-item builder
Purchase flow: name -> placeholder -> configure via the shipped PlanConfigurator -> Paddle overlay with custom_data -> paste install UUID to link and issue. lineItemsFor mirrors the Go catalogue.LineItems/billable exactly (base included in exactly one place). ManageBillingButton opens the hosted portal. Paddle token and env are baked into the build, never fetched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { initPaddle } from "@/lib/paddle";
|
||||
|
||||
/* Opens the Paddle overlay with the resolved line items and custom_data. The
|
||||
* items come from the configurator via catalogue pricing; custom_data is what
|
||||
* lets the webhook route without a lookup table. */
|
||||
export function CheckoutButton({
|
||||
items,
|
||||
customData,
|
||||
disabled,
|
||||
label = "Continue to payment",
|
||||
}: {
|
||||
items: { priceId: string; quantity: number }[];
|
||||
customData: { account_id: string; instance_id: string };
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function open() {
|
||||
setBusy(true);
|
||||
const paddle = await initPaddle();
|
||||
setBusy(false);
|
||||
paddle?.Checkout.open({
|
||||
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
|
||||
customData,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy || items.length === 0}
|
||||
onClick={open}
|
||||
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
|
||||
>
|
||||
{busy ? "Opening…" : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import clsx from "clsx";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Instance, type License } from "@/lib/api";
|
||||
import { ManageBillingButton } from "@/components/ManageBillingButton";
|
||||
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { StatePill } from "./StatePill";
|
||||
import { Button, LinkButton } from "./Button";
|
||||
@@ -252,6 +253,8 @@ export function InstanceRecord({
|
||||
{renew.isPending ? "Renewing…" : "Renew"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{cloud && <ManageBillingButton />}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
|
||||
/* Opens Paddle's hosted customer portal in a new tab. The account learns its
|
||||
* paddle_customer_id from its first paid subscription's webhook, so this reports
|
||||
* a plain message rather than erroring when there is no billing account yet. */
|
||||
export function ManageBillingButton() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
|
||||
async function open() {
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
const { url } = await api.billingPortal();
|
||||
window.open(url, "_blank", "noopener");
|
||||
} catch (e) {
|
||||
setNote(e instanceof ApiError ? e.message : "Could not open billing.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Button type="button" variant="line" onClick={open} disabled={busy}>
|
||||
{busy ? "Opening…" : "Manage billing"}
|
||||
</Button>
|
||||
{note && <span className="text-[0.78rem] text-ink-3">{note}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user