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.

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