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

745 lines31,246 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 { ErrorText, SubmitButton } 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 meters,
185 error,
186}: {
187 state: FeatureState | null;
188 status: PlanStatus;
189 entitlements: Entitlements | null;
190 owner: boolean;
191 enabled: boolean;
192 live: boolean;
193 /** This month's usage by meter, from billing's `usage_meters`. */
194 meters: MeterUsage[] | null;
195 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}
252 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."
254 />
255 ) : null}
256 {meters && <MonthUsage meters={meters} on={on} comped={status.kind === "comped"} entitlements={entitlements} />}
257
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" ? (
272 <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…">
273 <CreditCard size={14} />
274 Start the g1t plan
275 </SubmitButton>
276 ) : status.kind === "canceling" ? (
277 <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…">
278 Keep the plan
279 </SubmitButton>
280 ) : null}
281 {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && (
282 <SubmitButton variant={status.kind === "past_due" ? "accent" : "quiet"} name="intent" value="portal" pending="Opening Stripe…">
283 {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"}
284 <ArrowUpRight size={14} />
285 </SubmitButton>
286 )}
287 {status.kind === "paid" && subscription && (
288 <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…">
289 End at the end of the period
290 </SubmitButton>
291 )}
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
308/**
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);
327 return (
328 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
329 <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>
343 </li>
344 ))}
345 </ul>
346 <div className="mt-1 flex items-baseline justify-between gap-4 border-t border-line pt-2.5 text-sm">
347 {/* 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>
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, which pay part of this total: Usage shows what was charged.`}
358 </p>
359 </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">
396 <SubmitButton variant="quiet" name="intent" value="card-check" pending="Opening Stripe…">
397 <ShieldCheck size={14} />
398 Check a card
399 </SubmitButton>
400 <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
411export function SpendLimitCard({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) {
412 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">
512 <SubmitButton variant="quiet" match={{ intent: "spend-limit" }} pending="Saving…">
513 Save limit
514 </SubmitButton>
515 <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
558export function RaiseCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) {
559 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>
591 <SubmitButton variant="quiet" match={{ intent: "request", kind: "limit" }} pending="Sending…">
592 Send the request
593 </SubmitButton>
594 <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
606export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) {
607 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) => (
625 <SubmitButton key={amount} variant="quiet" name="amount" value={amount} match={{ intent: "prepay" }} pending="Opening Stripe…">
626 {wholeDollars(amount * 1_000_000)}
627 </SubmitButton>
628 ))}
629 <span className="text-sm text-muted">or</span>
630 <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" />
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…">
633 <CreditCard size={14} />
634 Prepay
635 </SubmitButton>
636 </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
659export function CapsCard({ entitlements, owner, error }: { entitlements: Entitlements; owner: boolean; error?: string }) {
660 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>
697 <SubmitButton variant="quiet" match={{ intent: "caps" }} pending="Saving…">
698 Save caps
699 </SubmitButton>
700 </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
713export function OverageCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) {
714 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 />
734 <SubmitButton variant="quiet" match={{ intent: "request", kind: "overage" }} pending="Sending…">
735 Tell g1t
736 </SubmitButton>
737 <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}