| 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, credits from g1t, 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 | */ |
| 7 | import { ArrowUpRight, CreditCard, Gauge, Gift, Landmark, ShieldCheck, Sparkles } from "lucide-react"; |
| 8 | import type { ReactNode } from "react"; |
| 9 | import { Form } from "react-router"; |
| 10 | |
| 11 | import type { Credits, Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, UsageAlert } from "@g1t/contracts"; |
| 12 | |
| 13 | import { |
| 14 | CAPS, |
| 15 | CREDIT_KIND, |
| 16 | PLUS_TAX, |
| 17 | PREPAY, |
| 18 | type PlanStatus, |
| 19 | alertText, |
| 20 | alertTone, |
| 21 | cardFeeCents, |
| 22 | creditLine, |
| 23 | gigabytes, |
| 24 | requestStatus, |
| 25 | share, |
| 26 | shortDay, |
| 27 | shownMeters, |
| 28 | spendRange, |
| 29 | } from "../lib/billing"; |
| 30 | import { money, wholeDollars } from "../lib/money"; |
| 31 | import { ErrorText, SubmitButton } from "./ui"; |
| 32 | import { Badge } from "./ui/badge"; |
| 33 | import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; |
| 34 | |
| 35 | /** An error the action returned, for the section it belongs to. */ |
| 36 | export type SectionError = { section: string; error: string } | null | undefined; |
| 37 | |
| 38 | function errorFor(error: SectionError, section: string): string | undefined { |
| 39 | return error && error.section === section ? error.error : undefined; |
| 40 | } |
| 41 | |
| 42 | const CONTROL = |
| 43 | "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"; |
| 44 | |
| 45 | /** A dollar amount field: a `$` and a number. */ |
| 46 | function DollarInput({ name, defaultValue, placeholder, label }: { name: string; defaultValue?: string; placeholder?: string; label: string }) { |
| 47 | return ( |
| 48 | <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent-dim"> |
| 49 | <span className="text-sm text-muted">$</span> |
| 50 | <input |
| 51 | name={name} |
| 52 | inputMode="decimal" |
| 53 | aria-label={label} |
| 54 | defaultValue={defaultValue} |
| 55 | placeholder={placeholder} |
| 56 | autoComplete="off" |
| 57 | data-1p-ignore |
| 58 | data-lpignore="true" |
| 59 | className="w-24 bg-transparent px-1 py-1.5 text-sm tabular-nums outline-none placeholder:text-faint" |
| 60 | /> |
| 61 | </span> |
| 62 | ); |
| 63 | } |
| 64 | |
| 65 | /** A bordered card with a heading, a line about it, and a tone. */ |
| 66 | export function Card({ |
| 67 | id, |
| 68 | title, |
| 69 | icon, |
| 70 | aside, |
| 71 | about, |
| 72 | tone = "plain", |
| 73 | children, |
| 74 | }: { |
| 75 | id?: string; |
| 76 | title: ReactNode; |
| 77 | icon?: ReactNode; |
| 78 | aside?: ReactNode; |
| 79 | about?: ReactNode; |
| 80 | tone?: "plain" | "accent" | "warn" | "danger"; |
| 81 | children?: ReactNode; |
| 82 | }) { |
| 83 | const tones = { |
| 84 | plain: "border-line bg-surface", |
| 85 | accent: "border-accent/40 bg-accent/5", |
| 86 | warn: "border-warn/40 bg-warn/5", |
| 87 | danger: "border-danger/40 bg-danger/5", |
| 88 | }; |
| 89 | return ( |
| 90 | <section id={id} className={`mb-6 scroll-mt-20 rounded-xl border p-5 ${tones[tone]}`}> |
| 91 | <div className="flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap"> |
| 92 | <div className="min-w-0 basis-full sm:basis-0 sm:flex-1"> |
| 93 | <h2 className="flex flex-wrap items-center gap-2 font-medium"> |
| 94 | {icon && <span className="text-accent">{icon}</span>} |
| 95 | {title} |
| 96 | </h2> |
| 97 | {about && <div className="mt-1 max-w-2xl text-sm text-muted">{about}</div>} |
| 98 | </div> |
| 99 | {aside} |
| 100 | </div> |
| 101 | {children} |
| 102 | </section> |
| 103 | ); |
| 104 | } |
| 105 | |
| 106 | /** One meter: how much of something is used. */ |
| 107 | export function Meter({ |
| 108 | used, |
| 109 | of, |
| 110 | label, |
| 111 | detail, |
| 112 | calm = false, |
| 113 | }: { |
| 114 | used: number; |
| 115 | of: number | null; |
| 116 | label: string; |
| 117 | detail?: ReactNode; |
| 118 | /** Reaching the end is ordinary, not a warning: the plan's included usage, past which usage simply goes on demand. */ |
| 119 | calm?: boolean; |
| 120 | }) { |
| 121 | const part = share(used, of); |
| 122 | const bar = calm ? "bg-success" : of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-success"; |
| 123 | return ( |
| 124 | <div className="mt-4"> |
| 125 | <div className="flex flex-wrap justify-between gap-x-4 text-sm"> |
| 126 | <span className="text-muted">{label}</span> |
| 127 | <span className="tabular-nums"> |
| 128 | {money(used)} |
| 129 | {of != null && <span className="text-faint"> of {money(of)}</span>} |
| 130 | </span> |
| 131 | </div> |
| 132 | <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-line" role="presentation"> |
| 133 | <div className={`h-full ${bar}`} style={{ width: `${Math.max(part * 100, part > 0 ? 2 : 0)}%` }} /> |
| 134 | </div> |
| 135 | {detail && <p className="mt-1.5 text-xs text-faint">{detail}</p>} |
| 136 | </div> |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | // --- Spike ------------------------------------------------------------------------ |
| 141 | |
| 142 | // In its own module, so the app shell carries it without the billing page. |
| 143 | export { SpikeBanner } from "./spike-banner"; |
| 144 | |
| 145 | // --- Alerts ----------------------------------------------------------------------- |
| 146 | |
| 147 | export function Alerts({ alerts }: { alerts: UsageAlert[] }) { |
| 148 | const shown = alerts.filter((alert) => alert.level >= 50).sort((a, b) => b.level - a.level); |
| 149 | if (shown.length === 0) return null; |
| 150 | return ( |
| 151 | <ul className="mb-6 space-y-2"> |
| 152 | {shown.map((alert) => { |
| 153 | const tone = alertTone(alert.level); |
| 154 | return ( |
| 155 | <li |
| 156 | key={`${alert.meter}-${alert.level}`} |
| 157 | className={`flex gap-2.5 rounded-lg border px-4 py-2.5 text-sm ${ |
| 158 | tone === "stopped" ? "border-danger/40 bg-danger/5" : tone === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface" |
| 159 | }`} |
| 160 | > |
| 161 | <Gauge size={15} className={`mt-0.5 shrink-0 ${tone === "stopped" ? "text-danger" : tone === "warning" ? "text-warn" : "text-muted"}`} /> |
| 162 | <span>{alert.message || alertText(alert)}</span> |
| 163 | </li> |
| 164 | ); |
| 165 | })} |
| 166 | </ul> |
| 167 | ); |
| 168 | } |
| 169 | |
| 170 | // --- The plan ----------------------------------------------------------------------- |
| 171 | |
| 172 | const STATUS_TONE: Record<PlanStatus["kind"], "plain" | "accent" | "warn"> = { |
| 173 | free: "plain", |
| 174 | trial: "plain", |
| 175 | paid: "accent", |
| 176 | canceling: "warn", |
| 177 | past_due: "warn", |
| 178 | comped: "accent", |
| 179 | enterprise: "accent", |
| 180 | }; |
| 181 | |
| 182 | export function PlanCard({ |
| 183 | state, |
| 184 | status, |
| 185 | entitlements, |
| 186 | owner, |
| 187 | enabled, |
| 188 | live, |
| 189 | meters, |
| 190 | error, |
| 191 | }: { |
| 192 | state: FeatureState | null; |
| 193 | status: PlanStatus; |
| 194 | entitlements: Entitlements | null; |
| 195 | owner: boolean; |
| 196 | enabled: boolean; |
| 197 | live: boolean; |
| 198 | /** This month's usage by meter, from billing's `usage_meters`. */ |
| 199 | meters: MeterUsage[] | null; |
| 200 | error?: string; |
| 201 | }) { |
| 202 | const plan = state?.plan; |
| 203 | const subscription = state?.subscription ?? null; |
| 204 | const on = ["paid", "canceling", "comped", "enterprise"].includes(status.kind); |
| 205 | return ( |
| 206 | <Card |
| 207 | id="plan" |
| 208 | tone={STATUS_TONE[status.kind]} |
| 209 | icon={<Sparkles size={16} />} |
| 210 | title={ |
| 211 | <> |
| 212 | {plan?.title ?? "g1t"} |
| 213 | <span |
| 214 | className={`rounded-full px-2 py-0.5 text-xs ${ |
| 215 | status.kind === "past_due" || status.kind === "canceling" |
| 216 | ? "bg-warn/15 text-warn" |
| 217 | : on |
| 218 | ? "bg-success/15 text-success" |
| 219 | : "border border-line text-muted" |
| 220 | }`} |
| 221 | > |
| 222 | {status.label} |
| 223 | {status.kind === "canceling" && subscription?.periodEnd ? `, ${new Date(subscription.periodEnd).toLocaleDateString()}` : ""} |
| 224 | </span> |
| 225 | </> |
| 226 | } |
| 227 | about={ |
| 228 | on |
| 229 | ? "Everything on g1t for the workspace: agents, checks, workflows, the merge queue, deployments and semantic search." |
| 230 | : "The forge is free: repositories, issues, pull requests, reviews, search and the audit log. Anything that runs compute needs the plan, or the trial." |
| 231 | } |
| 232 | aside={ |
| 233 | <p className="shrink-0 sm:text-right"> |
| 234 | <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span> |
| 235 | <span className="text-sm text-muted"> / month</span> |
| 236 | <span className="block text-xs text-faint">per workspace, never per seat</span> |
| 237 | <span className="block text-xs text-faint"> |
| 238 | {plan?.cardFeeCents ? `+ ${money(plan.cardFeeCents * 10_000)} card processing fee, ${PLUS_TAX}` : "Plus tax where it applies"} |
| 239 | </span> |
| 240 | </p> |
| 241 | } |
| 242 | > |
| 243 | {plan && ( |
| 244 | <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2"> |
| 245 | {plan.includes.map((line) => ( |
| 246 | <li key={line} className="flex gap-2"> |
| 247 | <span className="text-success">✓</span> |
| 248 | {line} |
| 249 | </li> |
| 250 | ))} |
| 251 | </ul> |
| 252 | )} |
| 253 | {plan && <p className="mt-3 text-xs text-faint">{plan.overage}</p>} |
| 254 | |
| 255 | {on && entitlements?.includedMicros ? ( |
| 256 | <Meter |
| 257 | label="Included usage this month" |
| 258 | used={entitlements.includedUsedMicros ?? 0} |
| 259 | of={entitlements.includedMicros} |
| 260 | calm |
| 261 | 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." |
| 262 | /> |
| 263 | ) : null} |
| 264 | {meters && <MonthUsage meters={meters} on={on} comped={status.kind === "comped"} entitlements={entitlements} />} |
| 265 | |
| 266 | {status.kind === "comped" || status.kind === "enterprise" ? ( |
| 267 | <p className="mt-4 text-sm text-muted"> |
| 268 | {status.kind === "comped" |
| 269 | ? "This workspace has a 100% discount from g1t: the plan and its usage are shown at their price, and nothing is charged." |
| 270 | : "An enterprise pays for this workspace, on its own invoice."} |
| 271 | </p> |
| 272 | ) : !enabled ? ( |
| 273 | <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so the plan cannot be started here.</p> |
| 274 | ) : !owner ? ( |
| 275 | <p className="mt-4 text-sm text-muted">An owner manages the plan.</p> |
| 276 | ) : ( |
| 277 | <Form method="post" className="mt-5 flex flex-wrap items-center gap-3"> |
| 278 | <input type="hidden" name="feature" value={plan?.feature ?? "plan"} /> |
| 279 | {status.kind === "free" || status.kind === "trial" ? ( |
| 280 | <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…"> |
| 281 | <CreditCard size={14} /> |
| 282 | Start the g1t plan |
| 283 | </SubmitButton> |
| 284 | ) : status.kind === "canceling" ? ( |
| 285 | <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…"> |
| 286 | Keep the plan |
| 287 | </SubmitButton> |
| 288 | ) : null} |
| 289 | {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && ( |
| 290 | <SubmitButton variant={status.kind === "past_due" ? "accent" : "outline"} name="intent" value="portal" pending="Opening Stripe…"> |
| 291 | {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"} |
| 292 | <ArrowUpRight size={14} /> |
| 293 | </SubmitButton> |
| 294 | )} |
| 295 | {status.kind === "paid" && subscription && ( |
| 296 | <SubmitButton variant="outline" name="intent" value="cancel" pending="Saving…"> |
| 297 | End at the end of the period |
| 298 | </SubmitButton> |
| 299 | )} |
| 300 | {!live && (status.kind === "free" || status.kind === "trial") && ( |
| 301 | <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span> |
| 302 | )} |
| 303 | </Form> |
| 304 | )} |
| 305 | <ErrorText>{error}</ErrorText> |
| 306 | {(status.kind === "free" || status.kind === "trial") && ( |
| 307 | <p className="mt-4 text-xs text-faint"> |
| 308 | Your first month starts at a {wholeDollars(100_000_000)} limit on usage past what is included, so a stolen card cannot run |
| 309 | up a bill. It rises as payments clear, and you can prepay or ask to raise it at once. |
| 310 | </p> |
| 311 | )} |
| 312 | </Card> |
| 313 | ); |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * This month's usage, one line per meter, in dollars at cost plus 20% and |
| 318 | * in what was used. No quotas: every line is metered from the first unit. |
| 319 | */ |
| 320 | function MonthUsage({ |
| 321 | meters, |
| 322 | on, |
| 323 | comped, |
| 324 | entitlements, |
| 325 | }: { |
| 326 | meters: MeterUsage[]; |
| 327 | on: boolean; |
| 328 | comped: boolean; |
| 329 | entitlements: Entitlements | null; |
| 330 | }) { |
| 331 | const freeStorage = gigabytes(entitlements?.freePrivateStorageBytes ?? 1_000_000_000); |
| 332 | const freeGit = (entitlements?.gitOperationsIncluded ?? 50_000).toLocaleString("en-US"); |
| 333 | const rows = shownMeters(meters, on); |
| 334 | const total = rows.reduce((sum, row) => sum + row.micros, 0); |
| 335 | return ( |
| 336 | <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4"> |
| 337 | <div className="flex items-baseline justify-between gap-4"> |
| 338 | <p className="text-xs font-medium text-muted">This month's usage</p> |
| 339 | <p className="text-xs text-faint">At cost plus 20%</p> |
| 340 | </div> |
| 341 | <ul className="mt-2 divide-y divide-line/60"> |
| 342 | {rows.map((row) => ( |
| 343 | <li key={row.key} className="flex items-baseline justify-between gap-4 py-2 text-sm"> |
| 344 | <span className="min-w-0"> |
| 345 | <span>{row.label}</span> |
| 346 | {(row.quantity || row.micros === 0) && ( |
| 347 | <span className="block truncate text-xs text-faint">{row.quantity ?? "None yet"}</span> |
| 348 | )} |
| 349 | </span> |
| 350 | <span className={`shrink-0 tabular-nums ${row.micros > 0 ? "" : "text-faint"}`}>{money(row.micros)}</span> |
| 351 | </li> |
| 352 | ))} |
| 353 | </ul> |
| 354 | <div className="mt-1 flex items-baseline justify-between gap-4 border-t border-line pt-2.5 text-sm"> |
| 355 | {/* At price, before the plan's included usage, the trial and the pools paid |
| 356 | their part: Usage shows what was charged. */} |
| 357 | <span className="font-medium">Total at price</span> |
| 358 | <span className="font-medium tabular-nums">{money(total)}</span> |
| 359 | </div> |
| 360 | <p className="mt-2 text-xs text-faint"> |
| 361 | {comped |
| 362 | ? "What this workspace's usage comes to at price. Its 100% discount takes all of it off." |
| 363 | : on |
| 364 | ? `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, after the included usage and any discount, like the rest.` |
| 365 | : `The forge is free: ${freeStorage} of private storage and ${freeGit} git operations a month. Agents run from the trial, and checks, workflows and the merge queue on public repositories from the open-source pool; together they pay part of this total: Usage shows what was charged.`} |
| 366 | </p> |
| 367 | </div> |
| 368 | ); |
| 369 | } |
| 370 | |
| 371 | // --- The trial ----------------------------------------------------------------------- |
| 372 | |
| 373 | export function TrialCard({ |
| 374 | entitlements, |
| 375 | trialMicros, |
| 376 | owner, |
| 377 | enabled, |
| 378 | error, |
| 379 | }: { |
| 380 | entitlements: Entitlements | null; |
| 381 | trialMicros: number; |
| 382 | owner: boolean; |
| 383 | enabled: boolean; |
| 384 | error?: string; |
| 385 | }) { |
| 386 | const verified = entitlements?.trialVerified ?? false; |
| 387 | const left = entitlements?.trialMicrosLeft ?? 0; |
| 388 | return ( |
| 389 | <Card |
| 390 | id="trial" |
| 391 | icon={<ShieldCheck size={16} />} |
| 392 | title={`Try it with ${wholeDollars(trialMicros)} of usage`} |
| 393 | about={ |
| 394 | verified |
| 395 | ? left > 0 |
| 396 | ? "Your trial pays for agents, checks and workflows until it is used. It is given once per workspace." |
| 397 | : "This workspace has no trial left. Start the plan to keep going." |
| 398 | : "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." |
| 399 | } |
| 400 | > |
| 401 | {verified && left > 0 && <Meter label="Trial used" used={Math.max(0, trialMicros - left)} of={trialMicros} />} |
| 402 | {!verified && enabled && owner && ( |
| 403 | <Form method="post" className="mt-4 flex flex-wrap items-center gap-3"> |
| 404 | <SubmitButton variant="outline" name="intent" value="card-check" pending="Opening Stripe…"> |
| 405 | <ShieldCheck size={14} /> |
| 406 | Check a card |
| 407 | </SubmitButton> |
| 408 | <span className="text-xs text-faint">Trials come from a monthly pool; when it is given out, new ones start on the 1st.</span> |
| 409 | </Form> |
| 410 | )} |
| 411 | {!verified && !owner && <p className="mt-3 text-sm text-muted">An owner can do the card check.</p>} |
| 412 | <ErrorText>{error}</ErrorText> |
| 413 | </Card> |
| 414 | ); |
| 415 | } |
| 416 | |
| 417 | // --- Spend limit ----------------------------------------------------------------------- |
| 418 | |
| 419 | export function SpendLimitCard({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) { |
| 420 | const range = spendRange(limit); |
| 421 | const spent = limit.spentMicros ?? 0; |
| 422 | const spendLimit = limit.spendLimitMicros; |
| 423 | const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "full" : "fixed"; |
| 424 | const tone = limit.state === "stopped" ? "danger" : limit.state === "warning" ? "warn" : "plain"; |
| 425 | return ( |
| 426 | <Card |
| 427 | id="limit" |
| 428 | tone={tone} |
| 429 | icon={<Gauge size={16} />} |
| 430 | title="Spend limit" |
| 431 | about="What on-demand usage may reach this month, past what the plan includes. At the limit new work waits; runs already going finish." |
| 432 | aside={limit.firstMonth ? <Badge size="md">First month</Badge> : null} |
| 433 | > |
| 434 | {limit.account.startsWith("ent_") && ( |
| 435 | <p className="mt-2 text-sm text-muted"> |
| 436 | Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are for all of its |
| 437 | workspaces together. |
| 438 | </p> |
| 439 | )} |
| 440 | <div className="grid gap-x-8 sm:grid-cols-2"> |
| 441 | <Meter |
| 442 | label="Charged this month" |
| 443 | used={spent} |
| 444 | of={spendLimit} |
| 445 | detail={ |
| 446 | mode === "automatic" |
| 447 | ? "Automatic: $200, or twice last month's spend, within what is available." |
| 448 | : mode === "fixed" |
| 449 | ? "A limit you set." |
| 450 | : "As high as is available." |
| 451 | } |
| 452 | /> |
| 453 | <Meter |
| 454 | label="Not yet paid" |
| 455 | used={limit.exposureMicros} |
| 456 | of={limit.ceilingMicros} |
| 457 | detail={limit.growth ?? "Charged to the card as it nears the limit, so work keeps going."} |
| 458 | /> |
| 459 | </div> |
| 460 | {limit.message && <p className="mt-4 text-sm">{limit.message}</p>} |
| 461 | |
| 462 | <div className="mt-5 rounded-lg border border-line bg-bg/40 p-4 text-sm"> |
| 463 | <p> |
| 464 | {range.selfServeMicros == null ? ( |
| 465 | "No ceiling: set any limit." |
| 466 | ) : ( |
| 467 | <> |
| 468 | You can set up to <span className="font-medium tabular-nums">{wholeDollars(range.selfServeMicros)}</span> yourself: the |
| 469 | highest limit this workspace has had{(limit.prepaidMicros ?? 0) > 0 ? ", plus what is prepaid" : ""}. |
| 470 | </> |
| 471 | )} |
| 472 | </p> |
| 473 | <p className="mt-1 text-muted"> |
| 474 | {range.raiseOnceMicros != null ? ( |
| 475 | <> |
| 476 | Once, you can raise it yourself to <span className="tabular-nums text-fg">{wholeDollars(range.raiseOnceMicros)}</span>. |
| 477 | Past that, prepay or use Raise my limit. |
| 478 | </> |
| 479 | ) : range.raisedAt ? ( |
| 480 | <>The one-time raise was used on {new Date(range.raisedAt).toLocaleDateString()}. Prepay or use Raise my limit for more.</> |
| 481 | ) : range.selfServeMicros != null ? ( |
| 482 | "For more, prepay or use Raise my limit." |
| 483 | ) : null} |
| 484 | </p> |
| 485 | </div> |
| 486 | |
| 487 | {owner && ( |
| 488 | <Form method="post" className="mt-4"> |
| 489 | <input type="hidden" name="intent" value="spend-limit" /> |
| 490 | <fieldset> |
| 491 | <legend className="text-xs text-muted">Your monthly spend limit</legend> |
| 492 | <RadioGroup name="mode" defaultValue={mode} className="mt-2 gap-2.5 text-sm"> |
| 493 | <label className="flex cursor-pointer items-center gap-2"> |
| 494 | <RadioGroupItem value="automatic" /> |
| 495 | Automatic |
| 496 | </label> |
| 497 | <label className="flex cursor-pointer flex-wrap items-center gap-2"> |
| 498 | <RadioGroupItem value="fixed" /> |
| 499 | Fixed at |
| 500 | <DollarInput |
| 501 | name="limit" |
| 502 | label="Spend limit in dollars" |
| 503 | defaultValue={mode === "fixed" && spendLimit != null ? String(spendLimit / 1_000_000) : ""} |
| 504 | placeholder={range.selfServeMicros != null ? String(range.selfServeMicros / 1_000_000) : "500"} |
| 505 | /> |
| 506 | </label> |
| 507 | <label className="flex cursor-pointer items-center gap-2"> |
| 508 | <RadioGroupItem value="full" /> |
| 509 | Everything available{range.selfServeMicros != null ? ` (${wholeDollars(range.selfServeMicros)})` : ""} |
| 510 | </label> |
| 511 | {range.raiseOnceMicros != null && ( |
| 512 | <label className="flex cursor-pointer items-center gap-2"> |
| 513 | <RadioGroupItem value="raise" /> |
| 514 | Use my one-time raise, to the amount above or up to {wholeDollars(range.raiseOnceMicros)} |
| 515 | </label> |
| 516 | )} |
| 517 | </RadioGroup> |
| 518 | </fieldset> |
| 519 | <div className="mt-3 flex flex-wrap items-center gap-3"> |
| 520 | <SubmitButton variant="outline" match={{ intent: "spend-limit" }} pending="Saving…"> |
| 521 | Save limit |
| 522 | </SubmitButton> |
| 523 | <a href="#raise" className="text-sm text-muted hover:text-fg"> |
| 524 | Need more? Raise my limit |
| 525 | </a> |
| 526 | </div> |
| 527 | <ErrorText>{error}</ErrorText> |
| 528 | </Form> |
| 529 | )} |
| 530 | </Card> |
| 531 | ); |
| 532 | } |
| 533 | |
| 534 | // --- Raise my limit ----------------------------------------------------------------------- |
| 535 | |
| 536 | function RequestList({ requests }: { requests: LimitRequest[] }) { |
| 537 | if (requests.length === 0) return null; |
| 538 | return ( |
| 539 | <ul className="mt-4 divide-y divide-line rounded-lg border border-line text-sm"> |
| 540 | {requests.map((request) => ( |
| 541 | <li key={request.id} className="px-4 py-2.5"> |
| 542 | <p className="flex flex-wrap items-center justify-between gap-2"> |
| 543 | <span> |
| 544 | {request.kind === "limit" ? `Asked for ${wholeDollars(request.amountMicros)}` : "Told g1t about this month"} |
| 545 | <span className="text-faint"> · {new Date(request.createdAt).toLocaleDateString()}</span> |
| 546 | </span> |
| 547 | <span |
| 548 | className={`rounded-full px-2 py-0.5 text-xs ${ |
| 549 | request.status === "approved" |
| 550 | ? "bg-success/15 text-success" |
| 551 | : request.status === "declined" |
| 552 | ? "bg-danger/15 text-danger" |
| 553 | : "border border-line text-muted" |
| 554 | }`} |
| 555 | > |
| 556 | {requestStatus(request)} |
| 557 | </span> |
| 558 | </p> |
| 559 | {request.answer && <p className="mt-1 text-muted">{request.answer}</p>} |
| 560 | </li> |
| 561 | ))} |
| 562 | </ul> |
| 563 | ); |
| 564 | } |
| 565 | |
| 566 | export function RaiseCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) { |
| 567 | const mine = requests.filter((request) => request.kind === "limit"); |
| 568 | const waiting = mine.some((request) => request.status === "open"); |
| 569 | return ( |
| 570 | <Card |
| 571 | id="raise" |
| 572 | title="Raise my limit" |
| 573 | 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." |
| 574 | > |
| 575 | {owner && !waiting && ( |
| 576 | <Form method="post" className="mt-4 space-y-3"> |
| 577 | <input type="hidden" name="intent" value="request" /> |
| 578 | <input type="hidden" name="kind" value="limit" /> |
| 579 | <div className="flex flex-wrap gap-4"> |
| 580 | <label className="text-sm"> |
| 581 | <span className="mb-1 block text-xs text-muted">Limit you need</span> |
| 582 | <DollarInput name="amount" label="Limit you need in dollars" placeholder="2000" /> |
| 583 | </label> |
| 584 | <label className="text-sm"> |
| 585 | <span className="mb-1 block text-xs text-muted">What you expect to spend a month</span> |
| 586 | <DollarInput name="expected" label="Expected monthly spend in dollars" placeholder="1500" /> |
| 587 | </label> |
| 588 | </div> |
| 589 | <label className="block text-sm"> |
| 590 | <span className="mb-1 block text-xs text-muted">What it is for</span> |
| 591 | <textarea |
| 592 | name="reason" |
| 593 | rows={2} |
| 594 | maxLength={2000} |
| 595 | placeholder="A launch next week: agents on 40 issues across three projects." |
| 596 | className={`${CONTROL} w-full`} |
| 597 | /> |
| 598 | </label> |
| 599 | <SubmitButton variant="outline" match={{ intent: "request", kind: "limit" }} pending="Sending…"> |
| 600 | Send the request |
| 601 | </SubmitButton> |
| 602 | <ErrorText>{error}</ErrorText> |
| 603 | </Form> |
| 604 | )} |
| 605 | {owner && waiting && <p className="mt-3 text-sm text-muted">Your request is with g1t. You can send another once it is answered.</p>} |
| 606 | {!owner && <p className="mt-3 text-sm text-muted">An owner can ask.</p>} |
| 607 | <RequestList requests={mine} /> |
| 608 | </Card> |
| 609 | ); |
| 610 | } |
| 611 | |
| 612 | // --- Prepay ----------------------------------------------------------------------- |
| 613 | |
| 614 | /** |
| 615 | * Credit g1t gave the workspace: what is left, each grant in a line with |
| 616 | * its expiry, and what each was for. Spent before anything prepaid. |
| 617 | */ |
| 618 | export function CreditsCard({ credits }: { credits: Credits }) { |
| 619 | const open = credits.grants.filter((grant) => grant.state === "open"); |
| 620 | const past = credits.grants.filter((grant) => grant.state !== "open"); |
| 621 | return ( |
| 622 | <Card |
| 623 | id="credits" |
| 624 | icon={<Gift size={16} />} |
| 625 | title="Credits from g1t" |
| 626 | about="Credit g1t gave this workspace. It pays for usage before anything prepaid, the soonest-expiring first. Unused credit stops counting when it expires." |
| 627 | aside={ |
| 628 | <p className="shrink-0 sm:text-right"> |
| 629 | <span className="block text-xs text-muted">Credit left</span> |
| 630 | <span className="text-xl font-semibold tabular-nums">{money(credits.leftMicros)}</span> |
| 631 | </p> |
| 632 | } |
| 633 | > |
| 634 | <ul className="mt-4 divide-y divide-line rounded-lg border border-line"> |
| 635 | {[...open, ...past].map((grant) => ( |
| 636 | <li key={grant.id} className={`px-3.5 py-2.5 text-sm ${grant.state === "open" ? "" : "text-muted"}`}> |
| 637 | <p className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 638 | <span className={`tabular-nums ${grant.state === "open" ? "font-medium" : ""}`}>{creditLine(grant)}</span> |
| 639 | <Badge size="md" className="py-px">{CREDIT_KIND[grant.kind]}</Badge> |
| 640 | </p> |
| 641 | <p className="mt-0.5 text-xs text-faint"> |
| 642 | {grant.kind === "refund" && grant.refundFor ? `For ${grant.refundFor}. ` : ""} |
| 643 | {grant.note} · given {shortDay(grant.createdAt)} |
| 644 | </p> |
| 645 | </li> |
| 646 | ))} |
| 647 | </ul> |
| 648 | </Card> |
| 649 | ); |
| 650 | } |
| 651 | |
| 652 | export function PrepayCard({ |
| 653 | prepaidMicros, |
| 654 | owner, |
| 655 | live, |
| 656 | cardFee, |
| 657 | error, |
| 658 | }: { |
| 659 | prepaidMicros: number; |
| 660 | owner: boolean; |
| 661 | live: boolean; |
| 662 | /** The card fee as billing charges it, to show it before paying. */ |
| 663 | cardFee?: { on: boolean; percentMicros: number; fixedCents: number } | null; |
| 664 | error?: string; |
| 665 | }) { |
| 666 | const fees = PREPAY.presets.map((amount) => `${money(cardFeeCents(amount * 100, cardFee) * 10_000)} on ${wholeDollars(amount * 1_000_000)}`); |
| 667 | return ( |
| 668 | <Card |
| 669 | id="prepay" |
| 670 | icon={<Landmark size={16} />} |
| 671 | title="Prepay" |
| 672 | 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." |
| 673 | aside={ |
| 674 | <p className="shrink-0 sm:text-right"> |
| 675 | <span className="block text-xs text-muted">Prepaid balance</span> |
| 676 | <span className="text-xl font-semibold tabular-nums">{money(prepaidMicros)}</span> |
| 677 | </p> |
| 678 | } |
| 679 | > |
| 680 | {owner ? ( |
| 681 | <Form method="post" className="mt-4 space-y-3"> |
| 682 | <input type="hidden" name="intent" value="prepay" /> |
| 683 | <div className="flex flex-wrap items-center gap-2"> |
| 684 | {PREPAY.presets.map((amount) => ( |
| 685 | <SubmitButton key={amount} variant="outline" name="amount" value={amount} match={{ intent: "prepay" }} pending="Opening Stripe…"> |
| 686 | {wholeDollars(amount * 1_000_000)} |
| 687 | </SubmitButton> |
| 688 | ))} |
| 689 | <span className="text-sm text-muted">or</span> |
| 690 | <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" /> |
| 691 | {/* An empty amount, so a preset's press is not this button's: the typed amount is used. */} |
| 692 | <SubmitButton variant="accent" name="amount" value="" match={{ intent: "prepay" }} pending="Opening Stripe…"> |
| 693 | <CreditCard size={14} /> |
| 694 | Prepay |
| 695 | </SubmitButton> |
| 696 | </div> |
| 697 | <RadioGroup name="method" defaultValue="card" className="flex flex-wrap gap-x-5 gap-y-2 text-sm"> |
| 698 | <label className="flex cursor-pointer items-center gap-2"> |
| 699 | <RadioGroupItem value="card" /> |
| 700 | By card, with 3-D Secure ({wholeDollars(PREPAY.min * 1_000_000)} to {wholeDollars(PREPAY.maxCard * 1_000_000)}) |
| 701 | </label> |
| 702 | <label className="flex cursor-pointer items-center gap-2"> |
| 703 | <RadioGroupItem value="bank_transfer" /> |
| 704 | By bank transfer (from {wholeDollars(PREPAY.bankFrom * 1_000_000)}; counted when it arrives) |
| 705 | </label> |
| 706 | </RadioGroup> |
| 707 | <p className="text-xs text-faint"> |
| 708 | {cardFee?.on ? `By card, a card processing fee is its own line (${fees.join(", ")}); a bank transfer has none. ` : ""} |
| 709 | Tax is added where it applies, from your billing address; what you prepay is credited in full. |
| 710 | </p> |
| 711 | {!live && <p className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>} |
| 712 | <ErrorText>{error}</ErrorText> |
| 713 | </Form> |
| 714 | ) : ( |
| 715 | <p className="mt-3 text-sm text-muted">An owner can prepay.</p> |
| 716 | )} |
| 717 | </Card> |
| 718 | ); |
| 719 | } |
| 720 | |
| 721 | // --- Caps ----------------------------------------------------------------------- |
| 722 | |
| 723 | export function CapsCard({ entitlements, owner, error }: { entitlements: Entitlements; owner: boolean; error?: string }) { |
| 724 | return ( |
| 725 | <Card |
| 726 | id="caps" |
| 727 | title="Caps on agents" |
| 728 | about={ |
| 729 | <> |
| 730 | 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 |
| 731 | its own. Blank goes back to {wholeDollars(CAPS.run.default)} and {wholeDollars(CAPS.issue.default)}. A cap g1t set for this |
| 732 | workspace wins. |
| 733 | </> |
| 734 | } |
| 735 | > |
| 736 | <dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:max-w-md"> |
| 737 | <div> |
| 738 | <dt className="text-xs text-muted">Per run</dt> |
| 739 | <dd className="mt-0.5 text-lg font-semibold tabular-nums">{wholeDollars(entitlements.runCapMicros)}</dd> |
| 740 | </div> |
| 741 | <div> |
| 742 | <dt className="text-xs text-muted">Per issue</dt> |
| 743 | <dd className="mt-0.5 text-lg font-semibold tabular-nums">{wholeDollars(entitlements.issueCapMicros)}</dd> |
| 744 | </div> |
| 745 | </dl> |
| 746 | {owner && ( |
| 747 | <Form method="post" className="mt-4 flex flex-wrap items-end gap-4"> |
| 748 | <input type="hidden" name="intent" value="caps" /> |
| 749 | <label className="text-sm"> |
| 750 | <span className="mb-1 block text-xs text-muted"> |
| 751 | Per run ({wholeDollars(CAPS.run.min)} to {wholeDollars(CAPS.run.max)}) |
| 752 | </span> |
| 753 | <DollarInput name="run" label="Run cap in dollars" defaultValue={String(entitlements.runCapMicros / 1_000_000)} /> |
| 754 | </label> |
| 755 | <label className="text-sm"> |
| 756 | <span className="mb-1 block text-xs text-muted"> |
| 757 | Per issue ({wholeDollars(CAPS.issue.min)} to {wholeDollars(CAPS.issue.max)}) |
| 758 | </span> |
| 759 | <DollarInput name="issue" label="Issue cap in dollars" defaultValue={String(entitlements.issueCapMicros / 1_000_000)} /> |
| 760 | </label> |
| 761 | <SubmitButton variant="outline" match={{ intent: "caps" }} pending="Saving…"> |
| 762 | Save caps |
| 763 | </SubmitButton> |
| 764 | </Form> |
| 765 | )} |
| 766 | <ErrorText>{error}</ErrorText> |
| 767 | <p className="mt-3 text-xs text-faint"> |
| 768 | {entitlements.maxConcurrentAgents} agents at a time{entitlements.maxRunMinutes ? `, ${entitlements.maxRunMinutes} minutes a run` : ""} |
| 769 | {entitlements.firstMonth ? " in the first month" : ""}. |
| 770 | </p> |
| 771 | </Card> |
| 772 | ); |
| 773 | } |
| 774 | |
| 775 | // --- Overage ----------------------------------------------------------------------- |
| 776 | |
| 777 | export function OverageCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) { |
| 778 | const mine = requests.filter((request) => request.kind === "overage"); |
| 779 | const waiting = mine.some((request) => request.status === "open"); |
| 780 | return ( |
| 781 | <Card |
| 782 | id="overage" |
| 783 | title="Spent more than you meant to? Tell us." |
| 784 | 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." |
| 785 | > |
| 786 | {owner && !waiting && ( |
| 787 | <Form method="post" className="mt-4 space-y-3"> |
| 788 | <input type="hidden" name="intent" value="request" /> |
| 789 | <input type="hidden" name="kind" value="overage" /> |
| 790 | <textarea |
| 791 | name="reason" |
| 792 | rows={2} |
| 793 | maxLength={2000} |
| 794 | aria-label="What happened" |
| 795 | placeholder="An agent kept retrying a failing check on Tuesday night." |
| 796 | className={`${CONTROL} w-full`} |
| 797 | /> |
| 798 | <SubmitButton variant="outline" match={{ intent: "request", kind: "overage" }} pending="Sending…"> |
| 799 | Tell g1t |
| 800 | </SubmitButton> |
| 801 | <ErrorText>{error}</ErrorText> |
| 802 | </Form> |
| 803 | )} |
| 804 | {owner && waiting && <p className="mt-3 text-sm text-muted">g1t has your note and answers within one business day.</p>} |
| 805 | {!owner && <p className="mt-3 text-sm text-muted">An owner can tell g1t.</p>} |
| 806 | <RequestList requests={mine} /> |
| 807 | </Card> |
| 808 | ); |
| 809 | } |