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

807 lines33,410 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1/**
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
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas11import type { Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, UsageAlert } from "@g1t/contracts";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look12
13import {
14 CAPS,
15 PREPAY,
16 type PlanStatus,
17 alertText,
18 alertTone,
19 dollars,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas20 gigabytes,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look21 requestStatus,
22 share,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas23 shownMeters,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look24 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. */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas102export 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}) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look116 const part = share(used, of);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas117 const bar = calm ? "bg-accent" : of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-accent";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look118 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,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas246 meters,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look247 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;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas256 /** This month's usage by meter, from billing's `usage_meters`. */
257 meters: MeterUsage[] | null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look258 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}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas315 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."
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look317 />
318 ) : null}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas319 {meters && <MonthUsage meters={meters} on={on} comped={status.kind === "comped"} entitlements={entitlements} />}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look320
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
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas371/**
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);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look390 return (
391 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas392 <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>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look406 </li>
407 ))}
408 </ul>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas409 <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>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look420 </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}