Skip to content

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

547 linesCodeBlameRaw
1/**
2 * The billing page's sections: the spend-spike banner (also in the app
3 * shell), alerts, the trial, the spend limit and its range,
4 * Raise my limit, credits from g1t, Prepay, and "Spent more than you
5 * meant to?". Each posts to the billing route's action with an `intent`.
6 */
7import { CreditCard, Gauge, Gift, Landmark, ShieldCheck } from "lucide-react";
8import type { ReactNode } from "react";
9import { Form } from "react-router";
10
11import type { Credits, Entitlements, Limit, LimitRequest, UsageAlert } from "@g1t/contracts";
12
13import { CREDIT_KIND, PREPAY, alertText, alertTone, creditLine, requestStatus, share, shortDay, spendRange } from "../lib/billing";
14import { money, wholeDollars } from "../lib/money";
15import { type Failed, isFailed } from "../lib/partial";
16import { Unavailable } from "./boundary";
17import { ErrorText, SubmitButton } from "./ui";
18import { Badge } from "./ui/badge";
19import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
20
21/** An error the action returned, for the section it belongs to. */
22export type SectionError = { section: string; error: string } | null | undefined;
23
24const CONTROL =
25 "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";
26
27/** A dollar amount field: a `$` and a number. */
28function DollarInput({ name, defaultValue, placeholder, label }: { name: string; defaultValue?: string; placeholder?: string; label: string }) {
29 return (
30 <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent-dim">
31 <span className="text-sm text-muted">$</span>
32 <input
33 name={name}
34 inputMode="decimal"
35 aria-label={label}
36 defaultValue={defaultValue}
37 placeholder={placeholder}
38 autoComplete="off"
39 data-1p-ignore
40 data-lpignore="true"
41 className="w-24 bg-transparent px-1 py-1.5 text-sm tabular-nums outline-none placeholder:text-faint"
42 />
43 </span>
44 );
45}
46
47/** A bordered card with a heading, a line about it, and a tone. */
48export function Card({
49 id,
50 title,
51 icon,
52 aside,
53 about,
54 tone = "plain",
55 children,
56}: {
57 id?: string;
58 title: ReactNode;
59 icon?: ReactNode;
60 aside?: ReactNode;
61 about?: ReactNode;
62 tone?: "plain" | "accent" | "warn" | "danger";
63 children?: ReactNode;
64}) {
65 const tones = {
66 plain: "border-line bg-surface",
67 accent: "border-accent/40 bg-accent/5",
68 warn: "border-warn/40 bg-warn/5",
69 danger: "border-danger/40 bg-danger/5",
70 };
71 return (
72 <section id={id} className={`mb-6 scroll-mt-20 rounded-xl border p-5 ${tones[tone]}`}>
73 <div className="flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap">
74 <div className="min-w-0 basis-full sm:basis-0 sm:flex-1">
75 <h2 className="flex flex-wrap items-center gap-2 font-medium">
76 {icon && <span className="text-accent">{icon}</span>}
77 {title}
78 </h2>
79 {about && <div className="mt-1 max-w-2xl text-sm text-muted">{about}</div>}
80 </div>
81 {aside}
82 </div>
83 {children}
84 </section>
85 );
86}
87
88/** One meter: how much of something is used. */
89export function Meter({
90 used,
91 of,
92 label,
93 detail,
94 calm = false,
95}: {
96 used: number;
97 of: number | null;
98 label: string;
99 detail?: ReactNode;
100 /** Reaching the end is ordinary, not a warning: the plan's included usage, past which usage simply goes on demand. */
101 calm?: boolean;
102}) {
103 const part = share(used, of);
104 const bar = calm ? "bg-success" : of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-success";
105 return (
106 <div className="mt-4">
107 <div className="flex flex-wrap justify-between gap-x-4 text-sm">
108 <span className="text-muted">{label}</span>
109 <span className="tabular-nums">
110 {money(used)}
111 {of != null && <span className="text-faint"> of {money(of)}</span>}
112 </span>
113 </div>
114 <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
115 <div className={`h-full ${bar}`} style={{ width: `${Math.max(part * 100, part > 0 ? 2 : 0)}%` }} />
116 </div>
117 {detail && <p className="mt-1.5 text-xs text-faint">{detail}</p>}
118 </div>
119 );
120}
121
122// --- Spike ------------------------------------------------------------------------
123
124// In its own module, so the app shell carries it without the billing page.
125export { SpikeBanner } from "./spike-banner";
126
127// --- Alerts -----------------------------------------------------------------------
128
129export function Alerts({ alerts }: { alerts: UsageAlert[] }) {
130 const shown = alerts.filter((alert) => alert.level >= 50).sort((a, b) => b.level - a.level);
131 if (shown.length === 0) return null;
132 return (
133 <ul className="mb-6 space-y-2">
134 {shown.map((alert) => {
135 const tone = alertTone(alert.level);
136 return (
137 <li
138 key={`${alert.meter}-${alert.level}`}
139 className={`flex gap-2.5 rounded-lg border px-4 py-2.5 text-sm ${
140 tone === "stopped" ? "border-danger/40 bg-danger/5" : tone === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
141 }`}
142 >
143 <Gauge size={15} className={`mt-0.5 shrink-0 ${tone === "stopped" ? "text-danger" : tone === "warning" ? "text-warn" : "text-muted"}`} />
144 <span>{alert.message || alertText(alert)}</span>
145 </li>
146 );
147 })}
148 </ul>
149 );
150}
151
152// --- The trial -----------------------------------------------------------------------
153
154export function TrialCard({
155 entitlements,
156 trialMicros,
157 owner,
158 enabled,
159 error,
160}: {
161 entitlements: Entitlements | null;
162 trialMicros: number;
163 owner: boolean;
164 enabled: boolean;
165 error?: string;
166}) {
167 const verified = entitlements?.trialVerified ?? false;
168 const left = entitlements?.trialMicrosLeft ?? 0;
169 return (
170 <Card
171 id="trial"
172 icon={<ShieldCheck size={16} />}
173 title={`Try it with ${wholeDollars(trialMicros)} of usage`}
174 about={
175 verified
176 ? left > 0
177 ? "Your trial pays for checks, workflows and deployments until it is used. It is given once per workspace."
178 : "This workspace has no trial left. Start the plan to keep going."
179 : "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."
180 }
181 >
182 {verified && left > 0 && <Meter label="Trial used" used={Math.max(0, trialMicros - left)} of={trialMicros} />}
183 {!verified && enabled && owner && (
184 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
185 <SubmitButton variant="outline" name="intent" value="card-check" pending="Opening Stripe…">
186 <ShieldCheck size={14} />
187 Check a card
188 </SubmitButton>
189 <span className="text-xs text-faint">Trials come from a monthly pool; when it is given out, new ones start on the 1st.</span>
190 </Form>
191 )}
192 {!verified && !owner && <p className="mt-3 text-sm text-muted">An owner can do the card check.</p>}
193 <ErrorText>{error}</ErrorText>
194 </Card>
195 );
196}
197
198// --- Spend limit -----------------------------------------------------------------------
199
200export function SpendLimitCard({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) {
201 const range = spendRange(limit);
202 const spent = limit.spentMicros ?? 0;
203 const spendLimit = limit.spendLimitMicros;
204 const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "full" : "fixed";
205 const tone = limit.state === "stopped" ? "danger" : limit.state === "warning" ? "warn" : "plain";
206 return (
207 <Card
208 id="limit"
209 tone={tone}
210 icon={<Gauge size={16} />}
211 title="Spend limit"
212 about="What on-demand usage may reach this month, past what the plan includes. At the limit new work waits; runs already going finish."
213 aside={limit.firstMonth ? <Badge size="md">First month</Badge> : null}
214 >
215 {limit.account.startsWith("ent_") && (
216 <p className="mt-2 text-sm text-muted">
217 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are for all of its workspaces together.
218 </p>
219 )}
220 <div className="grid gap-x-8 sm:grid-cols-2">
221 <Meter
222 label="Charged this month"
223 used={spent}
224 of={spendLimit}
225 detail={
226 mode === "automatic"
227 ? "Automatic: $200, or twice last month's spend, within what is available."
228 : mode === "fixed"
229 ? "A limit you set."
230 : "As high as is available."
231 }
232 />
233 <Meter
234 label="Not yet paid"
235 used={limit.exposureMicros}
236 of={limit.ceilingMicros}
237 detail={limit.growth ?? "Charged to the card as it nears the limit, so work keeps going."}
238 />
239 </div>
240 {limit.firstMonth && spendLimit != null && (limit.trustCeilingMicros ?? limit.ceilingMicros) != null && (
241 <p className="mt-4 text-sm text-muted">
242 In the first month, work waits at whichever comes first: {wholeDollars(spendLimit)} charged this month (your spend limit), or{" "}
243 {wholeDollars(limit.trustCeilingMicros ?? limit.ceilingMicros ?? 0)} not yet paid for (the first month's limit). The card is charged as usage nears
244 it, which makes room again.
245 </p>
246 )}
247 {limit.message && <p className="mt-4 text-sm">{limit.message}</p>}
248
249 <div className="mt-5 rounded-lg border border-line bg-bg/40 p-4 text-sm">
250 <p>
251 {range.selfServeMicros == null ? (
252 "No ceiling: set any limit."
253 ) : (
254 <>
255 You can set up to <span className="font-medium tabular-nums">{wholeDollars(range.selfServeMicros)}</span> yourself: the highest limit this
256 workspace has had{(limit.prepaidMicros ?? 0) > 0 ? ", plus what is prepaid" : ""}.
257 </>
258 )}
259 </p>
260 <p className="mt-1 text-muted">
261 {range.raiseOnceMicros != null ? (
262 <>
263 Once, you can raise it yourself to <span className="tabular-nums text-fg">{wholeDollars(range.raiseOnceMicros)}</span>. Past that, prepay or use
264 Raise my limit.
265 </>
266 ) : range.raisedAt ? (
267 <>The one-time raise was used on {new Date(range.raisedAt).toLocaleDateString()}. Prepay or use Raise my limit for more.</>
268 ) : range.selfServeMicros != null ? (
269 "For more, prepay or use Raise my limit."
270 ) : null}
271 </p>
272 </div>
273
274 {owner && (
275 <Form method="post" className="mt-4">
276 <input type="hidden" name="intent" value="spend-limit" />
277 <fieldset>
278 <legend className="text-xs text-muted">Your monthly spend limit</legend>
279 <RadioGroup name="mode" defaultValue={mode} className="mt-2 gap-2.5 text-sm">
280 <label className="flex cursor-pointer items-center gap-2">
281 <RadioGroupItem value="automatic" />
282 Automatic
283 </label>
284 <label className="flex cursor-pointer flex-wrap items-center gap-2">
285 <RadioGroupItem value="fixed" />
286 Fixed at
287 <DollarInput
288 name="limit"
289 label="Spend limit in dollars"
290 defaultValue={mode === "fixed" && spendLimit != null ? String(spendLimit / 1_000_000) : ""}
291 placeholder={range.selfServeMicros != null ? String(range.selfServeMicros / 1_000_000) : "500"}
292 />
293 </label>
294 <label className="flex cursor-pointer items-center gap-2">
295 <RadioGroupItem value="full" />
296 Everything available{range.selfServeMicros != null ? ` (${wholeDollars(range.selfServeMicros)})` : ""}
297 </label>
298 {range.raiseOnceMicros != null && (
299 <label className="flex cursor-pointer items-center gap-2">
300 <RadioGroupItem value="raise" />
301 Use my one-time raise, to the amount above or up to {wholeDollars(range.raiseOnceMicros)}
302 </label>
303 )}
304 </RadioGroup>
305 </fieldset>
306 <div className="mt-3 flex flex-wrap items-center gap-3">
307 <SubmitButton variant="outline" match={{ intent: "spend-limit" }} pending="Saving…">
308 Save limit
309 </SubmitButton>
310 <a href="#raise" className="text-sm text-muted hover:text-fg">
311 Need more? Raise my limit
312 </a>
313 </div>
314 <ErrorText>{error}</ErrorText>
315 </Form>
316 )}
317 </Card>
318 );
319}
320
321// --- Raise my limit -----------------------------------------------------------------------
322
323function RequestList({ requests }: { requests: LimitRequest[] }) {
324 if (requests.length === 0) return null;
325 return (
326 <ul className="mt-4 divide-y divide-line rounded-lg border border-line text-sm">
327 {requests.map((request) => (
328 <li key={request.id} className="px-4 py-2.5">
329 <p className="flex flex-wrap items-center justify-between gap-2">
330 <span>
331 {request.kind === "limit" ? `Asked for ${wholeDollars(request.amountMicros)}` : "Told g1t about this month"}
332 <span className="text-faint"> · {new Date(request.createdAt).toLocaleDateString()}</span>
333 </span>
334 <span
335 className={`rounded-full px-2 py-0.5 text-xs ${
336 request.status === "approved"
337 ? "bg-success/15 text-success"
338 : request.status === "declined"
339 ? "bg-danger/15 text-danger"
340 : "border border-line text-muted"
341 }`}
342 >
343 {requestStatus(request)}
344 </span>
345 </p>
346 {request.answer && <p className="mt-1 text-muted">{request.answer}</p>}
347 </li>
348 ))}
349 </ul>
350 );
351}
352
353export function RaiseCard({ requests, owner, error }: { requests: LimitRequest[] | Failed; owner: boolean; error?: string }) {
354 const about =
355 "Ask for a higher limit than you can set yourself. A person at g1t reads it and answers within one business day, in the app and by email. Nothing about price is negotiated.";
356 // Without the requests there is no telling whether one is open: no form,
357 // so a second one is never sent while the first waits.
358 if (isFailed(requests)) {
359 return (
360 <Card id="raise" title="Raise my limit" about={about}>
361 <div className="mt-3">
362 <Unavailable what={requests.what} compact />
363 </div>
364 </Card>
365 );
366 }
367 const mine = requests.filter((request) => request.kind === "limit");
368 const waiting = mine.some((request) => request.status === "open");
369 return (
370 <Card id="raise" title="Raise my limit" about={about}>
371 {owner && !waiting && (
372 <Form method="post" className="mt-4 space-y-3">
373 <input type="hidden" name="intent" value="request" />
374 <input type="hidden" name="kind" value="limit" />
375 <div className="flex flex-wrap gap-4">
376 <label className="text-sm">
377 <span className="mb-1 block text-xs text-muted">Limit you need</span>
378 <DollarInput name="amount" label="Limit you need in dollars" placeholder="2000" />
379 </label>
380 <label className="text-sm">
381 <span className="mb-1 block text-xs text-muted">What you expect to spend a month</span>
382 <DollarInput name="expected" label="Expected monthly spend in dollars" placeholder="1500" />
383 </label>
384 </div>
385 <label className="block text-sm">
386 <span className="mb-1 block text-xs text-muted">What it is for</span>
387 <textarea
388 name="reason"
389 rows={2}
390 maxLength={2000}
391 placeholder="A launch next week: builds and previews across three projects."
392 className={`${CONTROL} w-full`}
393 />
394 </label>
395 <SubmitButton variant="outline" match={{ intent: "request", kind: "limit" }} pending="Sending…">
396 Send the request
397 </SubmitButton>
398 <ErrorText>{error}</ErrorText>
399 </Form>
400 )}
401 {owner && waiting && <p className="mt-3 text-sm text-muted">Your request is with g1t. You can send another once it is answered.</p>}
402 {!owner && <p className="mt-3 text-sm text-muted">An owner can ask.</p>}
403 <RequestList requests={mine} />
404 </Card>
405 );
406}
407
408// --- Prepay -----------------------------------------------------------------------
409
410/**
411 * Credit g1t gave the workspace: what is left, each grant in a line with
412 * its expiry, and what each was for. Spent before anything prepaid.
413 */
414export function CreditsCard({ credits }: { credits: Credits }) {
415 const open = credits.grants.filter((grant) => grant.state === "open");
416 const past = credits.grants.filter((grant) => grant.state !== "open");
417 return (
418 <Card
419 id="credits"
420 icon={<Gift size={16} />}
421 title="Credits from g1t"
422 about="Credit g1t gave this workspace. It pays for usage before anything prepaid, the soonest-expiring first. Unused credit stops counting when it expires."
423 aside={
424 <p className="shrink-0 sm:text-right">
425 <span className="block text-xs text-muted">Credit left</span>
426 <span className="text-xl font-semibold tabular-nums">{money(credits.leftMicros)}</span>
427 </p>
428 }
429 >
430 <ul className="mt-4 divide-y divide-line rounded-lg border border-line">
431 {[...open, ...past].map((grant) => (
432 <li key={grant.id} className={`px-3.5 py-2.5 text-sm ${grant.state === "open" ? "" : "text-muted"}`}>
433 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
434 <span className={`tabular-nums ${grant.state === "open" ? "font-medium" : ""}`}>{creditLine(grant)}</span>
435 <Badge size="md" className="py-px">
436 {CREDIT_KIND[grant.kind]}
437 </Badge>
438 </p>
439 <p className="mt-0.5 text-xs text-faint">
440 {grant.kind === "refund" && grant.refundFor ? `For ${grant.refundFor}. ` : ""}
441 {grant.note} · given {shortDay(grant.createdAt)}
442 </p>
443 </li>
444 ))}
445 </ul>
446 </Card>
447 );
448}
449
450export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) {
451 return (
452 <Card
453 id="prepay"
454 icon={<Landmark size={16} />}
455 title="Prepay"
456 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."
457 aside={
458 <p className="shrink-0 sm:text-right">
459 <span className="block text-xs text-muted">Prepaid balance</span>
460 <span className="text-xl font-semibold tabular-nums">{money(prepaidMicros)}</span>
461 </p>
462 }
463 >
464 {owner ? (
465 <Form method="post" className="mt-4 space-y-3">
466 <input type="hidden" name="intent" value="prepay" />
467 <div className="flex flex-wrap items-center gap-2">
468 {PREPAY.presets.map((amount) => (
469 <SubmitButton key={amount} variant="outline" name="amount" value={amount} match={{ intent: "prepay" }} pending="Opening Stripe…">
470 {wholeDollars(amount * 1_000_000)}
471 </SubmitButton>
472 ))}
473 <span className="text-sm text-muted">or</span>
474 <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" />
475 {/* An empty amount, so a preset's press is not this button's: the typed amount is used. */}
476 <SubmitButton variant="accent" name="amount" value="" match={{ intent: "prepay" }} pending="Opening Stripe…">
477 <CreditCard size={14} />
478 Prepay
479 </SubmitButton>
480 </div>
481 <RadioGroup name="method" defaultValue="card" className="flex flex-wrap gap-x-5 gap-y-2 text-sm">
482 <label className="flex cursor-pointer items-center gap-2">
483 <RadioGroupItem value="card" />
484 By card, with 3-D Secure ({wholeDollars(PREPAY.min * 1_000_000)} to {wholeDollars(PREPAY.maxCard * 1_000_000)})
485 </label>
486 <label className="flex cursor-pointer items-center gap-2">
487 <RadioGroupItem value="bank_transfer" />
488 By bank transfer (from {wholeDollars(PREPAY.bankFrom * 1_000_000)}; counted when it arrives)
489 </label>
490 </RadioGroup>
491 <p className="text-xs text-faint">
492 No fee on top, by card or bank transfer. Tax is added where it applies, from your billing address; what you prepay is credited in full.
493 </p>
494 {!live && <p className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>}
495 <ErrorText>{error}</ErrorText>
496 </Form>
497 ) : (
498 <p className="mt-3 text-sm text-muted">An owner can prepay.</p>
499 )}
500 </Card>
501 );
502}
503
504// --- Overage -----------------------------------------------------------------------
505
506export function OverageCard({ requests, owner, error }: { requests: LimitRequest[] | Failed; owner: boolean; error?: string }) {
507 const title = "Spent more than you meant to? Tell us.";
508 const about =
509 "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.";
510 // As on Raise my limit: no form while an open request may be unseen.
511 if (isFailed(requests)) {
512 return (
513 <Card id="overage" title={title} about={about}>
514 <div className="mt-3">
515 <Unavailable what={requests.what} compact />
516 </div>
517 </Card>
518 );
519 }
520 const mine = requests.filter((request) => request.kind === "overage");
521 const waiting = mine.some((request) => request.status === "open");
522 return (
523 <Card id="overage" title={title} about={about}>
524 {owner && !waiting && (
525 <Form method="post" className="mt-4 space-y-3">
526 <input type="hidden" name="intent" value="request" />
527 <input type="hidden" name="kind" value="overage" />
528 <textarea
529 name="reason"
530 rows={2}
531 maxLength={2000}
532 aria-label="What happened"
533 placeholder="A workflow kept retrying a failing check on Tuesday night."
534 className={`${CONTROL} w-full`}
535 />
536 <SubmitButton variant="outline" match={{ intent: "request", kind: "overage" }} pending="Sending…">
537 Tell g1t
538 </SubmitButton>
539 <ErrorText>{error}</ErrorText>
540 </Form>
541 )}
542 {owner && waiting && <p className="mt-3 text-sm text-muted">g1t has your note and answers within one business day.</p>}
543 {!owner && <p className="mt-3 text-sm text-muted">An owner can tell g1t.</p>}
544 <RequestList requests={mine} />
545 </Card>
546 );
547}