pr_01m47d24b0e6n91zwymwxg0vpx/apps/web/app/components/usage-card.tsx

148 lines6,083 bytesCodeBlame
1import { ArrowRight, ChevronDown, CreditCard } from "lucide-react";
2import { useState } from "react";
3import { Link } from "react-router";
4
5import { ButtonLink } from "./ui";
6import { type UsageGlance, dollars, share, usageTask } from "../lib/billing";
7
8/** Line items shown before Show more. */
9const FIRST_LINES = 3;
10
11/** What the card says the workspace is on, beside its spend. */
12const STANDING: Record<UsageGlance["kind"], string> = {
13 beta: "Free for now",
14 comped: "Comped",
15 plan: "g1t plan",
16 trial: "Trial",
17 forge: "Free",
18};
19
20/**
21 * A thin meter of `used` against `of`. The trial warns near its end; the
22 * plan's included usage running out is ordinary, as on-demand takes over.
23 */
24function Bar({ used, of, warns }: { used: number; of: number; warns: boolean }) {
25 const part = share(used, of);
26 const tone = !warns ? "bg-accent" : part >= 1 ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-accent";
27 return (
28 <span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-line" role="presentation">
29 <span className={`block h-full rounded-full ${tone}`} style={{ width: `${Math.max(part * 100, part > 0 ? 2 : 0)}%` }} />
30 </span>
31 );
32}
33
34/**
35 * The workspace's month on its overview: what it has spent, what pays
36 * first and how much of it is left, what was charged past that, and what
37 * it went on, with the way to Billing.
38 */
39export function UsageCard({ slug, glance, owner }: { slug: string; glance: UsageGlance; owner: boolean }) {
40 const [all, setAll] = useState(false);
41 const lines = all ? glance.lines : glance.lines.slice(0, FIRST_LINES);
42 const more = glance.lines.length - FIRST_LINES;
43 return (
44 <section aria-labelledby="usage-card" className="rounded-xl border border-line bg-surface">
45 <div className="p-5 pb-4">
46 <div className="flex items-baseline justify-between gap-3">
47 <h2 id="usage-card" className="font-medium">
48 <Link to={`/${slug}/-/usage`} className="hover:underline">
49 Usage
50 </Link>
51 </h2>
52 <span className="text-xs text-faint">This month</span>
53 </div>
54 <p className="mt-3 flex items-baseline justify-between gap-3">
55 <span className="text-2xl font-semibold tracking-tight tabular-nums">{dollars(glance.spentMicros)}</span>
56 <span
57 className={`rounded-full px-2 py-0.5 text-xs ${
58 glance.kind === "forge" ? "border border-line text-muted" : "bg-accent/15 text-accent"
59 }`}
60 >
61 {STANDING[glance.kind]}
62 </span>
63 </p>
64 <p className="mt-1 text-xs text-faint">
65 {glance.kind === "beta"
66 ? "At cost. Nothing is charged while g1t is being built out."
67 : glance.kind === "comped"
68 ? "Recorded at what it costs. Nothing is charged to this workspace."
69 : glance.kind === "forge"
70 ? "The forge is free. Agents, workflows and deployments need the plan or the trial."
71 : "Charged so far, from the 1st."}
72 </p>
73
74 {glance.credit && (
75 <div className="mt-4 text-sm">
76 <div className="flex justify-between gap-3">
77 <span className="text-muted">{glance.credit.label}</span>
78 <span className="tabular-nums">
79 {dollars(glance.credit.usedMicros)}
80 <span className="text-faint"> / {dollars(glance.credit.ofMicros)}</span>
81 </span>
82 </div>
83 <Bar used={glance.credit.usedMicros} of={glance.credit.ofMicros} warns={glance.kind === "trial"} />
84 </div>
85 )}
86 {glance.onDemand && (
87 <div className="mt-3 flex justify-between gap-3 text-sm">
88 <span className="text-muted">On-demand charges</span>
89 <span className="tabular-nums">
90 {dollars(glance.onDemand.micros)}
91 {glance.onDemand.limitMicros != null && (
92 <span className="text-faint"> / {dollars(glance.onDemand.limitMicros, 0)}</span>
93 )}
94 </span>
95 </div>
96 )}
97 </div>
98
99 {glance.lines.length > 0 && (
100 <div className="border-t border-line px-5 py-3">
101 <ul className="space-y-2 text-sm">
102 {lines.map((line) => (
103 <li key={line.key} className="flex items-center gap-2">
104 <span className="size-2 shrink-0 rounded-sm" style={{ background: usageTask(line.key).color }} />
105 <span className="min-w-0 grow truncate text-muted">{line.label}</span>
106 <span className="shrink-0 font-mono text-xs tabular-nums">{dollars(line.micros)}</span>
107 </li>
108 ))}
109 </ul>
110 {more > 0 && (
111 <button
112 type="button"
113 onClick={() => setAll((open) => !open)}
114 aria-expanded={all}
115 className="mt-2 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"
116 >
117 <ChevronDown size={13} className={`transition-transform ${all ? "rotate-180" : ""}`} />
118 {all ? "Show less" : `Show ${more} more`}
119 </button>
120 )}
121 </div>
122 )}
123
124 <div className="flex flex-wrap items-center justify-between gap-2 border-t border-line px-5 py-3">
125 <Link to={`/${slug}/-/usage`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
126 Usage, run by run <ArrowRight size={12} />
127 </Link>
128 {glance.kind === "forge" || glance.kind === "trial" ? (
129 owner ? (
130 <ButtonLink to={`/${slug}/-/billing#plan`} variant="accent">
131 Start the plan
132 </ButtonLink>
133 ) : (
134 <ButtonLink to={`/${slug}/-/billing`} variant="quiet">
135 <CreditCard size={14} />
136 Billing
137 </ButtonLink>
138 )
139 ) : (
140 <ButtonLink to={`/${slug}/-/billing`} variant="quiet">
141 <CreditCard size={14} />
142 Billing
143 </ButtonLink>
144 )}
145 </div>
146 </section>
147 );
148}