Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1 | /** |
| 2 | * Billing settings' newer sections: the plan at a glance with the upcoming | |
| 3 | * invoice, prepaid AI credit with auto-reload, the budget's alerts, the | |
| 4 | * payment method (kept on Stripe), add-ons, invoice details and the | |
| 5 | * invoices. Cards and payment methods are handled on Stripe's own page: | |
| 6 | * g1t has no card form. Each form posts to the billing route's action with | |
| 7 | * an `intent`. | |
| 8 | */ | |
| 9 | import { ArrowUpRight, Bell, Bot, CreditCard, FileText, Mail, Plus, ReceiptText, Sparkles } from "lucide-react"; | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 10 | import { type ReactNode, useState } from "react"; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 11 | import { Form, Link } from "react-router"; |
| 12 | ||
| 13 | import type { AiCredit, BillingDetails, FeatureState, Limit, UsageReport } from "@g1t/contracts"; | |
| 14 | ||
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 15 | import { PLUS_TAX, type PlanStatus, cardFeeCents, dollars, feeAndTax, wholeDollars } from "../lib/billing"; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 16 | import { money } from "../lib/usage"; |
| 17 | import { Card } from "./billing"; | |
| 18 | import { ErrorText, SubmitButton } from "./ui"; | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 19 | import { SelectField } from "./ui/select"; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 20 | import { Skeleton } from "./ui/skeleton"; |
| 21 | ||
| 22 | const FIELD = | |
| 23 | "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; | |
| 24 | ||
| 25 | function Dollar({ name, defaultValue, label, className }: { name: string; defaultValue?: string; label: string; className?: string }) { | |
| 26 | return ( | |
| 27 | <span className={`flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent-dim ${className ?? ""}`}> | |
| 28 | <span className="text-sm text-muted">$</span> | |
| 29 | <input | |
| 30 | name={name} | |
| 31 | inputMode="decimal" | |
| 32 | aria-label={label} | |
| 33 | defaultValue={defaultValue} | |
| 34 | autoComplete="off" | |
| 35 | data-1p-ignore | |
| 36 | data-lpignore="true" | |
| 37 | className="w-20 bg-transparent px-1 py-1.5 text-sm tabular-nums outline-none" | |
| 38 | /> | |
| 39 | </span> | |
| 40 | ); | |
| 41 | } | |
| 42 | ||
| 43 | function whole(micros: number): string { | |
| 44 | return String(Math.round(micros / 1_000_000)); | |
| 45 | } | |
| 46 | ||
| 47 | // --- The plan --------------------------------------------------------------------------------- | |
| 48 | ||
| 49 | /** | |
| 50 | * The plan at a glance: its name and standing, the billing period, the | |
| 51 | * included usage, and the upcoming invoice, from g1t's own ledger. | |
| 52 | */ | |
| 53 | export function PlanSummary({ | |
| 54 | slug, | |
| 55 | state, | |
| 56 | status, | |
| 57 | details, | |
| 58 | report, | |
| 59 | owner, | |
| 60 | enabled, | |
| 61 | staff, | |
| 62 | error, | |
| 63 | }: { | |
| 64 | slug: string; | |
| 65 | state: FeatureState | null; | |
| 66 | status: PlanStatus; | |
| 67 | details: BillingDetails | null; | |
| 68 | report: UsageReport | null; | |
| 69 | owner: boolean; | |
| 70 | enabled: boolean; | |
| 71 | staff: boolean; | |
| 72 | error?: string; | |
| 73 | }) { | |
| 74 | const plan = state?.plan; | |
| 75 | const subscription = state?.subscription ?? null; | |
| 76 | const on = ["paid", "canceling", "comped", "enterprise", "past_due"].includes(status.kind); | |
| 77 | const now = new Date(); | |
| 78 | const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); | |
| 79 | const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)); | |
| 80 | const period = `${start.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })} – ${end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })}`; | |
| 81 | const upcoming = details?.upcoming; | |
| 82 | return ( | |
| 83 | <Card | |
| 84 | id="plan" | |
| 85 | tone={status.kind === "past_due" || status.kind === "canceling" ? "warn" : on ? "accent" : "plain"} | |
| 86 | icon={<Sparkles size={16} />} | |
| 87 | title={ | |
| 88 | <> | |
| 89 | {plan?.title ?? "g1t"} | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 90 | <span className={`rounded-full px-2 py-0.5 text-xs ${on && status.kind !== "past_due" && status.kind !== "canceling" ? "bg-success/15 text-success" : status.kind === "free" || status.kind === "trial" ? "border border-line text-muted" : "bg-warn/15 text-warn"}`}> |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 91 | {status.label} |
| 92 | {status.kind === "canceling" && subscription?.periodEnd ? `, ${new Date(subscription.periodEnd).toLocaleDateString()}` : ""} | |
| 93 | </span> | |
| 94 | </> | |
| 95 | } | |
| 96 | about={ | |
| 97 | on | |
| 98 | ? `Billing period ${period}. One price for the workspace, never per seat.` | |
| 99 | : "The forge is free: repositories, issues, pull requests, reviews, search and the audit log. Agents, checks, workflows and deployments need the plan, or the trial." | |
| 100 | } | |
| 101 | aside={ | |
| 102 | <p className="shrink-0 sm:text-right"> | |
| 103 | <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span> | |
| 104 | <span className="text-sm text-muted"> / month</span> | |
| 105 | <span className="block text-xs text-faint">with {wholeDollars(report?.included?.of ?? 10_000_000)} of usage included</span> | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 106 | {status.kind !== "comped" && status.kind !== "enterprise" && ( |
| 107 | <span className="block text-xs text-faint"> | |
| 108 | {plan?.cardFeeCents ? `+ ${dollars(plan.cardFeeCents * 10_000)} card processing fee, ` : ""} | |
| 109 | {plan?.cardFeeCents ? PLUS_TAX : "Plus tax where it applies"} | |
| 110 | </span> | |
| 111 | )} | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 112 | </p> |
| 113 | } | |
| 114 | > | |
| 115 | <div className="mt-4 grid gap-4 sm:grid-cols-2"> | |
| 116 | {report?.included ? ( | |
| 117 | <div className="rounded-lg border border-line bg-bg/40 p-3"> | |
| 118 | <p className="flex justify-between text-sm"> | |
| 119 | <span className="text-muted">Included usage</span> | |
| 120 | <span className="tabular-nums"> | |
| 121 | {money(report.included.used)} <span className="text-faint">/ {money(report.included.of)}</span> | |
| 122 | </span> | |
| 123 | </p> | |
| 124 | <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation"> | |
| 125 | <div className="h-full rounded-full bg-accent" style={{ width: `${Math.min(100, (report.included.used / Math.max(1, report.included.of)) * 100)}%` }} /> | |
| 126 | </div> | |
| 127 | <p className="mt-1.5 text-xs text-faint">Used first; starts again on the 1st. Unused usage does not carry over.</p> | |
| 128 | </div> | |
| 129 | ) : ( | |
| 130 | <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm"> | |
| 131 | <p className="text-muted">Usage at price this month</p> | |
| 132 | <p className="mt-1 text-lg font-semibold tabular-nums">{report ? money(report.totals.priceMicros) : "—"}</p> | |
| 133 | {(report?.discountPercent ?? 0) > 0 && report && ( | |
| 134 | <p className="text-xs text-faint"> | |
| 135 | Discount ({report.discountPercent}%) −{money(report.totals.discountMicros)} · charged {money(report.totals.chargedMicros)} | |
| 136 | </p> | |
| 137 | )} | |
| 138 | </div> | |
| 139 | )} | |
| 140 | <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm"> | |
| 141 | <p className="flex justify-between"> | |
| 142 | <span className="text-muted">Upcoming invoice</span> | |
| 143 | <span className="text-lg font-semibold tabular-nums">{upcoming ? money(upcoming.totalMicros) : "—"}</span> | |
| 144 | </p> | |
| 145 | <p className="text-xs text-faint">Including add-ons and usage, from g1t's ledger{upcoming ? `; the month closes ${new Date(upcoming.closesAt).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}` : ""}.</p> | |
| 146 | {upcoming && ( | |
| 147 | <details className="mt-1.5"> | |
| 148 | <summary className="cursor-pointer text-xs text-muted hover:text-fg">View upcoming invoice</summary> | |
| 149 | <dl className="mt-1.5 space-y-1 text-xs"> | |
| 150 | <div className="flex justify-between"> | |
| 151 | <dt className="text-muted">Plan and add-ons</dt> | |
| 152 | <dd className="tabular-nums">{money(upcoming.subscriptionsMicros)}</dd> | |
| 153 | </div> | |
| 154 | <div className="flex justify-between"> | |
| 155 | <dt className="text-muted">Usage owed, after included usage, credit and discount</dt> | |
| 156 | <dd className="tabular-nums">{money(upcoming.usageMicros)}</dd> | |
| 157 | </div> | |
| 158 | </dl> | |
| 159 | </details> | |
| 160 | )} | |
| 161 | </div> | |
| 162 | </div> | |
| 163 | <div className="mt-4 flex flex-wrap items-center gap-2"> | |
| 164 | <Link to={`/${slug}/-/usage`} className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 hover:border-line-strong hover:text-fg"> | |
| 165 | <FileText size={14} /> Usage | |
| 166 | </Link> | |
| 167 | <a href="#invoices" className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 hover:border-line-strong hover:text-fg"> | |
| 168 | <ReceiptText size={14} /> Invoices | |
| 169 | </a> | |
| 170 | {owner && enabled && ( | |
| 171 | <Form method="post" className="contents"> | |
| 172 | <input type="hidden" name="feature" value="plan" /> | |
| 173 | {status.kind === "free" || status.kind === "trial" ? ( | |
| 174 | <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…"> | |
| 175 | <CreditCard size={14} /> | |
| 176 | Start the g1t plan | |
| 177 | </SubmitButton> | |
| 178 | ) : status.kind === "canceling" ? ( | |
| 179 | <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…"> | |
| 180 | Keep the plan | |
| 181 | </SubmitButton> | |
| 182 | ) : status.kind === "paid" && subscription ? ( | |
| 183 | <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…"> | |
| 184 | Downgrade to free at the period's end | |
| 185 | </SubmitButton> | |
| 186 | ) : null} | |
| 187 | </Form> | |
| 188 | )} | |
| 189 | <a href="mailto:sales@g1t.sh?subject=Custom%20needs" className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 190 | <Mail size={14} /> Custom needs? Contact us | |
| 191 | </a> | |
| 192 | </div> | |
| 193 | {staff && enabled && (status.kind === "free" || status.kind === "trial") && ( | |
| 194 | <p className="mt-3 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p> | |
| 195 | )} | |
| 196 | {(status.kind === "free" || status.kind === "trial") && ( | |
| 197 | <p className="mt-3 text-xs text-faint"> | |
| 198 | Starting the plan gives $5 of AI credit, once. Your first month starts at a {wholeDollars(100_000_000)} limit on usage past | |
| 199 | what is included, so a stolen card cannot run up a bill. | |
| 200 | </p> | |
| 201 | )} | |
| 202 | <ErrorText>{error}</ErrorText> | |
| 203 | </Card> | |
| 204 | ); | |
| 205 | } | |
| 206 | ||
| 207 | // --- AI credit ---------------------------------------------------------------------------------- | |
| 208 | ||
| 209 | /** Prepaid AI credit: the balance, buying more, and auto-reload. */ | |
| 210 | export function AiCreditCard({ credit, owner, enabled, staff, error }: { credit: AiCredit; owner: boolean; enabled: boolean; staff: boolean; error?: string }) { | |
| 211 | const rate = credit.agentRateMicros; | |
| 212 | const fee = credit.cardFee; | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 213 | // What is chosen, so the fee shows before Stripe's page does. |
| 214 | const [chosenCents, setChosenCents] = useState(2_500); | |
| 215 | const chosenFee = cardFeeCents(chosenCents, fee); | |
| 216 | const stripeFee = `Stripe's ${(fee.percentMicros / 10_000).toFixed(1)}% + ${dollars(fee.fixedCents * 10_000)}`; | |
| 217 | const feeText = !fee.on | |
| 218 | ? `${feeAndTax(0)}.` | |
| 219 | : chosenCents > 0 | |
| 220 | ? `On $${(chosenCents / 100).toLocaleString("en-US")}: ${feeAndTax(chosenFee).replace(/^C/, "c")} (the fee is ${stripeFee}, its own line at checkout).` | |
| 221 | : `A card processing fee (${stripeFee}) is its own line at checkout, ${PLUS_TAX}.`; | |
| 222 | const choose = (form: HTMLFormElement) => { | |
| 223 | const data = new FormData(form); | |
| 224 | const amount = String(data.get("amount") ?? ""); | |
| 225 | const dollarsChosen = amount === "custom" ? Number(String(data.get("custom") ?? "").replace(/[$,\s]/g, "")) : Number(amount); | |
| 226 | setChosenCents(Number.isFinite(dollarsChosen) && dollarsChosen > 0 ? Math.round(dollarsChosen * 100) : 0); | |
| 227 | }; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 228 | return ( |
| 229 | <Card | |
| 230 | id="ai-credit" | |
| 231 | tone={credit.blocked ? "warn" : "plain"} | |
| 232 | icon={<Bot size={16} />} | |
| 233 | title="AI credit" | |
| 234 | about={ | |
| 235 | credit.freeViaDiscount | |
| 236 | ? "Agent and AI Gateway usage is free for this workspace under its discount: shown at its price, then the discount." | |
| 237 | : credit.postpaid | |
| 238 | ? "This workspace's enterprise is invoiced for Agent and AI Gateway usage after use: no credit needed." | |
| 239 | : `Prepaid credit for Agent and AI Gateway usage, spent before anything else. Models at the provider's price${credit.modelMarkupPercent ? ` plus ${credit.modelMarkupPercent}%` : ""}, plus the agent rate${rate > 0 ? ` (${dollars(rate)} per million tokens)` : ""}. Credit expires ${credit.expiresDays === 365 ? "1 year" : `${credit.expiresDays} days`} after purchase.` | |
| 240 | } | |
| 241 | aside={ | |
| 242 | <p className="shrink-0 sm:text-right"> | |
| 243 | <span className="text-2xl font-semibold tabular-nums tracking-tight">{money(credit.balanceMicros)}</span> | |
| 244 | <span className="block text-xs text-faint"> | |
| 245 | {credit.givenMicros > 0 ? `${money(credit.purchasedMicros)} bought, ${money(credit.givenMicros)} from g1t` : "left"} | |
| 246 | </span> | |
| 247 | </p> | |
| 248 | } | |
| 249 | > | |
| 250 | {credit.blocked && ( | |
| 251 | <p className="mt-3 rounded-lg border border-warn/40 bg-warn/5 px-3 py-2 text-sm"> | |
| 252 | Out of AI credit, with this month's included usage used: new runs on g1t's models wait until you buy credit or turn on | |
| 253 | auto-reload. Runs on your own model provider keep going. | |
| 254 | </p> | |
| 255 | )} | |
| 256 | {!credit.freeViaDiscount && !credit.postpaid && ( | |
| 257 | <> | |
| 258 | {credit.canBuy && owner && enabled ? ( | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 259 | <Form method="post" className="mt-4" onChange={(event) => choose(event.currentTarget)}> |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 260 | <input type="hidden" name="intent" value="buy-ai-credit" /> |
| 261 | <fieldset className="flex flex-wrap items-center gap-2"> | |
| 262 | <legend className="mb-2 text-xs text-muted">Buy AI credit</legend> | |
| 263 | {credit.presetsCents.map((cents) => ( | |
| 264 | <label key={cents} className="cursor-pointer"> | |
| 265 | <input type="radio" name="amount" value={String(cents / 100)} defaultChecked={cents === 2_500} className="peer sr-only" /> | |
| 266 | <span className="inline-block rounded-md border border-line px-3 py-1.5 text-sm tabular-nums peer-checked:border-accent peer-checked:bg-accent/10 peer-focus-visible:ring-2 peer-focus-visible:ring-accent-dim"> | |
| 267 | ${cents / 100} | |
| 268 | </span> | |
| 269 | </label> | |
| 270 | ))} | |
| 271 | <label className="cursor-pointer"> | |
| 272 | <input type="radio" name="amount" value="custom" className="peer sr-only" /> | |
| 273 | <span className="inline-block rounded-md border border-line px-3 py-1.5 text-sm peer-checked:border-accent peer-checked:bg-accent/10">Custom</span> | |
| 274 | </label> | |
| 275 | <Dollar name="custom" label="Custom amount in dollars" /> | |
| 276 | <SubmitButton variant="accent" match={{ intent: "buy-ai-credit" }} pending="Opening Stripe…"> | |
| 277 | <Plus size={14} /> Buy AI credit | |
| 278 | </SubmitButton> | |
| 279 | </fieldset> | |
| 280 | <p className="mt-2 text-xs text-faint"> | |
| 281 | ${credit.minCents / 100} to ${(credit.maxCents / 100).toLocaleString("en-US")}, on Stripe's page. {feeText} | |
| 282 | {staff ? " Test mode: card 4242 4242 4242 4242." : ""} | |
| 283 | </p> | |
| 284 | </Form> | |
| 285 | ) : !credit.canBuy ? ( | |
| 286 | <p className="mt-4 text-sm text-muted">AI credit is for workspaces on the g1t plan. Starting the plan comes with $5 of it.</p> | |
| 287 | ) : ( | |
| 288 | <p className="mt-4 text-sm text-muted">An owner buys AI credit.</p> | |
| 289 | )} | |
| 290 | {credit.canBuy && <AutoReload credit={credit} owner={owner && enabled} />} | |
| 291 | </> | |
| 292 | )} | |
| 293 | {credit.grants.length > 0 && ( | |
| 294 | <details className="mt-4"> | |
| 295 | <summary className="cursor-pointer text-xs text-muted hover:text-fg">Purchases and grants</summary> | |
| 296 | <ul className="mt-2 divide-y divide-line/60 text-sm"> | |
| 297 | {credit.grants.map((grant) => ( | |
| 298 | <li key={grant.id} className="flex flex-wrap justify-between gap-2 py-1.5"> | |
| 299 | <span className="min-w-0"> | |
| 300 | {grant.kind === "purchased" ? "Bought" : grant.source === "upgrade" ? "For starting the plan" : "From g1t"}{" "} | |
| 301 | <span className="text-faint">· {new Date(grant.createdAt).toLocaleDateString()}{grant.expiresAt ? `, expires ${new Date(grant.expiresAt).toLocaleDateString()}` : ""}</span> | |
| 302 | </span> | |
| 303 | <span className="tabular-nums"> | |
| 304 | {money(grant.leftMicros)} <span className="text-faint">of {money(grant.amountMicros)}</span> | |
| 305 | </span> | |
| 306 | </li> | |
| 307 | ))} | |
| 308 | </ul> | |
| 309 | </details> | |
| 310 | )} | |
| 311 | <ErrorText>{error}</ErrorText> | |
| 312 | </Card> | |
| 313 | ); | |
| 314 | } | |
| 315 | ||
| 316 | function AutoReload({ credit, owner }: { credit: AiCredit; owner: boolean }) { | |
| 317 | const r = credit.reload; | |
| 318 | return ( | |
| 319 | <div className="mt-5 rounded-lg border border-line bg-bg/40 p-4"> | |
| 320 | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 321 | <p className="text-sm font-medium">Auto-reload</p> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 322 | <span className={`rounded-full px-2 py-0.5 text-xs ${r.enabled ? "bg-success/15 text-success" : "border border-line text-muted"}`}>{r.enabled ? "On" : "Off"}</span> |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 323 | </div> |
| 324 | {r.failedAt && ( | |
| 325 | <p className="mt-2 text-sm text-danger"> | |
| 326 | Turned off on {new Date(r.failedAt).toLocaleDateString()}: {r.error ?? "the card was declined"}. Update the card on Stripe, then turn it on again. | |
| 327 | </p> | |
| 328 | )} | |
| 329 | {owner ? ( | |
| 330 | <Form method="post" className="mt-3 space-y-3 text-sm"> | |
| 331 | <input type="hidden" name="intent" value="ai-reload" /> | |
| 332 | <label className="flex items-center gap-2"> | |
| You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how. | 333 | <input type="checkbox" name="enabled" defaultChecked={r.enabled} className="accent-[var(--color-accent)]" /> |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 334 | Reload from the saved card |
| 335 | </label> | |
| 336 | <div className="flex flex-wrap items-center gap-2 text-muted"> | |
| 337 | When AI credit falls below <Dollar name="threshold" label="Threshold in dollars" defaultValue={whole(r.thresholdMicros)} /> | |
| 338 | reload it to <Dollar name="target" label="Target in dollars" defaultValue={whole(r.targetMicros)} /> | |
| 339 | </div> | |
| 340 | <div className="flex flex-wrap items-center gap-2 text-muted"> | |
| 341 | At most <Dollar name="monthly" label="Monthly maximum in dollars" defaultValue={whole(r.monthlyMaxMicros)} /> a month | |
| 342 | {(r.reloadedMicros ?? 0) > 0 && <span className="text-faint">· {money(r.reloadedMicros ?? 0)} reloaded this month</span>} | |
| 343 | </div> | |
| 344 | <SubmitButton variant="quiet" match={{ intent: "ai-reload" }} pending="Saving…"> | |
| 345 | Save auto-reload | |
| 346 | </SubmitButton> | |
| 347 | <p className="text-xs text-faint">A reload that cannot be charged turns auto-reload off and tells the owners by email.</p> | |
| 348 | </Form> | |
| 349 | ) : ( | |
| 350 | <p className="mt-2 text-sm text-muted"> | |
| 351 | {r.enabled ? `Below ${money(r.thresholdMicros)}, back to ${money(r.targetMicros)}, at most ${money(r.monthlyMaxMicros)} a month.` : "Off."} | |
| 352 | </p> | |
| 353 | )} | |
| 354 | </div> | |
| 355 | ); | |
| 356 | } | |
| 357 | ||
| 358 | // --- Budget alerts -------------------------------------------------------------------------------- | |
| 359 | ||
| 360 | /** The budget's alerts, whether it pauses usage, and its webhook: beside the spend limit. */ | |
| 361 | export function BudgetAlerts({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) { | |
| 362 | const levels = limit.alertLevels ?? [100, 90, 75, 50]; | |
| 363 | const pause = limit.pauseAtLimit ?? true; | |
| 364 | const spent = limit.spentMicros ?? 0; | |
| 365 | const of = limit.spendLimitMicros; | |
| 366 | return ( | |
| 367 | <Card id="budget" icon={<Bell size={16} />} title="Budget alerts" about="On usage past what the plan includes, measured against the spend limit above. Each alert is emailed to the owners once a month."> | |
| 368 | {of != null && ( | |
| 369 | <div className="mt-3"> | |
| 370 | <div className="flex justify-between text-sm"> | |
| 371 | <span className="text-muted">Spent this month</span> | |
| 372 | <span className="tabular-nums"> | |
| 373 | {money(spent)} <span className="text-faint">of {money(of)}</span> | |
| 374 | </span> | |
| 375 | </div> | |
| 376 | <div className="relative mt-1.5 h-1.5 rounded-full bg-line" role="presentation"> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 377 | <div className={`h-full rounded-full ${spent >= of ? "bg-danger" : spent * 4 >= of * 3 ? "bg-warn" : "bg-success"}`} style={{ width: `${Math.min(100, (spent / Math.max(1, of)) * 100)}%` }} /> |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 378 | {levels.filter((l) => l < 100).map((l) => ( |
| 379 | <span key={l} className="absolute -top-0.5 h-2.5 w-px bg-fg/40" style={{ left: `${l}%` }} /> | |
| 380 | ))} | |
| 381 | </div> | |
| 382 | </div> | |
| 383 | )} | |
| 384 | {owner ? ( | |
| 385 | <Form method="post" className="mt-4 space-y-3 text-sm"> | |
| 386 | <input type="hidden" name="intent" value="budget" /> | |
| 387 | <fieldset className="flex flex-wrap items-center gap-3"> | |
| 388 | <legend className="mb-1.5 text-xs text-muted">Alert at</legend> | |
| 389 | {[50, 75, 90, 100].map((level) => ( | |
| 390 | <label key={level} className="flex items-center gap-1.5"> | |
| You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how. | 391 | <input type="checkbox" name="alert" value={String(level)} defaultChecked={levels.includes(level)} className="accent-[var(--color-accent)]" /> |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 392 | {level}% |
| 393 | </label> | |
| 394 | ))} | |
| 395 | </fieldset> | |
| 396 | <label className="flex items-center gap-2"> | |
| You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how. | 397 | <input type="checkbox" name="pause" defaultChecked={pause} className="accent-[var(--color-accent)]" /> |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 398 | Pause usage at 100% |
| 399 | <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span> | |
| 400 | </label> | |
| 401 | <label className="block"> | |
| 402 | <span className="block text-xs text-muted">Webhook, told of each alert (optional)</span> | |
| 403 | <input name="webhook" type="url" placeholder="https://hooks.example.com/g1t" defaultValue={limit.budgetWebhook ?? ""} className={`mt-1 ${FIELD} max-w-md`} /> | |
| 404 | </label> | |
| 405 | <SubmitButton variant="quiet" match={{ intent: "budget" }} pending="Saving…"> | |
| 406 | Save alerts | |
| 407 | </SubmitButton> | |
| 408 | </Form> | |
| 409 | ) : ( | |
| 410 | <p className="mt-3 text-sm text-muted"> | |
| 411 | Alerts at {levels.map((l) => `${l}%`).join(", ") || "none"}; usage {pause ? "pauses" : "does not pause"} at 100%. | |
| 412 | </p> | |
| 413 | )} | |
| 414 | <ErrorText>{error}</ErrorText> | |
| 415 | </Card> | |
| 416 | ); | |
| 417 | } | |
| 418 | ||
| 419 | // --- Payment method ------------------------------------------------------------------------------- | |
| 420 | ||
| 421 | export function PaymentMethodCard({ details, owner, enabled, staff, error }: { details: BillingDetails | null; owner: boolean; enabled: boolean; staff: boolean; error?: string }) { | |
| 422 | const method = details?.paymentMethod; | |
| 423 | return ( | |
| 424 | <Card | |
| 425 | id="payment" | |
| 426 | icon={<CreditCard size={16} />} | |
| 427 | title="Payment method" | |
| 428 | about="Cards are added, removed and made the default on Stripe's billing page. g1t never sees card numbers." | |
| 429 | aside={ | |
| 430 | owner && enabled ? ( | |
| 431 | <Form method="post"> | |
| 432 | <SubmitButton variant="quiet" name="intent" value="portal" pending="Opening Stripe…"> | |
| 433 | Manage in Stripe | |
| 434 | <ArrowUpRight size={14} /> | |
| 435 | </SubmitButton> | |
| 436 | </Form> | |
| 437 | ) : null | |
| 438 | } | |
| 439 | > | |
| 440 | <div className="mt-3 text-sm"> | |
| 441 | {method ? ( | |
| 442 | <p className="flex flex-wrap items-center gap-2"> | |
| 443 | <span className="rounded border border-line px-1.5 py-0.5 text-xs uppercase tracking-wide text-muted">{method.brand ?? method.kind}</span> | |
| 444 | {method.last4 && ( | |
| 445 | <span> | |
| 446 | ending <span className="font-mono">{method.last4}</span> | |
| 447 | </span> | |
| 448 | )} | |
| 449 | {method.expMonth && method.expYear && ( | |
| 450 | <span className="text-muted"> | |
| 451 | · expires {String(method.expMonth).padStart(2, "0")}/{method.expYear} | |
| 452 | </span> | |
| 453 | )} | |
| 454 | <span className="text-xs text-faint">default</span> | |
| 455 | </p> | |
| 456 | ) : ( | |
| 457 | <p className="text-muted">No payment method yet. Starting the plan or buying AI credit saves the card you pay with.</p> | |
| 458 | )} | |
| 459 | {details?.unavailable && <p className="mt-2 text-xs text-warn">{details.unavailable}</p>} | |
| 460 | {staff && <p className="mt-2 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>} | |
| 461 | </div> | |
| 462 | <ErrorText>{error}</ErrorText> | |
| 463 | </Card> | |
| 464 | ); | |
| 465 | } | |
| 466 | ||
| 467 | // --- Add-ons ------------------------------------------------------------------------------------ | |
| 468 | ||
| 469 | export function AddOns({ plan, security, owner, enabled, error }: { plan: FeatureState | null; security: FeatureState | null; owner: boolean; enabled: boolean; error?: string }) { | |
| 470 | const planOn = !!plan?.on; | |
| 471 | const rows: { key: string; name: string; about: string; price: string; on: boolean; label: string; control: ReactNode }[] = []; | |
| 472 | if (security) { | |
| 473 | const status = security.subscription?.status; | |
| 474 | rows.push({ | |
| 475 | key: "security", | |
| 476 | name: security.plan.title, | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 477 | about: `Custom patterns, validity checks, code scanning and dependency review on private repositories. ${ |
| 478 | security.plan.cardFeeCents ? `Plus a ${dollars(security.plan.cardFeeCents * 10_000)} card processing fee a month, and tax where it applies.` : "Plus tax where it applies." | |
| 479 | }`, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 480 | price: `$${(security.plan.monthlyCents / 100).toFixed(security.plan.monthlyCents % 100 ? 2 : 0)} / month`, |
| 481 | on: security.on, | |
| 482 | label: security.included ? "Included" : status === "canceling" ? "Ends at the period's end" : security.on ? "On" : "Off", | |
| 483 | control: | |
| 484 | !owner || !enabled || security.included ? null : ( | |
| 485 | <Form method="post"> | |
| 486 | <input type="hidden" name="feature" value="security" /> | |
| 487 | {!security.on ? ( | |
| 488 | <SubmitButton variant="quiet" name="intent" value="subscribe" pending="Opening Stripe…"> | |
| 489 | Turn on | |
| 490 | </SubmitButton> | |
| 491 | ) : status === "canceling" ? ( | |
| 492 | <SubmitButton variant="quiet" name="intent" value="resume" pending="Saving…"> | |
| 493 | Keep it on | |
| 494 | </SubmitButton> | |
| 495 | ) : ( | |
| 496 | <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…"> | |
| 497 | Turn off | |
| 498 | </SubmitButton> | |
| 499 | )} | |
| 500 | </Form> | |
| 501 | ), | |
| 502 | }); | |
| 503 | } | |
| 504 | rows.push({ | |
| 505 | key: "deployments", | |
| 506 | name: "Production deployments", | |
| 507 | about: "Previews per pull request and production on g1t.page, metered at cost plus 20%.", | |
| 508 | price: "With the plan", | |
| 509 | on: planOn, | |
| 510 | label: planOn ? "On" : "Needs the plan", | |
| 511 | control: null, | |
| 512 | }); | |
| 513 | return ( | |
| 514 | <section id="add-ons" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface"> | |
| 515 | <h2 className="px-5 pt-5 font-medium">Add-ons</h2> | |
| 516 | <ul className="mt-3 divide-y divide-line border-t border-line"> | |
| 517 | {rows.map((row) => ( | |
| 518 | <li key={row.key} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-3 text-sm"> | |
| 519 | <div className="min-w-0 flex-1 basis-60"> | |
| 520 | <p className="font-medium">{row.name}</p> | |
| 521 | <p className="text-xs text-muted">{row.about}</p> | |
| 522 | </div> | |
| 523 | <span className="tabular-nums text-muted">{row.price}</span> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 524 | <span className={`rounded-full px-2 py-0.5 text-xs ${row.on ? "bg-success/15 text-success" : "border border-line text-muted"}`}>{row.label}</span> |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 525 | {row.control} |
| 526 | </li> | |
| 527 | ))} | |
| 528 | </ul> | |
| 529 | {error && ( | |
| 530 | <div className="px-5 pb-4"> | |
| 531 | <ErrorText>{error}</ErrorText> | |
| 532 | </div> | |
| 533 | )} | |
| 534 | </section> | |
| 535 | ); | |
| 536 | } | |
| 537 | ||
| 538 | // --- Invoice details ------------------------------------------------------------------------------ | |
| 539 | ||
| 540 | const TAX_IDS: [string, string][] = [ | |
| 541 | ["", "None"], | |
| 542 | ["eu_vat", "EU VAT"], | |
| 543 | ["gb_vat", "UK VAT"], | |
| 544 | ["us_ein", "US EIN"], | |
| 545 | ["ca_bn", "Canadian BN"], | |
| 546 | ["au_abn", "Australian ABN"], | |
| 547 | ["ch_vat", "Swiss VAT"], | |
| 548 | ["no_vat", "Norwegian VAT"], | |
| 549 | ["in_gst", "Indian GST"], | |
| 550 | ["jp_cn", "Japanese corporate number"], | |
| 551 | ["br_cnpj", "Brazilian CNPJ"], | |
| 552 | ["mx_rfc", "Mexican RFC"], | |
| 553 | ["sg_uen", "Singapore UEN"], | |
| 554 | ["nz_gst", "New Zealand GST"], | |
| 555 | ["za_vat", "South African VAT"], | |
| 556 | ]; | |
| 557 | ||
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 558 | /** What Stripe's check of a tax ID found. */ |
| 559 | const TAX_ID_STATUS: Record<string, string> = { | |
| 560 | verified: "Verified by Stripe.", | |
| 561 | pending: "Stripe is checking it.", | |
| 562 | unverified: "Stripe could not verify it. Check the number.", | |
| 563 | unavailable: "Stripe cannot check this kind of ID.", | |
| 564 | }; | |
| 565 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 566 | const LANGUAGES: [string, string][] = [ |
| 567 | ["", "Automatic"], | |
| 568 | ["en", "English"], | |
| 569 | ["en-GB", "English (UK)"], | |
| 570 | ["de", "German"], | |
| 571 | ["fr", "French"], | |
| 572 | ["es", "Spanish"], | |
| 573 | ["it", "Italian"], | |
| 574 | ["nl", "Dutch"], | |
| 575 | ["pt-BR", "Portuguese (Brazil)"], | |
| 576 | ["ja", "Japanese"], | |
| 577 | ["zh", "Chinese"], | |
| 578 | ]; | |
| 579 | ||
| 580 | export function InvoiceDetailsCard({ details, owner, enabled, error }: { details: BillingDetails | null; owner: boolean; enabled: boolean; error?: string }) { | |
| 581 | const a = details?.address; | |
| 582 | const disabled = !owner || !enabled; | |
| 583 | return ( | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 584 | <Card |
| 585 | id="details" | |
| 586 | icon={<ReceiptText size={16} />} | |
| 587 | title="Invoice details" | |
| 588 | about="Who invoices are for. Kept on the workspace's Stripe customer and printed on every invoice. Tax is worked out from the address." | |
| 589 | > | |
| 590 | {details?.customer && !details.taxLocation && ( | |
| 591 | <p className={`mt-3 rounded-lg border px-3 py-2 text-sm ${details.taxAddressNeededAt ? "border-warn/40 bg-warn/5" : "border-line bg-bg/40 text-muted"}`}> | |
| 592 | {details.taxAddressNeededAt | |
| 593 | ? "Add the billing address: Stripe needs at least the country (and in the US the ZIP code) to work out tax, so g1t is not charging the card until it is here." | |
| 594 | : "No billing address yet. Stripe needs at least the country (and in the US the ZIP code) to work out tax before g1t charges the card."} | |
| 595 | </p> | |
| 596 | )} | |
| 597 | {(details?.taxExempt === "exempt" || details?.taxExempt === "reverse") && ( | |
| 598 | <p className="mt-3 text-xs text-muted"> | |
| 599 | {details.taxExempt === "exempt" ? "Tax exempt: no tax is added." : "Reverse charge: you account for the tax yourself, and invoices say so."} | |
| 600 | </p> | |
| 601 | )} | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 602 | <Form method="post" className="mt-4 grid gap-3 text-sm sm:grid-cols-2"> |
| 603 | <input type="hidden" name="intent" value="details" /> | |
| 604 | <fieldset disabled={disabled} className="contents"> | |
| 605 | <label className="block"> | |
| 606 | <span className="text-xs text-muted">Invoice email</span> | |
| 607 | <input name="email" type="email" defaultValue={details?.email ?? ""} className={`mt-1 ${FIELD}`} /> | |
| 608 | </label> | |
| 609 | <label className="block"> | |
| 610 | <span className="text-xs text-muted">Company name</span> | |
| 611 | <input name="name" defaultValue={details?.name ?? ""} className={`mt-1 ${FIELD}`} /> | |
| 612 | </label> | |
| 613 | <label className="block sm:col-span-2"> | |
| 614 | <span className="text-xs text-muted">Address</span> | |
| 615 | <input name="line1" placeholder="Street" defaultValue={a?.line1 ?? ""} className={`mt-1 ${FIELD}`} /> | |
| 616 | <input name="line2" placeholder="Suite, floor (optional)" aria-label="Address line 2" defaultValue={a?.line2 ?? ""} className={`mt-2 ${FIELD}`} /> | |
| 617 | </label> | |
| 618 | <div className="grid grid-cols-2 gap-3 sm:col-span-2 sm:grid-cols-4"> | |
| 619 | <input name="city" placeholder="City" aria-label="City" defaultValue={a?.city ?? ""} className={FIELD} /> | |
| 620 | <input name="state" placeholder="State or region" aria-label="State or region" defaultValue={a?.state ?? ""} className={FIELD} /> | |
| 621 | <input name="postalCode" placeholder="Postal code" aria-label="Postal code" defaultValue={a?.postalCode ?? ""} className={FIELD} /> | |
| 622 | <input name="country" placeholder="Country (US)" aria-label="Country, two letters" maxLength={2} defaultValue={a?.country ?? ""} className={`${FIELD} uppercase`} /> | |
| 623 | </div> | |
| 624 | <label className="block"> | |
| 625 | <span className="text-xs text-muted">Tax ID</span> | |
| 626 | <span className="mt-1 flex gap-2"> | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 627 | <SelectField |
| 628 | name="taxIdType" | |
| 629 | defaultValue={details?.taxIdType ?? ""} | |
| 630 | aria-label="Kind of tax ID" | |
| 631 | className="h-auto w-auto shrink-0 px-2.5 py-1.5" | |
| 632 | options={TAX_IDS.map(([value, label]) => ({ value, label }))} | |
| 633 | /> | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 634 | <input name="taxId" defaultValue={details?.taxId ?? ""} aria-label="Tax ID" className={FIELD} /> |
| 635 | </span> | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 636 | {details?.taxId && details.taxIdStatus && ( |
| 637 | <span className="mt-1 block text-xs text-faint">{TAX_ID_STATUS[details.taxIdStatus] ?? `Stripe's check: ${details.taxIdStatus}`}</span> | |
| 638 | )} | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 639 | </label> |
| 640 | <label className="block"> | |
| 641 | <span className="text-xs text-muted">Purchase order</span> | |
| 642 | <input name="poNumber" defaultValue={details?.poNumber ?? ""} className={`mt-1 ${FIELD}`} /> | |
| 643 | </label> | |
| 644 | <label className="block"> | |
| 645 | <span className="text-xs text-muted">Invoice language</span> | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 646 | <SelectField |
| 647 | name="language" | |
| 648 | defaultValue={details?.language ?? ""} | |
| 649 | aria-label="Invoice language" | |
| 650 | className="mt-1 h-auto px-2.5 py-1.5" | |
| 651 | options={LANGUAGES.map(([value, label]) => ({ value, label }))} | |
| 652 | /> | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 653 | </label> |
| 654 | {!disabled && ( | |
| 655 | <div className="flex items-end"> | |
| 656 | <SubmitButton variant="quiet" match={{ intent: "details" }} pending="Saving…"> | |
| 657 | Save invoice details | |
| 658 | </SubmitButton> | |
| 659 | </div> | |
| 660 | )} | |
| 661 | </fieldset> | |
| 662 | </Form> | |
| 663 | {!owner && <p className="mt-3 text-xs text-faint">An owner changes these.</p>} | |
| 664 | <ErrorText>{error}</ErrorText> | |
| 665 | </Card> | |
| 666 | ); | |
| 667 | } | |
| 668 | ||
| 669 | // --- Invoices --------------------------------------------------------------------------------------- | |
| 670 | ||
| 671 | const STATUS: Record<string, string> = { paid: "Paid", open: "Open", void: "Void", uncollectible: "Uncollectible", draft: "Draft" }; | |
| 672 | ||
| 673 | export function InvoicesCard({ details }: { details: BillingDetails | null }) { | |
| 674 | const invoices = details?.invoices ?? []; | |
| 675 | return ( | |
| 676 | <section id="invoices" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface"> | |
| 677 | <div className="px-5 pt-5"> | |
| 678 | <h2 className="font-medium">Invoices</h2> | |
| 679 | <p className="mt-1 text-sm text-muted">The plan, add-ons, AI credit and each month's usage, as Stripe sent them.</p> | |
| 680 | </div> | |
| 681 | {invoices.length === 0 ? ( | |
| 682 | <p className="px-5 py-5 text-sm text-faint">No invoices yet.</p> | |
| 683 | ) : ( | |
| 684 | <ul className="mt-3 divide-y divide-line border-t border-line"> | |
| 685 | {invoices.map((invoice) => ( | |
| 686 | <li key={invoice.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-5 py-2.5 text-sm"> | |
| 687 | <span className="w-24 shrink-0 text-muted tabular-nums">{new Date(invoice.createdAt).toLocaleDateString("en-US", { timeZone: "UTC" })}</span> | |
| 688 | <span className="min-w-0 flex-1 truncate">{invoice.description ?? invoice.number ?? invoice.id}</span> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 689 | <span className={`rounded-full px-2 py-0.5 text-xs ${invoice.status === "paid" ? "bg-success/15 text-success" : invoice.status === "open" ? "bg-warn/15 text-warn" : "border border-line text-muted"}`}> |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 690 | {STATUS[invoice.status] ?? invoice.status} |
| 691 | </span> | |
| 692 | <span className="w-20 text-right tabular-nums">{money(invoice.totalCents * 10_000)}</span> | |
| 693 | <span className="flex gap-3 text-xs"> | |
| 694 | {invoice.hostedUrl && ( | |
| 695 | <a href={invoice.hostedUrl} className="text-muted hover:text-fg"> | |
| 696 | View | |
| 697 | </a> | |
| 698 | )} | |
| 699 | {invoice.pdfUrl && ( | |
| 700 | <a href={invoice.pdfUrl} className="text-muted hover:text-fg"> | |
| 701 | ||
| 702 | </a> | |
| 703 | )} | |
| 704 | </span> | |
| 705 | </li> | |
| 706 | ))} | |
| 707 | </ul> | |
| 708 | )} | |
| 709 | </section> | |
| 710 | ); | |
| 711 | } | |
| 712 | ||
| 713 | /** Stripe's sections while Stripe is asked: the same shapes. */ | |
| 714 | export function StripeSkeleton() { | |
| 715 | return ( | |
| 716 | <div aria-busy="true"> | |
| 717 | <span role="status" className="sr-only"> | |
| 718 | Loading from Stripe… | |
| 719 | </span> | |
| 720 | {[5.5, 14, 8].map((rem, i) => ( | |
| 721 | <div key={i} className="mb-6 rounded-xl border border-line bg-surface p-5" aria-hidden="true" style={{ minHeight: `${rem}rem` }}> | |
| 722 | <Skeleton className="h-4 w-36" /> | |
| 723 | <Skeleton className="mt-3 h-3 w-72 max-w-full" /> | |
| 724 | <Skeleton className="mt-4 h-8 w-full" /> | |
| 725 | </div> | |
| 726 | ))} | |
| 727 | </div> | |
| 728 | ); | |
| 729 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.