pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/components/billing.tsx

770 lines32,198 bytesCodeBlame
1/**
2 * The billing page's sections: the spend-spike banner (also in the app
3 * shell), alerts, the g1t plan, the trial, the spend limit and its range,
4 * Raise my limit, Prepay, the run and issue caps, and "Spent more than you
5 * meant to?". Each posts to the billing route's action with an `intent`.
6 */
7import { AlertTriangle, ArrowUpRight, CreditCard, Gauge, Landmark, OctagonX, Play, ShieldCheck, Sparkles } from "lucide-react";
8import type { ReactNode } from "react";
9import { Form } from "react-router";
10
11import type { DeployUsage, Entitlements, FeatureState, Limit, LimitRequest, UsageAlert } from "@g1t/contracts";
12import { DEPLOYMENTS_ALLOWANCE } from "@g1t/contracts";
13
14import {
15 CAPS,
16 PREPAY,
17 type PlanStatus,
18 alertText,
19 alertTone,
20 dollars,
21 requestStatus,
22 share,
23 spendRange,
24 wholeDollars,
25} from "../lib/billing";
26import { Button, ErrorText } from "./ui";
27import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
28
29/** An error the action returned, for the section it belongs to. */
30export type SectionError = { section: string; error: string } | null | undefined;
31
32function errorFor(error: SectionError, section: string): string | undefined {
33 return error && error.section === section ? error.error : undefined;
34}
35
36const CONTROL =
37 "rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm tabular-nums outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
38
39/** A dollar amount field: a `$` and a number. */
40function DollarInput({ name, defaultValue, placeholder, label }: { name: string; defaultValue?: string; placeholder?: string; label: string }) {
41 return (
42 <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent-dim">
43 <span className="text-sm text-muted">$</span>
44 <input
45 name={name}
46 inputMode="decimal"
47 aria-label={label}
48 defaultValue={defaultValue}
49 placeholder={placeholder}
50 autoComplete="off"
51 data-1p-ignore
52 data-lpignore="true"
53 className="w-24 bg-transparent px-1 py-1.5 text-sm tabular-nums outline-none placeholder:text-faint"
54 />
55 </span>
56 );
57}
58
59/** A bordered card with a heading, a line about it, and a tone. */
60export function Card({
61 id,
62 title,
63 icon,
64 aside,
65 about,
66 tone = "plain",
67 children,
68}: {
69 id?: string;
70 title: ReactNode;
71 icon?: ReactNode;
72 aside?: ReactNode;
73 about?: ReactNode;
74 tone?: "plain" | "accent" | "warn" | "danger";
75 children?: ReactNode;
76}) {
77 const tones = {
78 plain: "border-line bg-surface",
79 accent: "border-accent/40 bg-accent/5",
80 warn: "border-warn/40 bg-warn/5",
81 danger: "border-danger/40 bg-danger/5",
82 };
83 return (
84 <section id={id} className={`mb-6 scroll-mt-20 rounded-xl border p-5 ${tones[tone]}`}>
85 <div className="flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap">
86 <div className="min-w-0 basis-full sm:basis-0 sm:flex-1">
87 <h2 className="flex flex-wrap items-center gap-2 font-medium">
88 {icon && <span className="text-accent">{icon}</span>}
89 {title}
90 </h2>
91 {about && <div className="mt-1 max-w-2xl text-sm text-muted">{about}</div>}
92 </div>
93 {aside}
94 </div>
95 {children}
96 </section>
97 );
98}
99
100/** One meter: how much of something is used. */
101export function Meter({ used, of, label, detail }: { used: number; of: number | null; label: string; detail?: ReactNode }) {
102 const part = share(used, of);
103 const bar = of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-accent";
104 return (
105 <div className="mt-4">
106 <div className="flex flex-wrap justify-between gap-x-4 text-sm">
107 <span className="text-muted">{label}</span>
108 <span className="tabular-nums">
109 {dollars(used)}
110 {of != null && <span className="text-faint"> of {dollars(of)}</span>}
111 </span>
112 </div>
113 <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
114 <div className={`h-full ${bar}`} style={{ width: `${Math.max(part * 100, part > 0 ? 2 : 0)}%` }} />
115 </div>
116 {detail && <p className="mt-1.5 text-xs text-faint">{detail}</p>}
117 </div>
118 );
119}
120
121// --- Spike ------------------------------------------------------------------------
122
123/**
124 * Compute paused by a spend spike, with the owners' choice: keep going for
125 * 24 hours, or stop. Also a hold by g1t, which only g1t lifts. Posts to the
126 * workspace's billing page, so it works from anywhere in the app.
127 */
128export function SpikeBanner({
129 slug,
130 entitlements,
131 owner,
132 compact = false,
133}: {
134 slug: string;
135 entitlements: Pick<Entitlements, "paused" | "spike">;
136 owner: boolean;
137 compact?: boolean;
138}) {
139 const spike = entitlements.spike?.status === "open" ? entitlements.spike : null;
140 if (!spike && !entitlements.paused) return null;
141 const text = spike
142 ? `Spending spiked: ${dollars(spike.hourMicros)} in the last hour, against a usual ${dollars(spike.averageMicros)}. New agents, checks and builds wait until an owner decides; work already running finishes.`
143 : `g1t paused new compute for this workspace: ${entitlements.paused}`;
144 return (
145 <div
146 id="spike"
147 role="alert"
148 className={
149 compact
150 ? "flex flex-wrap items-center justify-center gap-x-4 gap-y-2 border-b border-danger/30 bg-danger/10 px-4 py-2 text-sm"
151 : "mb-6 scroll-mt-20 rounded-xl border border-danger/40 bg-danger/5 p-5"
152 }
153 >
154 <p className={compact ? "flex items-center gap-2" : "flex gap-2.5 text-sm"}>
155 <AlertTriangle size={compact ? 15 : 17} className="shrink-0 text-danger" />
156 <span>{compact ? (spike ? `${slug}: spending spiked, so new compute is paused.` : `${slug}: new compute is paused.`) : text}</span>
157 </p>
158 {spike && owner ? (
159 <Form method="post" action={`/${slug}/-/billing`} className={compact ? "flex gap-2" : "mt-4 flex flex-wrap gap-2"}>
160 <input type="hidden" name="intent" value="spike" />
161 <Button variant="accent" type="submit" name="decision" value="keep">
162 <Play size={14} />
163 Keep going
164 </Button>
165 <Button variant="quiet" type="submit" name="decision" value="stop">
166 <OctagonX size={14} />
167 Stop
168 </Button>
169 </Form>
170 ) : spike ? (
171 <p className={compact ? "text-xs text-muted" : "mt-3 text-sm text-muted"}>An owner chooses Keep going or Stop on Billing.</p>
172 ) : (
173 <p className={compact ? "text-xs text-muted" : "mt-3 text-sm text-muted"}>
174 Write to <a href="mailto:hey@flagon.io" className="underline underline-offset-2">hey@flagon.io</a> to have it looked at.
175 </p>
176 )}
177 {!compact && spike && (
178 <p className="mt-3 text-xs text-faint">
179 Keep going lets work start again for 24 hours, unless spending doubles again first. Stop keeps new compute paused until you
180 choose Keep going.
181 </p>
182 )}
183 </div>
184 );
185}
186
187// --- Alerts -----------------------------------------------------------------------
188
189export function Alerts({ alerts }: { alerts: UsageAlert[] }) {
190 const shown = alerts.filter((alert) => alert.level >= 50).sort((a, b) => b.level - a.level);
191 if (shown.length === 0) return null;
192 return (
193 <ul className="mb-6 space-y-2">
194 {shown.map((alert) => {
195 const tone = alertTone(alert.level);
196 return (
197 <li
198 key={`${alert.meter}-${alert.level}`}
199 className={`flex gap-2.5 rounded-lg border px-4 py-2.5 text-sm ${
200 tone === "stopped" ? "border-danger/40 bg-danger/5" : tone === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
201 }`}
202 >
203 <Gauge size={15} className={`mt-0.5 shrink-0 ${tone === "stopped" ? "text-danger" : tone === "warning" ? "text-warn" : "text-muted"}`} />
204 <span>{alert.message || alertText(alert)}</span>
205 </li>
206 );
207 })}
208 </ul>
209 );
210}
211
212// --- The plan -----------------------------------------------------------------------
213
214const STATUS_TONE: Record<PlanStatus["kind"], "plain" | "accent" | "warn"> = {
215 free: "plain",
216 trial: "plain",
217 paid: "accent",
218 canceling: "warn",
219 past_due: "warn",
220 comped: "accent",
221 enterprise: "accent",
222};
223
224export function PlanCard({
225 state,
226 status,
227 entitlements,
228 owner,
229 enabled,
230 live,
231 busy,
232 deployUsage,
233 error,
234}: {
235 state: FeatureState | null;
236 status: PlanStatus;
237 entitlements: Entitlements | null;
238 owner: boolean;
239 enabled: boolean;
240 live: boolean;
241 busy: boolean;
242 deployUsage: DeployUsage | null;
243 error?: string;
244}) {
245 const plan = state?.plan;
246 const subscription = state?.subscription ?? null;
247 const on = ["paid", "canceling", "comped", "enterprise"].includes(status.kind);
248 return (
249 <Card
250 id="plan"
251 tone={STATUS_TONE[status.kind]}
252 icon={<Sparkles size={16} />}
253 title={
254 <>
255 {plan?.title ?? "g1t"}
256 <span
257 className={`rounded-full px-2 py-0.5 text-xs ${
258 status.kind === "past_due" || status.kind === "canceling"
259 ? "bg-warn/15 text-warn"
260 : on
261 ? "bg-accent/15 text-accent"
262 : "border border-line text-muted"
263 }`}
264 >
265 {status.label}
266 {status.kind === "canceling" && subscription?.periodEnd ? `, ${new Date(subscription.periodEnd).toLocaleDateString()}` : ""}
267 </span>
268 </>
269 }
270 about={
271 on
272 ? "Everything on g1t for the workspace: agents, checks, workflows, the merge queue, deployments and semantic search."
273 : "The forge is free: repositories, issues, pull requests, reviews, search and the audit log. Anything that runs compute needs the plan, or the trial."
274 }
275 aside={
276 <p className="shrink-0 sm:text-right">
277 <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span>
278 <span className="text-sm text-muted"> / month</span>
279 <span className="block text-xs text-faint">per workspace, never per seat</span>
280 </p>
281 }
282 >
283 {plan && (
284 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
285 {plan.includes.map((line) => (
286 <li key={line} className="flex gap-2">
287 <span className="text-accent">✓</span>
288 {line}
289 </li>
290 ))}
291 </ul>
292 )}
293 {plan && <p className="mt-3 text-xs text-faint">{plan.overage}</p>}
294
295 {on && entitlements?.includedMicros ? (
296 <Meter
297 label="Included usage this month"
298 used={entitlements.includedUsedMicros ?? 0}
299 of={entitlements.includedMicros}
300 detail="Used first, at cost plus 20%. It starts again on the 1st; what is unused does not carry over."
301 />
302 ) : null}
303 {on && deployUsage && <DeployMeter usage={deployUsage} entitlements={entitlements} />}
304
305 {status.kind === "comped" || status.kind === "enterprise" ? (
306 <p className="mt-4 text-sm text-muted">
307 {status.kind === "comped"
308 ? "g1t covers this workspace's plan. Its usage is still recorded at what it costs, and shown as given."
309 : "An enterprise pays for this workspace, on its own invoice."}
310 </p>
311 ) : !enabled ? (
312 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so the plan cannot be started here.</p>
313 ) : !owner ? (
314 <p className="mt-4 text-sm text-muted">An owner manages the plan.</p>
315 ) : (
316 <Form method="post" className="mt-5 flex flex-wrap items-center gap-3">
317 <input type="hidden" name="feature" value={plan?.feature ?? "plan"} />
318 {status.kind === "free" || status.kind === "trial" ? (
319 <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}>
320 <CreditCard size={14} />
321 Start the g1t plan
322 </Button>
323 ) : status.kind === "canceling" ? (
324 <Button variant="accent" type="submit" name="intent" value="resume" disabled={busy}>
325 Keep the plan
326 </Button>
327 ) : null}
328 {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && (
329 <Button variant={status.kind === "past_due" ? "accent" : "quiet"} type="submit" name="intent" value="portal" disabled={busy}>
330 {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"}
331 <ArrowUpRight size={14} />
332 </Button>
333 )}
334 {status.kind === "paid" && subscription && (
335 <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}>
336 End at the end of the period
337 </Button>
338 )}
339 {!live && (status.kind === "free" || status.kind === "trial") && (
340 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
341 )}
342 </Form>
343 )}
344 <ErrorText>{error}</ErrorText>
345 {(status.kind === "free" || status.kind === "trial") && (
346 <p className="mt-4 text-xs text-faint">
347 Your first month starts at a {wholeDollars(100_000_000)} limit on usage past what is included, so a stolen card cannot run
348 up a bill. It rises as payments clear, and you can prepay or ask to raise it at once.
349 </p>
350 )}
351 </Card>
352 );
353}
354
355/** This month's deployments against what the plan includes. */
356function DeployMeter({ usage, entitlements }: { usage: DeployUsage; entitlements: Entitlements | null }) {
357 const a = DEPLOYMENTS_ALLOWANCE;
358 const buildMinutes = (entitlements?.buildSecondsIncluded ?? a.buildSeconds) / 60;
359 const rows: [string, number, number][] = [
360 ["Apps up at once (most this month)", usage.peakApps, a.apps],
361 ["Build minutes", Math.ceil(usage.buildSeconds / 60), buildMinutes],
362 ["Requests", usage.requests, a.requests],
363 ["CPU milliseconds", usage.cpuMs, a.cpuMs],
364 ];
365 return (
366 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
367 <p className="text-xs font-medium text-muted">Deployments this month ({usage.month})</p>
368 <ul className="mt-2 space-y-2.5">
369 {rows.map(([label, used, included]) => (
370 <li key={label} className="text-sm">
371 <div className="flex justify-between gap-4">
372 <span className="text-muted">{label}</span>
373 <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}>
374 {used.toLocaleString("en-US")} <span className="text-faint">of {included.toLocaleString("en-US")}</span>
375 </span>
376 </div>
377 <div className="mt-1 h-1 overflow-hidden rounded-full bg-line">
378 <div className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`} style={{ width: `${share(used, included) * 100}%` }} />
379 </div>
380 </li>
381 ))}
382 </ul>
383 </div>
384 );
385}
386
387// --- The trial -----------------------------------------------------------------------
388
389export function TrialCard({
390 entitlements,
391 trialMicros,
392 owner,
393 enabled,
394 busy,
395 error,
396}: {
397 entitlements: Entitlements | null;
398 trialMicros: number;
399 owner: boolean;
400 enabled: boolean;
401 busy: boolean;
402 error?: string;
403}) {
404 const verified = entitlements?.trialVerified ?? false;
405 const left = entitlements?.trialMicrosLeft ?? 0;
406 return (
407 <Card
408 id="trial"
409 icon={<ShieldCheck size={16} />}
410 title={`Try it with ${wholeDollars(trialMicros)} of usage`}
411 about={
412 verified
413 ? left > 0
414 ? "Your trial pays for agents, checks and workflows until it is used. It is given once per workspace."
415 : "This workspace has no trial left. Start the plan to keep going."
416 : "Once per workspace, after a card check: Stripe saves and verifies the card with 3-D Secure and charges nothing. The trial needs a credit or debit card; prepaid cards can still pay for the plan."
417 }
418 >
419 {verified && left > 0 && <Meter label="Trial used" used={Math.max(0, trialMicros - left)} of={trialMicros} />}
420 {!verified && enabled && owner && (
421 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
422 <Button variant="quiet" type="submit" name="intent" value="card-check" disabled={busy}>
423 <ShieldCheck size={14} />
424 Check a card
425 </Button>
426 <span className="text-xs text-faint">Trials come from a monthly pool; when it is given out, new ones start on the 1st.</span>
427 </Form>
428 )}
429 {!verified && !owner && <p className="mt-3 text-sm text-muted">An owner can do the card check.</p>}
430 <ErrorText>{error}</ErrorText>
431 </Card>
432 );
433}
434
435// --- Spend limit -----------------------------------------------------------------------
436
437export function SpendLimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) {
438 const range = spendRange(limit);
439 const spent = limit.spentMicros ?? 0;
440 const spendLimit = limit.spendLimitMicros;
441 const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "full" : "fixed";
442 const tone = limit.state === "stopped" ? "danger" : limit.state === "warning" ? "warn" : "plain";
443 return (
444 <Card
445 id="limit"
446 tone={tone}
447 icon={<Gauge size={16} />}
448 title="Spend limit"
449 about="What on-demand usage may reach this month, past what the plan includes. At the limit new work waits; runs already going finish."
450 aside={limit.firstMonth ? <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">First month</span> : null}
451 >
452 {limit.account.startsWith("ent_") && (
453 <p className="mt-2 text-sm text-muted">
454 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are for all of its
455 workspaces together.
456 </p>
457 )}
458 <div className="grid gap-x-8 sm:grid-cols-2">
459 <Meter
460 label="Charged this month"
461 used={spent}
462 of={spendLimit}
463 detail={
464 mode === "automatic"
465 ? "Automatic: $200, or twice last month's spend, within what is available."
466 : mode === "fixed"
467 ? "A limit you set."
468 : "As high as is available."
469 }
470 />
471 <Meter
472 label="Not yet paid"
473 used={limit.exposureMicros}
474 of={limit.ceilingMicros}
475 detail={limit.growth ?? "Charged to the card as it nears the limit, so work keeps going."}
476 />
477 </div>
478 {limit.message && <p className="mt-4 text-sm">{limit.message}</p>}
479
480 <div className="mt-5 rounded-lg border border-line bg-bg/40 p-4 text-sm">
481 <p>
482 {range.selfServeMicros == null ? (
483 "No ceiling: set any limit."
484 ) : (
485 <>
486 You can set up to <span className="font-medium tabular-nums">{wholeDollars(range.selfServeMicros)}</span> yourself: the
487 highest limit this workspace has had{(limit.prepaidMicros ?? 0) > 0 ? ", plus what is prepaid" : ""}.
488 </>
489 )}
490 </p>
491 <p className="mt-1 text-muted">
492 {range.raiseOnceMicros != null ? (
493 <>
494 Once, you can raise it yourself to <span className="tabular-nums text-fg">{wholeDollars(range.raiseOnceMicros)}</span>.
495 Past that, prepay or use Raise my limit.
496 </>
497 ) : range.raisedAt ? (
498 <>The one-time raise was used on {new Date(range.raisedAt).toLocaleDateString()}. Prepay or use Raise my limit for more.</>
499 ) : range.selfServeMicros != null ? (
500 "For more, prepay or use Raise my limit."
501 ) : null}
502 </p>
503 </div>
504
505 {owner && (
506 <Form method="post" className="mt-4">
507 <input type="hidden" name="intent" value="spend-limit" />
508 <fieldset>
509 <legend className="text-xs text-muted">Your monthly spend limit</legend>
510 <RadioGroup name="mode" defaultValue={mode} className="mt-2 gap-2.5 text-sm">
511 <label className="flex cursor-pointer items-center gap-2">
512 <RadioGroupItem value="automatic" />
513 Automatic
514 </label>
515 <label className="flex cursor-pointer flex-wrap items-center gap-2">
516 <RadioGroupItem value="fixed" />
517 Fixed at
518 <DollarInput
519 name="limit"
520 label="Spend limit in dollars"
521 defaultValue={mode === "fixed" && spendLimit != null ? String(spendLimit / 1_000_000) : ""}
522 placeholder={range.selfServeMicros != null ? String(range.selfServeMicros / 1_000_000) : "500"}
523 />
524 </label>
525 <label className="flex cursor-pointer items-center gap-2">
526 <RadioGroupItem value="full" />
527 Everything available{range.selfServeMicros != null ? ` (${wholeDollars(range.selfServeMicros)})` : ""}
528 </label>
529 {range.raiseOnceMicros != null && (
530 <label className="flex cursor-pointer items-center gap-2">
531 <RadioGroupItem value="raise" />
532 Use my one-time raise, to the amount above or up to {wholeDollars(range.raiseOnceMicros)}
533 </label>
534 )}
535 </RadioGroup>
536 </fieldset>
537 <div className="mt-3 flex flex-wrap items-center gap-3">
538 <Button variant="quiet" type="submit" disabled={busy}>
539 Save limit
540 </Button>
541 <a href="#raise" className="text-sm text-muted hover:text-fg">
542 Need more? Raise my limit
543 </a>
544 </div>
545 <ErrorText>{error}</ErrorText>
546 </Form>
547 )}
548 </Card>
549 );
550}
551
552// --- Raise my limit -----------------------------------------------------------------------
553
554function RequestList({ requests }: { requests: LimitRequest[] }) {
555 if (requests.length === 0) return null;
556 return (
557 <ul className="mt-4 divide-y divide-line rounded-lg border border-line text-sm">
558 {requests.map((request) => (
559 <li key={request.id} className="px-4 py-2.5">
560 <p className="flex flex-wrap items-center justify-between gap-2">
561 <span>
562 {request.kind === "limit" ? `Asked for ${wholeDollars(request.amountMicros)}` : "Told g1t about this month"}
563 <span className="text-faint"> · {new Date(request.createdAt).toLocaleDateString()}</span>
564 </span>
565 <span
566 className={`rounded-full px-2 py-0.5 text-xs ${
567 request.status === "approved"
568 ? "bg-accent/15 text-accent"
569 : request.status === "declined"
570 ? "bg-danger/15 text-danger"
571 : "border border-line text-muted"
572 }`}
573 >
574 {requestStatus(request)}
575 </span>
576 </p>
577 {request.answer && <p className="mt-1 text-muted">{request.answer}</p>}
578 </li>
579 ))}
580 </ul>
581 );
582}
583
584export function RaiseCard({ requests, owner, busy, error }: { requests: LimitRequest[]; owner: boolean; busy: boolean; error?: string }) {
585 const mine = requests.filter((request) => request.kind === "limit");
586 const waiting = mine.some((request) => request.status === "open");
587 return (
588 <Card
589 id="raise"
590 title="Raise my limit"
591 about="Ask g1t for a higher limit than you can set yourself. A person reads it and answers within one business day, in the app and by email. Nothing about price is negotiated."
592 >
593 {owner && !waiting && (
594 <Form method="post" className="mt-4 space-y-3">
595 <input type="hidden" name="intent" value="request" />
596 <input type="hidden" name="kind" value="limit" />
597 <div className="flex flex-wrap gap-4">
598 <label className="text-sm">
599 <span className="mb-1 block text-xs text-muted">Limit you need</span>
600 <DollarInput name="amount" label="Limit you need in dollars" placeholder="2000" />
601 </label>
602 <label className="text-sm">
603 <span className="mb-1 block text-xs text-muted">What you expect to spend a month</span>
604 <DollarInput name="expected" label="Expected monthly spend in dollars" placeholder="1500" />
605 </label>
606 </div>
607 <label className="block text-sm">
608 <span className="mb-1 block text-xs text-muted">What it is for</span>
609 <textarea
610 name="reason"
611 rows={2}
612 maxLength={2000}
613 placeholder="A launch next week: agents on 40 issues across three projects."
614 className={`${CONTROL} w-full`}
615 />
616 </label>
617 <Button variant="quiet" type="submit" disabled={busy}>
618 Send the request
619 </Button>
620 <ErrorText>{error}</ErrorText>
621 </Form>
622 )}
623 {owner && waiting && <p className="mt-3 text-sm text-muted">Your request is with g1t. You can send another once it is answered.</p>}
624 {!owner && <p className="mt-3 text-sm text-muted">An owner can ask.</p>}
625 <RequestList requests={mine} />
626 </Card>
627 );
628}
629
630// --- Prepay -----------------------------------------------------------------------
631
632export function PrepayCard({ prepaidMicros, owner, live, busy, error }: { prepaidMicros: number; owner: boolean; live: boolean; busy: boolean; error?: string }) {
633 return (
634 <Card
635 id="prepay"
636 icon={<Landmark size={16} />}
637 title="Prepay"
638 about="Pay for usage in advance. It raises what you can use before work stops by the same amount, at once, and usage draws it down."
639 aside={
640 <p className="shrink-0 sm:text-right">
641 <span className="block text-xs text-muted">Prepaid balance</span>
642 <span className="text-xl font-semibold tabular-nums">{dollars(prepaidMicros)}</span>
643 </p>
644 }
645 >
646 {owner ? (
647 <Form method="post" className="mt-4 space-y-3">
648 <input type="hidden" name="intent" value="prepay" />
649 <div className="flex flex-wrap items-center gap-2">
650 {PREPAY.presets.map((amount) => (
651 <Button key={amount} variant="quiet" type="submit" name="amount" value={amount} disabled={busy}>
652 {wholeDollars(amount * 1_000_000)}
653 </Button>
654 ))}
655 <span className="text-sm text-muted">or</span>
656 <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" />
657 <Button variant="accent" type="submit" disabled={busy}>
658 <CreditCard size={14} />
659 Prepay
660 </Button>
661 </div>
662 <RadioGroup name="method" defaultValue="card" className="flex flex-wrap gap-x-5 gap-y-2 text-sm">
663 <label className="flex cursor-pointer items-center gap-2">
664 <RadioGroupItem value="card" />
665 By card, with 3-D Secure ({wholeDollars(PREPAY.min * 1_000_000)} to {wholeDollars(PREPAY.maxCard * 1_000_000)})
666 </label>
667 <label className="flex cursor-pointer items-center gap-2">
668 <RadioGroupItem value="bank_transfer" />
669 By bank transfer (from {wholeDollars(PREPAY.bankFrom * 1_000_000)}; counted when it arrives)
670 </label>
671 </RadioGroup>
672 {!live && <p className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>}
673 <ErrorText>{error}</ErrorText>
674 </Form>
675 ) : (
676 <p className="mt-3 text-sm text-muted">An owner can prepay.</p>
677 )}
678 </Card>
679 );
680}
681
682// --- Caps -----------------------------------------------------------------------
683
684export function CapsCard({ entitlements, owner, busy, error }: { entitlements: Entitlements; owner: boolean; busy: boolean; error?: string }) {
685 return (
686 <Card
687 id="caps"
688 title="Caps on agents"
689 about={
690 <>
691 The most one agent run may spend, and the most the agents on one issue may spend in all, so an agent stuck in a loop stops on
692 its own. Blank goes back to {wholeDollars(CAPS.run.default)} and {wholeDollars(CAPS.issue.default)}. A cap g1t set for this
693 workspace wins.
694 </>
695 }
696 >
697 <dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:max-w-md">
698 <div>
699 <dt className="text-xs text-muted">Per run</dt>
700 <dd className="mt-0.5 text-lg font-semibold tabular-nums">{wholeDollars(entitlements.runCapMicros)}</dd>
701 </div>
702 <div>
703 <dt className="text-xs text-muted">Per issue</dt>
704 <dd className="mt-0.5 text-lg font-semibold tabular-nums">{wholeDollars(entitlements.issueCapMicros)}</dd>
705 </div>
706 </dl>
707 {owner && (
708 <Form method="post" className="mt-4 flex flex-wrap items-end gap-4">
709 <input type="hidden" name="intent" value="caps" />
710 <label className="text-sm">
711 <span className="mb-1 block text-xs text-muted">
712 Per run ({wholeDollars(CAPS.run.min)} to {wholeDollars(CAPS.run.max)})
713 </span>
714 <DollarInput name="run" label="Run cap in dollars" defaultValue={String(entitlements.runCapMicros / 1_000_000)} />
715 </label>
716 <label className="text-sm">
717 <span className="mb-1 block text-xs text-muted">
718 Per issue ({wholeDollars(CAPS.issue.min)} to {wholeDollars(CAPS.issue.max)})
719 </span>
720 <DollarInput name="issue" label="Issue cap in dollars" defaultValue={String(entitlements.issueCapMicros / 1_000_000)} />
721 </label>
722 <Button variant="quiet" type="submit" disabled={busy}>
723 Save caps
724 </Button>
725 </Form>
726 )}
727 <ErrorText>{error}</ErrorText>
728 <p className="mt-3 text-xs text-faint">
729 {entitlements.maxConcurrentAgents} agents at a time{entitlements.maxRunMinutes ? `, ${entitlements.maxRunMinutes} minutes a run` : ""}
730 {entitlements.firstMonth ? " in the first month" : ""}.
731 </p>
732 </Card>
733 );
734}
735
736// --- Overage -----------------------------------------------------------------------
737
738export function OverageCard({ requests, owner, busy, error }: { requests: LimitRequest[]; owner: boolean; busy: boolean; error?: string }) {
739 const mine = requests.filter((request) => request.kind === "overage");
740 const waiting = mine.some((request) => request.status === "open");
741 return (
742 <Card
743 id="overage"
744 title="Spent more than you meant to? Tell us."
745 about="A loop that ran all night, a workflow on every push: say what happened. g1t can credit usage you did not mean, once in 12 months; larger credits, or a second one within 12 months, are reviewed by a person."
746 >
747 {owner && !waiting && (
748 <Form method="post" className="mt-4 space-y-3">
749 <input type="hidden" name="intent" value="request" />
750 <input type="hidden" name="kind" value="overage" />
751 <textarea
752 name="reason"
753 rows={2}
754 maxLength={2000}
755 aria-label="What happened"
756 placeholder="An agent kept retrying a failing check on Tuesday night."
757 className={`${CONTROL} w-full`}
758 />
759 <Button variant="quiet" type="submit" disabled={busy}>
760 Tell g1t
761 </Button>
762 <ErrorText>{error}</ErrorText>
763 </Form>
764 )}
765 {owner && waiting && <p className="mt-3 text-sm text-muted">g1t has your note and answers within one business day.</p>}
766 {!owner && <p className="mt-3 text-sm text-muted">An owner can tell g1t.</p>}
767 <RequestList requests={mine} />
768 </Card>
769 );
770}