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