feat: Updated purchase page
Server Deploy / deploy (push) Successful in 4m40s

This commit is contained in:
2026-07-27 14:59:43 +01:00
parent 0a86167c44
commit 4ad68e3ac4
14 changed files with 1443 additions and 187 deletions
+13 -5
View File
@@ -70,10 +70,15 @@ export default function OverviewPage() {
title="Overview"
subtitle={subtitle}
actions={
!hasFree && live.length > 0 ? (
<LinkButton variant="line" href="/instances/new">
Create a free instance
</LinkButton>
live.length > 0 ? (
<>
{!hasFree && (
<LinkButton variant="line" href="/instances/new">
Create a free instance
</LinkButton>
)}
<LinkButton href="/purchase">Buy a plan</LinkButton>
</>
) : undefined
}
record={[
@@ -96,7 +101,10 @@ export default function OverviewPage() {
server, and link it here to get your licence file.
</p>
<div className="flex flex-wrap gap-2.5">
<LinkButton href="/instances/new">Create a free instance</LinkButton>
<LinkButton href="/purchase">Buy a plan</LinkButton>
<LinkButton variant="line" href="/instances/new">
Create a free instance
</LinkButton>
<LinkButton variant="line" href="/instances/link">
Link an install
</LinkButton>
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -2,15 +2,15 @@ import type { Metadata } from "next";
import { PurchaseForm } from "./PurchaseForm";
import { PageHeader } from "@/components/PageHeader";
export const metadata: Metadata = { title: "Buy a self-hosted plan" };
export const metadata: Metadata = { title: "Buy a plan" };
export default function PurchasePage() {
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Self-hosted plan"
subtitle="Name your instance, choose a plan, and pay. After payment, paste the ID your install reports to receive its licence. Self-hosted is billed annually."
title="Choose your plan"
subtitle="Configure the instance, see exactly what you'll be charged, then pay. Nothing is billed until you confirm at checkout."
/>
<PurchaseForm />
</div>
+3
View File
@@ -319,6 +319,9 @@ export const api = {
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
createSelfHosted: (name: string) =>
post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
// Paid cloud: provisions the real instance the paid webhook then licenses.
createCloudCheckout: (name: string) =>
post<{ instance_id: string }>("/api/instances/cloud", { name }),
updateEntitlement: (
id: string,
body: { tier: Tier; term: Term; servers: number; features: string[] },
+59
View File
@@ -15,3 +15,62 @@ export function initPaddle(): Promise<Paddle | undefined> {
}
return cached;
}
export interface PricedLine {
priceId: string;
/* Already localised and currency-formatted by Paddle, e.g. "£39.00". The line
* total for the quantity, not the unit price. */
total: string;
unit: string;
}
export interface PricePreview {
currency: string;
/* Grand total, formatted. */
total: string;
lines: Record<string, PricedLine>;
}
/*
* previewPrices asks Paddle for the real localised prices of a set of line items,
* so the order summary shows what the customer will actually pay rather than a
* hardcoded number that would drift from the dashboard.
*
* It returns null when Paddle is unavailable or a price cannot be previewed (an
* unconfigured sandbox price, an ad blocker). The caller falls back to showing
* the line items without amounts rather than a wrong total — the real figure
* still appears in the checkout overlay, which is the authority.
*/
export async function previewPrices(
items: { priceId: string; quantity: number }[],
): Promise<PricePreview | null> {
if (items.length === 0) return { currency: "", total: "", lines: {} };
const paddle = await initPaddle();
if (!paddle) return null;
try {
const res = await paddle.PricePreview({
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
});
const currency = res.data.currencyCode;
const lines: Record<string, PricedLine> = {};
// Paddle gives per-line totals but no grand total, so sum the raw minor
// units and format once. The checkout overlay is the authority; this is
// the honest preview beside it.
let subtotalMinor = 0;
for (const li of res.data.details.lineItems) {
lines[li.price.id] = {
priceId: li.price.id,
total: li.formattedTotals.subtotal,
unit: li.formattedUnitTotals.subtotal,
};
subtotalMinor += Number.parseInt(li.totals.subtotal, 10) || 0;
}
const total = new Intl.NumberFormat(undefined, {
style: "currency",
currency,
}).format(subtotalMinor / 100);
return { currency, total, lines };
} catch {
return null;
}
}