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