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

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

This file's history is long; its oldest lines are credited to the oldest commit read.