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

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