forked from molecule-ai/molecule-core
feat(canvas): Phase 5 — credit balance pill + low-balance banner
Adds the UI surface for the credit system to /orgs: - CreditsPill next to each org row. Tone shifts from zinc → amber at 10% of plan to red at zero. - LowCreditsBanner appears under the pill for running orgs when the balance crosses thresholds: overage_used > 0 → "overage active", balance <= 0 → "out of credits, upgrade", trial tail → "trial almost out". - Pure helpers extracted to lib/credits.ts so formatCredits, pillTone, and bannerKind are unit-tested without jsdom. Backend List query now returns credits_balance / plan_monthly_credits / overage_used_credits / overage_cap_credits so no second round-trip is needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
24e8c5affd
commit
18894bebe8
@ -20,6 +20,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchSession, redirectToLogin, type Session } from "@/lib/auth";
|
||||
import { PLATFORM_URL } from "@/lib/api";
|
||||
import { formatCredits, pillTone, bannerKind } from "@/lib/credits";
|
||||
|
||||
type OrgStatus = "awaiting_payment" | "provisioning" | "running" | "failed" | string;
|
||||
|
||||
@ -31,6 +32,13 @@ interface Org {
|
||||
status: OrgStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Credit system fields. Present whenever the control plane's models
|
||||
// serializer runs — tests + older snapshot JSONs may not have them,
|
||||
// so treat as optional in TS and fall back to 0 at render time.
|
||||
credits_balance?: number;
|
||||
plan_monthly_credits?: number;
|
||||
overage_used_credits?: number;
|
||||
overage_cap_credits?: number;
|
||||
}
|
||||
|
||||
export default function OrgsPage() {
|
||||
@ -174,6 +182,10 @@ function OrgRow({ org }: { org: Org }) {
|
||||
<div className="text-sm text-zinc-400">
|
||||
{org.slug} · <StatusLabel status={org.status} /> · {org.plan || "free"}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<CreditsPill org={org} />
|
||||
<LowCreditsBanner org={org} />
|
||||
</div>
|
||||
</div>
|
||||
<OrgCTA org={org} />
|
||||
</div>
|
||||
@ -181,6 +193,48 @@ function OrgRow({ org }: { org: Org }) {
|
||||
);
|
||||
}
|
||||
|
||||
// CreditsPill renders the balance with a tone that matches the banner
|
||||
// severity. Format + color logic lives in @/lib/credits so it can be
|
||||
// tested without mounting React.
|
||||
function CreditsPill({ org }: { org: Org }) {
|
||||
const balance = org.credits_balance ?? 0;
|
||||
return (
|
||||
<span className={`rounded border px-2 py-0.5 text-xs ${pillTone(org)}`} title="Credit balance">
|
||||
{formatCredits(balance)} credits
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// LowCreditsBanner is a one-liner that only renders when the balance
|
||||
// is low AND the org is running. bannerKind() picks which message to
|
||||
// show; render just dispatches on it.
|
||||
function LowCreditsBanner({ org }: { org: Org }) {
|
||||
if (org.status !== "running") return null;
|
||||
const kind = bannerKind(org);
|
||||
if (kind === "none") return null;
|
||||
if (kind === "overage") {
|
||||
const used = (org.overage_used_credits ?? 0).toLocaleString();
|
||||
return (
|
||||
<span className="text-xs text-amber-300">
|
||||
overage active · {used} used
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (kind === "out-of-credits") {
|
||||
return (
|
||||
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-red-300 underline">
|
||||
out of credits — upgrade to keep running
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// trial-tail
|
||||
return (
|
||||
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-amber-300 underline">
|
||||
trial almost out
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLabel({ status }: { status: OrgStatus }) {
|
||||
const cls =
|
||||
status === "running"
|
||||
|
||||
53
canvas/src/lib/__tests__/credits.test.ts
Normal file
53
canvas/src/lib/__tests__/credits.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatCredits, pillTone, bannerKind } from "@/lib/credits";
|
||||
|
||||
describe("formatCredits", () => {
|
||||
it("renders raw numbers under 10k", () => {
|
||||
expect(formatCredits(0)).toBe("0");
|
||||
expect(formatCredits(42)).toBe("42");
|
||||
expect(formatCredits(9999)).toBe("9999");
|
||||
});
|
||||
it("compacts 10k+ with one decimal", () => {
|
||||
expect(formatCredits(12345)).toBe("12.3k");
|
||||
expect(formatCredits(30000)).toBe("30.0k");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pillTone", () => {
|
||||
it("zinc for healthy balance", () => {
|
||||
expect(pillTone({ credits_balance: 5000, plan_monthly_credits: 9000 })).toContain("zinc");
|
||||
});
|
||||
it("amber when under 10% of monthly", () => {
|
||||
expect(pillTone({ credits_balance: 500, plan_monthly_credits: 9000 })).toContain("amber");
|
||||
});
|
||||
it("red at zero or negative", () => {
|
||||
expect(pillTone({ credits_balance: 0, plan_monthly_credits: 9000 })).toContain("red");
|
||||
expect(pillTone({ credits_balance: -1, plan_monthly_credits: 9000 })).toContain("red");
|
||||
});
|
||||
it("trial (monthly=0) is healthy until balance hits zero", () => {
|
||||
// No paid plan → no ratio reference; only "0" means empty.
|
||||
expect(pillTone({ credits_balance: 50, plan_monthly_credits: 0 })).toContain("zinc");
|
||||
expect(pillTone({ credits_balance: 0, plan_monthly_credits: 0 })).toContain("red");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bannerKind", () => {
|
||||
it("overage wins when overage_used > 0", () => {
|
||||
// Even a healthy balance gets "overage" so the banner reminds the
|
||||
// paying customer that extra charges are accruing.
|
||||
expect(bannerKind({ credits_balance: 3000, plan_monthly_credits: 9000, overage_used_credits: 500 }))
|
||||
.toBe("overage");
|
||||
});
|
||||
it("out-of-credits when balance <= 0 and no overage", () => {
|
||||
expect(bannerKind({ credits_balance: 0, plan_monthly_credits: 9000 })).toBe("out-of-credits");
|
||||
});
|
||||
it("trial-tail when plan is free and balance is low", () => {
|
||||
expect(bannerKind({ credits_balance: 50, plan_monthly_credits: 0 })).toBe("trial-tail");
|
||||
});
|
||||
it("none for healthy paid balance", () => {
|
||||
expect(bannerKind({ credits_balance: 8000, plan_monthly_credits: 9000 })).toBe("none");
|
||||
});
|
||||
it("none for a trial that still has plenty of credits", () => {
|
||||
expect(bannerKind({ credits_balance: 400, plan_monthly_credits: 0 })).toBe("none");
|
||||
});
|
||||
});
|
||||
53
canvas/src/lib/credits.ts
Normal file
53
canvas/src/lib/credits.ts
Normal file
@ -0,0 +1,53 @@
|
||||
// credits.ts — small pure helpers for rendering credit state on /orgs.
|
||||
// Kept out of page.tsx so unit tests can exercise the formatting +
|
||||
// banner-kind logic in node (no jsdom) without needing to mount React.
|
||||
|
||||
export type CreditsBannerKind =
|
||||
| "none"
|
||||
| "overage" // paid plan has started burning overage this period
|
||||
| "out-of-credits" // balance 0, not on a paid plan (trial ran out)
|
||||
| "trial-tail"; // balance low but not zero, no paid plan yet
|
||||
|
||||
export interface CreditsFields {
|
||||
credits_balance?: number;
|
||||
plan_monthly_credits?: number;
|
||||
overage_used_credits?: number;
|
||||
}
|
||||
|
||||
// formatCredits renders an int as a compact string. 9999 → "9999",
|
||||
// 12345 → "12.3k". Keeps the balance pill narrow enough to fit on one
|
||||
// line next to the org slug even for the Scale plan's 30k grant.
|
||||
export function formatCredits(n: number): string {
|
||||
if (n < 10_000) return String(n);
|
||||
return `${(n / 1000).toFixed(1)}k`;
|
||||
}
|
||||
|
||||
// pillTone returns the tailwind classnames that color the balance pill.
|
||||
// Empty / exhausted → red; within 10% of zero → amber; else zinc. The
|
||||
// 10% threshold matches the banner trigger — one consistent "low"
|
||||
// signal so the pill and banner agree.
|
||||
export function pillTone(fields: CreditsFields): string {
|
||||
const balance = fields.credits_balance ?? 0;
|
||||
const monthly = fields.plan_monthly_credits ?? 0;
|
||||
if (balance <= 0) return "bg-red-950 text-red-200 border-red-800";
|
||||
const ratio = monthly > 0 ? balance / monthly : 1;
|
||||
if (ratio < 0.1) return "bg-amber-950 text-amber-200 border-amber-800";
|
||||
return "bg-zinc-800 text-zinc-200 border-zinc-700";
|
||||
}
|
||||
|
||||
// bannerKind picks which (if any) banner to show under the balance
|
||||
// pill. Precedence:
|
||||
// 1. overage_used > 0 → "overage" (even if balance is refreshed)
|
||||
// 2. balance <= 0 → "out-of-credits"
|
||||
// 3. trial + low tail → "trial-tail"
|
||||
// 4. otherwise → "none"
|
||||
export function bannerKind(fields: CreditsFields): CreditsBannerKind {
|
||||
const balance = fields.credits_balance ?? 0;
|
||||
const monthly = fields.plan_monthly_credits ?? 0;
|
||||
const overageUsed = fields.overage_used_credits ?? 0;
|
||||
|
||||
if (overageUsed > 0) return "overage";
|
||||
if (balance <= 0) return "out-of-credits";
|
||||
if (monthly === 0 && balance < 100) return "trial-tail";
|
||||
return "none";
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user