flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

746 lines30,780 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 { ArrowUpRight, CreditCard, Gauge, Landmark, 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// In its own module, so the app shell carries it without the billing page.
138export { SpikeBanner } from "./spike-banner";
139
140// --- Alerts -----------------------------------------------------------------------
141
142export function Alerts({ alerts }: { alerts: UsageAlert[] }) {
143 const shown = alerts.filter((alert) => alert.level >= 50).sort((a, b) => b.level - a.level);
144 if (shown.length === 0) return null;
145 return (
146 <ul className="mb-6 space-y-2">
147 {shown.map((alert) => {
148 const tone = alertTone(alert.level);
149 return (
150 <li
151 key={`${alert.meter}-${alert.level}`}
152 className={`flex gap-2.5 rounded-lg border px-4 py-2.5 text-sm ${
153 tone === "stopped" ? "border-danger/40 bg-danger/5" : tone === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
154 }`}
155 >
156 <Gauge size={15} className={`mt-0.5 shrink-0 ${tone === "stopped" ? "text-danger" : tone === "warning" ? "text-warn" : "text-muted"}`} />
157 <span>{alert.message || alertText(alert)}</span>
158 </li>
159 );
160 })}
161 </ul>
162 );
163}
164
165// --- The plan -----------------------------------------------------------------------
166
167const STATUS_TONE: Record<PlanStatus["kind"], "plain" | "accent" | "warn"> = {
168 free: "plain",
169 trial: "plain",
170 paid: "accent",
171 canceling: "warn",
172 past_due: "warn",
173 comped: "accent",
174 enterprise: "accent",
175};
176
177export function PlanCard({
178 state,
179 status,
180 entitlements,
181 owner,
182 enabled,
183 live,
184 busy,
185 meters,
186 error,
187}: {
188 state: FeatureState | null;
189 status: PlanStatus;
190 entitlements: Entitlements | null;
191 owner: boolean;
192 enabled: boolean;
193 live: boolean;
194 busy: boolean;
195 /** This month's usage by meter, from billing's `usage_meters`. */
196 meters: MeterUsage[] | null;
197 error?: string;
198}) {
199 const plan = state?.plan;
200 const subscription = state?.subscription ?? null;
201 const on = ["paid", "canceling", "comped", "enterprise"].includes(status.kind);
202 return (
203 <Card
204 id="plan"
205 tone={STATUS_TONE[status.kind]}
206 icon={<Sparkles size={16} />}
207 title={
208 <>
209 {plan?.title ?? "g1t"}
210 <span
211 className={`rounded-full px-2 py-0.5 text-xs ${
212 status.kind === "past_due" || status.kind === "canceling"
213 ? "bg-warn/15 text-warn"
214 : on
215 ? "bg-accent/15 text-accent"
216 : "border border-line text-muted"
217 }`}
218 >
219 {status.label}
220 {status.kind === "canceling" && subscription?.periodEnd ? `, ${new Date(subscription.periodEnd).toLocaleDateString()}` : ""}
221 </span>
222 </>
223 }
224 about={
225 on
226 ? "Everything on g1t for the workspace: agents, checks, workflows, the merge queue, deployments and semantic search."
227 : "The forge is free: repositories, issues, pull requests, reviews, search and the audit log. Anything that runs compute needs the plan, or the trial."
228 }
229 aside={
230 <p className="shrink-0 sm:text-right">
231 <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span>
232 <span className="text-sm text-muted"> / month</span>
233 <span className="block text-xs text-faint">per workspace, never per seat</span>
234 </p>
235 }
236 >
237 {plan && (
238 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
239 {plan.includes.map((line) => (
240 <li key={line} className="flex gap-2">
241 <span className="text-accent">✓</span>
242 {line}
243 </li>
244 ))}
245 </ul>
246 )}
247 {plan && <p className="mt-3 text-xs text-faint">{plan.overage}</p>}
248
249 {on && entitlements?.includedMicros ? (
250 <Meter
251 label="Included usage this month"
252 used={entitlements.includedUsedMicros ?? 0}
253 of={entitlements.includedMicros}
254 calm
255 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."
256 />
257 ) : null}
258 {meters && <MonthUsage meters={meters} on={on} comped={status.kind === "comped"} entitlements={entitlements} />}
259
260 {status.kind === "comped" || status.kind === "enterprise" ? (
261 <p className="mt-4 text-sm text-muted">
262 {status.kind === "comped"
263 ? "g1t covers this workspace's plan. Its usage is still recorded at what it costs, and shown as given."
264 : "An enterprise pays for this workspace, on its own invoice."}
265 </p>
266 ) : !enabled ? (
267 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so the plan cannot be started here.</p>
268 ) : !owner ? (
269 <p className="mt-4 text-sm text-muted">An owner manages the plan.</p>
270 ) : (
271 <Form method="post" className="mt-5 flex flex-wrap items-center gap-3">
272 <input type="hidden" name="feature" value={plan?.feature ?? "plan"} />
273 {status.kind === "free" || status.kind === "trial" ? (
274 <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}>
275 <CreditCard size={14} />
276 Start the g1t plan
277 </Button>
278 ) : status.kind === "canceling" ? (
279 <Button variant="accent" type="submit" name="intent" value="resume" disabled={busy}>
280 Keep the plan
281 </Button>
282 ) : null}
283 {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && (
284 <Button variant={status.kind === "past_due" ? "accent" : "quiet"} type="submit" name="intent" value="portal" disabled={busy}>
285 {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"}
286 <ArrowUpRight size={14} />
287 </Button>
288 )}
289 {status.kind === "paid" && subscription && (
290 <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}>
291 End at the end of the period
292 </Button>
293 )}
294 {!live && (status.kind === "free" || status.kind === "trial") && (
295 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
296 )}
297 </Form>
298 )}
299 <ErrorText>{error}</ErrorText>
300 {(status.kind === "free" || status.kind === "trial") && (
301 <p className="mt-4 text-xs text-faint">
302 Your first month starts at a {wholeDollars(100_000_000)} limit on usage past what is included, so a stolen card cannot run
303 up a bill. It rises as payments clear, and you can prepay or ask to raise it at once.
304 </p>
305 )}
306 </Card>
307 );
308}
309
310/**
311 * This month's usage, one line per meter, in dollars at cost plus 20% and
312 * in what was used. No quotas: every line is metered from the first unit.
313 */
314function MonthUsage({
315 meters,
316 on,
317 comped,
318 entitlements,
319}: {
320 meters: MeterUsage[];
321 on: boolean;
322 comped: boolean;
323 entitlements: Entitlements | null;
324}) {
325 const freeStorage = gigabytes(entitlements?.freePrivateStorageBytes ?? 1_000_000_000);
326 const freeGit = (entitlements?.gitOperationsIncluded ?? 50_000).toLocaleString("en-US");
327 const rows = shownMeters(meters, on);
328 const total = rows.reduce((sum, row) => sum + row.micros, 0);
329 return (
330 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
331 <div className="flex items-baseline justify-between gap-4">
332 <p className="text-xs font-medium text-muted">This month's usage</p>
333 <p className="text-xs text-faint">At cost plus 20%</p>
334 </div>
335 <ul className="mt-2 divide-y divide-line/60">
336 {rows.map((row) => (
337 <li key={row.key} className="flex items-baseline justify-between gap-4 py-2 text-sm">
338 <span className="min-w-0">
339 <span>{row.label}</span>
340 {(row.quantity || row.micros === 0) && (
341 <span className="block truncate text-xs text-faint">{row.quantity ?? "None yet"}</span>
342 )}
343 </span>
344 <span className={`shrink-0 tabular-nums ${row.micros > 0 ? "" : "text-faint"}`}>{dollars(row.micros)}</span>
345 </li>
346 ))}
347 </ul>
348 <div className="mt-1 flex items-baseline justify-between gap-4 border-t border-line pt-2.5 text-sm">
349 <span className="font-medium">Total</span>
350 <span className="font-medium tabular-nums">{dollars(total)}</span>
351 </div>
352 <p className="mt-2 text-xs text-faint">
353 {comped
354 ? "What this workspace's usage would cost. g1t covers it."
355 : on
356 ? `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.`
357 : `The forge is free: ${freeStorage} of private storage and ${freeGit} git operations a month. Agents run from the trial or the open-source pool.`}
358 </p>
359 </div>
360 );
361}
362
363// --- The trial -----------------------------------------------------------------------
364
365export function TrialCard({
366 entitlements,
367 trialMicros,
368 owner,
369 enabled,
370 busy,
371 error,
372}: {
373 entitlements: Entitlements | null;
374 trialMicros: number;
375 owner: boolean;
376 enabled: boolean;
377 busy: boolean;
378 error?: string;
379}) {
380 const verified = entitlements?.trialVerified ?? false;
381 const left = entitlements?.trialMicrosLeft ?? 0;
382 return (
383 <Card
384 id="trial"
385 icon={<ShieldCheck size={16} />}
386 title={`Try it with ${wholeDollars(trialMicros)} of usage`}
387 about={
388 verified
389 ? left > 0
390 ? "Your trial pays for agents, checks and workflows until it is used. It is given once per workspace."
391 : "This workspace has no trial left. Start the plan to keep going."
392 : "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."
393 }
394 >
395 {verified && left > 0 && <Meter label="Trial used" used={Math.max(0, trialMicros - left)} of={trialMicros} />}
396 {!verified && enabled && owner && (
397 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
398 <Button variant="quiet" type="submit" name="intent" value="card-check" disabled={busy}>
399 <ShieldCheck size={14} />
400 Check a card
401 </Button>
402 <span className="text-xs text-faint">Trials come from a monthly pool; when it is given out, new ones start on the 1st.</span>
403 </Form>
404 )}
405 {!verified && !owner && <p className="mt-3 text-sm text-muted">An owner can do the card check.</p>}
406 <ErrorText>{error}</ErrorText>
407 </Card>
408 );
409}
410
411// --- Spend limit -----------------------------------------------------------------------
412
413export function SpendLimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) {
414 const range = spendRange(limit);
415 const spent = limit.spentMicros ?? 0;
416 const spendLimit = limit.spendLimitMicros;
417 const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "full" : "fixed";
418 const tone = limit.state === "stopped" ? "danger" : limit.state === "warning" ? "warn" : "plain";
419 return (
420 <Card
421 id="limit"
422 tone={tone}
423 icon={<Gauge size={16} />}
424 title="Spend limit"
425 about="What on-demand usage may reach this month, past what the plan includes. At the limit new work waits; runs already going finish."
426 aside={limit.firstMonth ? <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">First month</span> : null}
427 >
428 {limit.account.startsWith("ent_") && (
429 <p className="mt-2 text-sm text-muted">
430 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are for all of its
431 workspaces together.
432 </p>
433 )}
434 <div className="grid gap-x-8 sm:grid-cols-2">
435 <Meter
436 label="Charged this month"
437 used={spent}
438 of={spendLimit}
439 detail={
440 mode === "automatic"
441 ? "Automatic: $200, or twice last month's spend, within what is available."
442 : mode === "fixed"
443 ? "A limit you set."
444 : "As high as is available."
445 }
446 />
447 <Meter
448 label="Not yet paid"
449 used={limit.exposureMicros}
450 of={limit.ceilingMicros}
451 detail={limit.growth ?? "Charged to the card as it nears the limit, so work keeps going."}
452 />
453 </div>
454 {limit.message && <p className="mt-4 text-sm">{limit.message}</p>}
455
456 <div className="mt-5 rounded-lg border border-line bg-bg/40 p-4 text-sm">
457 <p>
458 {range.selfServeMicros == null ? (
459 "No ceiling: set any limit."
460 ) : (
461 <>
462 You can set up to <span className="font-medium tabular-nums">{wholeDollars(range.selfServeMicros)}</span> yourself: the
463 highest limit this workspace has had{(limit.prepaidMicros ?? 0) > 0 ? ", plus what is prepaid" : ""}.
464 </>
465 )}
466 </p>
467 <p className="mt-1 text-muted">
468 {range.raiseOnceMicros != null ? (
469 <>
470 Once, you can raise it yourself to <span className="tabular-nums text-fg">{wholeDollars(range.raiseOnceMicros)}</span>.
471 Past that, prepay or use Raise my limit.
472 </>
473 ) : range.raisedAt ? (
474 <>The one-time raise was used on {new Date(range.raisedAt).toLocaleDateString()}. Prepay or use Raise my limit for more.</>
475 ) : range.selfServeMicros != null ? (
476 "For more, prepay or use Raise my limit."
477 ) : null}
478 </p>
479 </div>
480
481 {owner && (
482 <Form method="post" className="mt-4">
483 <input type="hidden" name="intent" value="spend-limit" />
484 <fieldset>
485 <legend className="text-xs text-muted">Your monthly spend limit</legend>
486 <RadioGroup name="mode" defaultValue={mode} className="mt-2 gap-2.5 text-sm">
487 <label className="flex cursor-pointer items-center gap-2">
488 <RadioGroupItem value="automatic" />
489 Automatic
490 </label>
491 <label className="flex cursor-pointer flex-wrap items-center gap-2">
492 <RadioGroupItem value="fixed" />
493 Fixed at
494 <DollarInput
495 name="limit"
496 label="Spend limit in dollars"
497 defaultValue={mode === "fixed" && spendLimit != null ? String(spendLimit / 1_000_000) : ""}
498 placeholder={range.selfServeMicros != null ? String(range.selfServeMicros / 1_000_000) : "500"}
499 />
500 </label>
501 <label className="flex cursor-pointer items-center gap-2">
502 <RadioGroupItem value="full" />
503 Everything available{range.selfServeMicros != null ? ` (${wholeDollars(range.selfServeMicros)})` : ""}
504 </label>
505 {range.raiseOnceMicros != null && (
506 <label className="flex cursor-pointer items-center gap-2">
507 <RadioGroupItem value="raise" />
508 Use my one-time raise, to the amount above or up to {wholeDollars(range.raiseOnceMicros)}
509 </label>
510 )}
511 </RadioGroup>
512 </fieldset>
513 <div className="mt-3 flex flex-wrap items-center gap-3">
514 <Button variant="quiet" type="submit" disabled={busy}>
515 Save limit
516 </Button>
517 <a href="#raise" className="text-sm text-muted hover:text-fg">
518 Need more? Raise my limit
519 </a>
520 </div>
521 <ErrorText>{error}</ErrorText>
522 </Form>
523 )}
524 </Card>
525 );
526}
527
528// --- Raise my limit -----------------------------------------------------------------------
529
530function RequestList({ requests }: { requests: LimitRequest[] }) {
531 if (requests.length === 0) return null;
532 return (
533 <ul className="mt-4 divide-y divide-line rounded-lg border border-line text-sm">
534 {requests.map((request) => (
535 <li key={request.id} className="px-4 py-2.5">
536 <p className="flex flex-wrap items-center justify-between gap-2">
537 <span>
538 {request.kind === "limit" ? `Asked for ${wholeDollars(request.amountMicros)}` : "Told g1t about this month"}
539 <span className="text-faint"> · {new Date(request.createdAt).toLocaleDateString()}</span>
540 </span>
541 <span
542 className={`rounded-full px-2 py-0.5 text-xs ${
543 request.status === "approved"
544 ? "bg-accent/15 text-accent"
545 : request.status === "declined"
546 ? "bg-danger/15 text-danger"
547 : "border border-line text-muted"
548 }`}
549 >
550 {requestStatus(request)}
551 </span>
552 </p>
553 {request.answer && <p className="mt-1 text-muted">{request.answer}</p>}
554 </li>
555 ))}
556 </ul>
557 );
558}
559
560export function RaiseCard({ requests, owner, busy, error }: { requests: LimitRequest[]; owner: boolean; busy: boolean; error?: string }) {
561 const mine = requests.filter((request) => request.kind === "limit");
562 const waiting = mine.some((request) => request.status === "open");
563 return (
564 <Card
565 id="raise"
566 title="Raise my limit"
567 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."
568 >
569 {owner && !waiting && (
570 <Form method="post" className="mt-4 space-y-3">
571 <input type="hidden" name="intent" value="request" />
572 <input type="hidden" name="kind" value="limit" />
573 <div className="flex flex-wrap gap-4">
574 <label className="text-sm">
575 <span className="mb-1 block text-xs text-muted">Limit you need</span>
576 <DollarInput name="amount" label="Limit you need in dollars" placeholder="2000" />
577 </label>
578 <label className="text-sm">
579 <span className="mb-1 block text-xs text-muted">What you expect to spend a month</span>
580 <DollarInput name="expected" label="Expected monthly spend in dollars" placeholder="1500" />
581 </label>
582 </div>
583 <label className="block text-sm">
584 <span className="mb-1 block text-xs text-muted">What it is for</span>
585 <textarea
586 name="reason"
587 rows={2}
588 maxLength={2000}
589 placeholder="A launch next week: agents on 40 issues across three projects."
590 className={`${CONTROL} w-full`}
591 />
592 </label>
593 <Button variant="quiet" type="submit" disabled={busy}>
594 Send the request
595 </Button>
596 <ErrorText>{error}</ErrorText>
597 </Form>
598 )}
599 {owner && waiting && <p className="mt-3 text-sm text-muted">Your request is with g1t. You can send another once it is answered.</p>}
600 {!owner && <p className="mt-3 text-sm text-muted">An owner can ask.</p>}
601 <RequestList requests={mine} />
602 </Card>
603 );
604}
605
606// --- Prepay -----------------------------------------------------------------------
607
608export function PrepayCard({ prepaidMicros, owner, live, busy, error }: { prepaidMicros: number; owner: boolean; live: boolean; busy: boolean; error?: string }) {
609 return (
610 <Card
611 id="prepay"
612 icon={<Landmark size={16} />}
613 title="Prepay"
614 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."
615 aside={
616 <p className="shrink-0 sm:text-right">
617 <span className="block text-xs text-muted">Prepaid balance</span>
618 <span className="text-xl font-semibold tabular-nums">{dollars(prepaidMicros)}</span>
619 </p>
620 }
621 >
622 {owner ? (
623 <Form method="post" className="mt-4 space-y-3">
624 <input type="hidden" name="intent" value="prepay" />
625 <div className="flex flex-wrap items-center gap-2">
626 {PREPAY.presets.map((amount) => (
627 <Button key={amount} variant="quiet" type="submit" name="amount" value={amount} disabled={busy}>
628 {wholeDollars(amount * 1_000_000)}
629 </Button>
630 ))}
631 <span className="text-sm text-muted">or</span>
632 <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" />
633 <Button variant="accent" type="submit" disabled={busy}>
634 <CreditCard size={14} />
635 Prepay
636 </Button>
637 </div>
638 <RadioGroup name="method" defaultValue="card" className="flex flex-wrap gap-x-5 gap-y-2 text-sm">
639 <label className="flex cursor-pointer items-center gap-2">
640 <RadioGroupItem value="card" />
641 By card, with 3-D Secure ({wholeDollars(PREPAY.min * 1_000_000)} to {wholeDollars(PREPAY.maxCard * 1_000_000)})
642 </label>
643 <label className="flex cursor-pointer items-center gap-2">
644 <RadioGroupItem value="bank_transfer" />
645 By bank transfer (from {wholeDollars(PREPAY.bankFrom * 1_000_000)}; counted when it arrives)
646 </label>
647 </RadioGroup>
648 {!live && <p className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>}
649 <ErrorText>{error}</ErrorText>
650 </Form>
651 ) : (
652 <p className="mt-3 text-sm text-muted">An owner can prepay.</p>
653 )}
654 </Card>
655 );
656}
657
658// --- Caps -----------------------------------------------------------------------
659
660export function CapsCard({ entitlements, owner, busy, error }: { entitlements: Entitlements; owner: boolean; busy: boolean; error?: string }) {
661 return (
662 <Card
663 id="caps"
664 title="Caps on agents"
665 about={
666 <>
667 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
668 its own. Blank goes back to {wholeDollars(CAPS.run.default)} and {wholeDollars(CAPS.issue.default)}. A cap g1t set for this
669 workspace wins.
670 </>
671 }
672 >
673 <dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:max-w-md">
674 <div>
675 <dt className="text-xs text-muted">Per run</dt>
676 <dd className="mt-0.5 text-lg font-semibold tabular-nums">{wholeDollars(entitlements.runCapMicros)}</dd>
677 </div>
678 <div>
679 <dt className="text-xs text-muted">Per issue</dt>
680 <dd className="mt-0.5 text-lg font-semibold tabular-nums">{wholeDollars(entitlements.issueCapMicros)}</dd>
681 </div>
682 </dl>
683 {owner && (
684 <Form method="post" className="mt-4 flex flex-wrap items-end gap-4">
685 <input type="hidden" name="intent" value="caps" />
686 <label className="text-sm">
687 <span className="mb-1 block text-xs text-muted">
688 Per run ({wholeDollars(CAPS.run.min)} to {wholeDollars(CAPS.run.max)})
689 </span>
690 <DollarInput name="run" label="Run cap in dollars" defaultValue={String(entitlements.runCapMicros / 1_000_000)} />
691 </label>
692 <label className="text-sm">
693 <span className="mb-1 block text-xs text-muted">
694 Per issue ({wholeDollars(CAPS.issue.min)} to {wholeDollars(CAPS.issue.max)})
695 </span>
696 <DollarInput name="issue" label="Issue cap in dollars" defaultValue={String(entitlements.issueCapMicros / 1_000_000)} />
697 </label>
698 <Button variant="quiet" type="submit" disabled={busy}>
699 Save caps
700 </Button>
701 </Form>
702 )}
703 <ErrorText>{error}</ErrorText>
704 <p className="mt-3 text-xs text-faint">
705 {entitlements.maxConcurrentAgents} agents at a time{entitlements.maxRunMinutes ? `, ${entitlements.maxRunMinutes} minutes a run` : ""}
706 {entitlements.firstMonth ? " in the first month" : ""}.
707 </p>
708 </Card>
709 );
710}
711
712// --- Overage -----------------------------------------------------------------------
713
714export function OverageCard({ requests, owner, busy, error }: { requests: LimitRequest[]; owner: boolean; busy: boolean; error?: string }) {
715 const mine = requests.filter((request) => request.kind === "overage");
716 const waiting = mine.some((request) => request.status === "open");
717 return (
718 <Card
719 id="overage"
720 title="Spent more than you meant to? Tell us."
721 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."
722 >
723 {owner && !waiting && (
724 <Form method="post" className="mt-4 space-y-3">
725 <input type="hidden" name="intent" value="request" />
726 <input type="hidden" name="kind" value="overage" />
727 <textarea
728 name="reason"
729 rows={2}
730 maxLength={2000}
731 aria-label="What happened"
732 placeholder="An agent kept retrying a failing check on Tuesday night."
733 className={`${CONTROL} w-full`}
734 />
735 <Button variant="quiet" type="submit" disabled={busy}>
736 Tell g1t
737 </Button>
738 <ErrorText>{error}</ErrorText>
739 </Form>
740 )}
741 {owner && waiting && <p className="mt-3 text-sm text-muted">g1t has your note and answers within one business day.</p>}
742 {!owner && <p className="mt-3 text-sm text-muted">An owner can tell g1t.</p>}
743 <RequestList requests={mine} />
744 </Card>
745 );
746}