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