| 1 | /** |
| 2 | * Billing settings' newer sections: the plan at a glance with the upcoming |
| 3 | * invoice, the budget's alerts, add-ons, and Billing on Stripe. The card, |
| 4 | * billing address, tax IDs, invoices and receipts, and ending the plan, are |
| 5 | * all managed on Stripe's own billing page (the customer portal): g1t has |
| 6 | * no form for any of them. Each form posts to the billing route's action |
| 7 | * with an `intent`. |
| 8 | */ |
| 9 | import { ArrowUpRight, Bell, CreditCard, FileText, Mail, Sparkles } from "lucide-react"; |
| 10 | import type { ReactNode } from "react"; |
| 11 | import { Form, Link } from "react-router"; |
| 12 | |
| 13 | import type { BillingDetails, ComplimentaryMonth, FeatureState, Limit, UsageReport } from "@g1t/contracts"; |
| 14 | |
| 15 | import type { PlanStatus } from "../lib/billing"; |
| 16 | import { money, wholeDollars } from "../lib/money"; |
| 17 | import { Card } from "./billing"; |
| 18 | import { ErrorText, SubmitButton } from "./ui"; |
| 19 | import { Checkbox } from "./ui/checkbox"; |
| 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 | // --- Complimentary ------------------------------------------------------------------------------ |
| 26 | |
| 27 | /** |
| 28 | * A complimentary enrollment's month: the allowance g1t gives, what is used, |
| 29 | * metered and held against it, what is left and when it starts again. Past |
| 30 | * it, new work that costs money stops: nothing is charged, card or not. An |
| 31 | * owner can start the plan on the same billing from here. |
| 32 | */ |
| 33 | export function ComplimentaryCard({ |
| 34 | month, |
| 35 | owner, |
| 36 | enabled, |
| 37 | error, |
| 38 | }: { |
| 39 | month: ComplimentaryMonth; |
| 40 | owner: boolean; |
| 41 | enabled: boolean; |
| 42 | error?: string; |
| 43 | }) { |
| 44 | const taken = month.usedMicros + (month.pendingMicros ?? 0) + (month.heldMicros ?? 0); |
| 45 | const resets = new Date(month.resetsAt).toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone: "UTC" }); |
| 46 | const charged = Boolean(month.chargePastAllowance); |
| 47 | const used = month.unlimited ? 0 : Math.min(100, (taken / Math.max(1, month.allowanceMicros)) * 100); |
| 48 | return ( |
| 49 | <Card |
| 50 | id="complimentary" |
| 51 | tone={!month.unlimited && month.remainingMicros <= 0 ? "warn" : "accent"} |
| 52 | icon={<Sparkles size={16} />} |
| 53 | title="Complimentary allowance" |
| 54 | about={ |
| 55 | charged |
| 56 | ? `Usage at its ordinary price, paid by g1t up to the allowance each calendar month (UTC). Past it, usage is charged to this workspace's card at the same prices, up to your spend limit. Unused allowance does not carry over.` |
| 57 | : `Usage at its ordinary price, paid by g1t up to the allowance each calendar month (UTC). Past it, new work that costs money stops until ${resets}; nothing is ever charged for it, and unused allowance does not carry over.` |
| 58 | } |
| 59 | aside={ |
| 60 | <p className="shrink-0 sm:text-right"> |
| 61 | <span className="text-2xl font-semibold tabular-nums tracking-tight">{month.unlimited ? "Unlimited" : money(month.allowanceMicros)}</span> |
| 62 | {!month.unlimited && <span className="text-sm text-muted"> / month</span>} |
| 63 | <span className="block text-xs text-faint">{month.feeCents ? `Fee ${money(month.feeCents * 10_000)} a month, invoiced` : "No monthly fee"}</span> |
| 64 | </p> |
| 65 | } |
| 66 | > |
| 67 | {!month.unlimited && ( |
| 68 | <div className="mt-4"> |
| 69 | <div className="h-1.5 overflow-hidden rounded-full bg-line" role="presentation"> |
| 70 | <div className={`h-full rounded-full ${month.remainingMicros <= 0 ? "bg-warn" : "bg-accent"}`} style={{ width: `${used}%` }} /> |
| 71 | </div> |
| 72 | </div> |
| 73 | )} |
| 74 | <dl className="mt-3 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4"> |
| 75 | <div> |
| 76 | <dt className="text-xs text-faint">Used</dt> |
| 77 | <dd className="tabular-nums">{money(month.usedMicros)}</dd> |
| 78 | </div> |
| 79 | <div> |
| 80 | <dt className="text-xs text-faint">Metered, not billed yet</dt> |
| 81 | <dd className="tabular-nums">{money((month.pendingMicros ?? 0) + (month.heldMicros ?? 0))}</dd> |
| 82 | </div> |
| 83 | <div> |
| 84 | <dt className="text-xs text-faint">Remaining</dt> |
| 85 | <dd className="tabular-nums">{month.unlimited ? "Unlimited" : money(month.remainingMicros)}</dd> |
| 86 | </div> |
| 87 | <div> |
| 88 | <dt className="text-xs text-faint">Starts again</dt> |
| 89 | <dd>{resets}</dd> |
| 90 | </div> |
| 91 | </dl> |
| 92 | {charged && !month.hasCard && ( |
| 93 | <p className="mt-4 text-sm text-muted"> |
| 94 | No card yet: past the allowance, work stops until one is added.{" "} |
| 95 | {owner && enabled && ( |
| 96 | <Form method="post" className="inline"> |
| 97 | <SubmitButton variant="outline" name="intent" value="card-check" pending="Opening Stripe…"> |
| 98 | <CreditCard size={14} /> |
| 99 | Add a card |
| 100 | </SubmitButton> |
| 101 | </Form> |
| 102 | )} |
| 103 | </p> |
| 104 | )} |
| 105 | {owner && enabled && !month.unlimited && ( |
| 106 | <Form method="post" className="mt-4 flex flex-wrap items-center gap-3"> |
| 107 | <SubmitButton variant="outline" name="intent" value="convert" pending="Starting the plan…"> |
| 108 | <CreditCard size={14} /> |
| 109 | Start the g1t plan instead |
| 110 | </SubmitButton> |
| 111 | <span className="text-xs text-faint"> |
| 112 | $21 a month with usage past what is included charged, on this workspace's billing. Nothing changes until the first payment |
| 113 | goes through. |
| 114 | </span> |
| 115 | </Form> |
| 116 | )} |
| 117 | {error && ( |
| 118 | <div className="mt-2"> |
| 119 | <ErrorText>{error}</ErrorText> |
| 120 | </div> |
| 121 | )} |
| 122 | </Card> |
| 123 | ); |
| 124 | } |
| 125 | |
| 126 | // --- The plan --------------------------------------------------------------------------------- |
| 127 | |
| 128 | /** |
| 129 | * The plan at a glance: its name and standing, the billing period, the |
| 130 | * included usage, and the upcoming invoice, from g1t's own ledger. |
| 131 | */ |
| 132 | export function PlanSummary({ |
| 133 | slug, |
| 134 | state, |
| 135 | status, |
| 136 | details, |
| 137 | report, |
| 138 | owner, |
| 139 | enabled, |
| 140 | live, |
| 141 | staff, |
| 142 | firstMonthMicros, |
| 143 | error, |
| 144 | }: { |
| 145 | slug: string; |
| 146 | state: FeatureState | null; |
| 147 | status: PlanStatus; |
| 148 | details: BillingDetails | null; |
| 149 | report: UsageReport | null; |
| 150 | owner: boolean; |
| 151 | enabled: boolean; |
| 152 | /** False while Stripe is in test mode, where cards are not real. */ |
| 153 | live: boolean; |
| 154 | staff: boolean; |
| 155 | /** The first month's limit on usage not yet paid for, from the price book. */ |
| 156 | firstMonthMicros?: number | null; |
| 157 | error?: string; |
| 158 | }) { |
| 159 | const plan = state?.plan; |
| 160 | const subscription = state?.subscription ?? null; |
| 161 | const on = ["paid", "canceling", "comped", "enterprise", "past_due", "complimentary"].includes(status.kind); |
| 162 | const now = new Date(); |
| 163 | const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); |
| 164 | const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)); |
| 165 | const period = `${start.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })} – ${end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })}`; |
| 166 | const upcoming = details?.upcoming; |
| 167 | return ( |
| 168 | <Card |
| 169 | id="plan" |
| 170 | tone={status.kind === "past_due" || status.kind === "canceling" ? "warn" : on ? "accent" : "plain"} |
| 171 | icon={<Sparkles size={16} />} |
| 172 | title={ |
| 173 | <> |
| 174 | {plan?.title ?? "g1t"} |
| 175 | <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"}`}> |
| 176 | {status.kind === "canceling" && subscription?.periodEnd ? `Ends on ${endsOn(subscription.periodEnd)}` : status.label} |
| 177 | </span> |
| 178 | </> |
| 179 | } |
| 180 | about={ |
| 181 | on |
| 182 | ? `Billing period ${period}. One price for the workspace, never per seat.` |
| 183 | : "The forge is free: repositories, issues, pull requests, reviews, search and the audit log. Checks, workflows and deployments need the plan, or the trial." |
| 184 | } |
| 185 | aside={ |
| 186 | <p className="shrink-0 sm:text-right"> |
| 187 | <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2100) / 100).toFixed(0)}</span> |
| 188 | <span className="text-sm text-muted"> / month</span> |
| 189 | <span className="block text-xs text-faint">with {wholeDollars(report?.included?.of ?? 10_000_000)} of usage included</span> |
| 190 | {status.kind !== "comped" && status.kind !== "enterprise" && status.kind !== "complimentary" && ( |
| 191 | <span className="block text-xs text-faint">Plus tax where it applies</span> |
| 192 | )} |
| 193 | </p> |
| 194 | } |
| 195 | > |
| 196 | <div className="mt-4 grid gap-4 sm:grid-cols-2"> |
| 197 | {report?.included ? ( |
| 198 | <div className="rounded-lg border border-line bg-bg/40 p-3"> |
| 199 | <p className="flex justify-between text-sm"> |
| 200 | <span className="text-muted">Included usage</span> |
| 201 | <span className="tabular-nums"> |
| 202 | {money(report.included.used)} <span className="text-faint">/ {money(report.included.of)}</span> |
| 203 | </span> |
| 204 | </p> |
| 205 | <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation"> |
| 206 | <div className="h-full rounded-full bg-accent" style={{ width: `${Math.min(100, (report.included.used / Math.max(1, report.included.of)) * 100)}%` }} /> |
| 207 | </div> |
| 208 | <p className="mt-1.5 text-xs text-faint">Used first; starts again on the 1st. Unused usage does not carry over.</p> |
| 209 | </div> |
| 210 | ) : ( |
| 211 | <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm"> |
| 212 | <p className="text-muted">Usage at price this month</p> |
| 213 | <p className="mt-1 text-lg font-semibold tabular-nums">{report ? money(report.totals.priceMicros) : "—"}</p> |
| 214 | {(report?.discountPercent ?? 0) > 0 && report && ( |
| 215 | <p className="text-xs text-faint"> |
| 216 | Discount ({report.discountPercent}%) −{money(report.totals.discountMicros)} · charged {money(report.totals.chargedMicros)} |
| 217 | </p> |
| 218 | )} |
| 219 | </div> |
| 220 | )} |
| 221 | <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm"> |
| 222 | <p className="flex justify-between"> |
| 223 | <span className="text-muted">Upcoming invoice</span> |
| 224 | <span className="text-lg font-semibold tabular-nums">{upcoming ? money(upcoming.totalMicros) : "—"}</span> |
| 225 | </p> |
| 226 | <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> |
| 227 | {upcoming && ( |
| 228 | <details className="mt-1.5"> |
| 229 | <summary className="cursor-pointer text-xs text-muted hover:text-fg">View upcoming invoice</summary> |
| 230 | <dl className="mt-1.5 space-y-1 text-xs"> |
| 231 | <div className="flex justify-between"> |
| 232 | <dt className="text-muted">Plan and add-ons</dt> |
| 233 | <dd className="tabular-nums">{money(upcoming.subscriptionsMicros)}</dd> |
| 234 | </div> |
| 235 | <div className="flex justify-between"> |
| 236 | <dt className="text-muted">Usage owed, after included usage, credit and discount</dt> |
| 237 | <dd className="tabular-nums">{money(upcoming.usageMicros)}</dd> |
| 238 | </div> |
| 239 | </dl> |
| 240 | </details> |
| 241 | )} |
| 242 | </div> |
| 243 | </div> |
| 244 | <div className="mt-4 flex flex-wrap items-center gap-2"> |
| 245 | <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"> |
| 246 | <FileText size={14} /> Usage |
| 247 | </Link> |
| 248 | {owner && enabled && (status.kind === "free" || status.kind === "trial") && ( |
| 249 | <Form method="post" className="contents"> |
| 250 | <input type="hidden" name="feature" value="plan" /> |
| 251 | <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…"> |
| 252 | <CreditCard size={14} /> |
| 253 | Start the g1t plan |
| 254 | </SubmitButton> |
| 255 | </Form> |
| 256 | )} |
| 257 | {owner && enabled && subscription && (status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && ( |
| 258 | // Ending the plan, keeping it after all, and the card it renews on |
| 259 | // are all on Stripe's billing page. |
| 260 | <Form method="post" className="contents"> |
| 261 | <input type="hidden" name="from" value="plan" /> |
| 262 | <SubmitButton variant={status.kind === "paid" ? "outline" : "accent"} name="intent" value="portal" pending="Opening Stripe…"> |
| 263 | {status.kind === "past_due" ? <CreditCard size={14} /> : null} |
| 264 | {status.kind === "past_due" ? "Update payment method" : "Manage billing"} |
| 265 | <ArrowUpRight size={14} /> |
| 266 | </SubmitButton> |
| 267 | </Form> |
| 268 | )} |
| 269 | <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"> |
| 270 | <Mail size={14} /> Custom needs? Contact us |
| 271 | </a> |
| 272 | </div> |
| 273 | {owner && enabled && subscription && status.kind === "paid" && ( |
| 274 | <p className="mt-3 text-xs text-faint">To end the plan at the end of the period, use Manage billing. Nothing more is charged for it after that.</p> |
| 275 | )} |
| 276 | {status.kind === "canceling" && ( |
| 277 | <p className="mt-3 text-sm text-muted"> |
| 278 | The plan ends at the end of the period and nothing more is charged for it.{" "} |
| 279 | {owner ? "To keep it, use Manage billing." : "An owner or billing manager can keep it from Manage billing."} |
| 280 | </p> |
| 281 | )} |
| 282 | {status.kind === "past_due" && ( |
| 283 | <p className="mt-3 text-sm text-warn"> |
| 284 | The last renewal payment failed, so the plan's features are stopped until it is paid.{" "} |
| 285 | {owner ? "Update the payment method, and Stripe tries the payment again." : "An owner or billing manager can update the payment method."} |
| 286 | </p> |
| 287 | )} |
| 288 | {staff && enabled && !live && (status.kind === "free" || status.kind === "trial") && ( |
| 289 | <p className="mt-3 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p> |
| 290 | )} |
| 291 | {(status.kind === "free" || status.kind === "trial") && ( |
| 292 | <p className="mt-3 text-xs text-faint"> |
| 293 | In its first month on the plan, a workspace can owe at most {wholeDollars(firstMonthMicros ?? 100_000_000)} of usage |
| 294 | not yet paid for, past what is included, so a stolen card cannot run up a bill. Work waits at whichever comes first: |
| 295 | that limit or your spend limit. After the first month, the limit grows as payments clear. |
| 296 | </p> |
| 297 | )} |
| 298 | <ErrorText>{error}</ErrorText> |
| 299 | </Card> |
| 300 | ); |
| 301 | } |
| 302 | |
| 303 | /** A plan's last day as people read it: "Nov 12, 2026". */ |
| 304 | export function endsOn(periodEnd: string): string { |
| 305 | return new Date(periodEnd).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); |
| 306 | } |
| 307 | |
| 308 | // --- Budget alerts -------------------------------------------------------------------------------- |
| 309 | |
| 310 | /** The budget's alerts, whether it pauses usage, and its webhook: beside the spend limit. */ |
| 311 | export function BudgetAlerts({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) { |
| 312 | const levels = limit.alertLevels ?? [100, 90, 75, 50]; |
| 313 | const pause = limit.pauseAtLimit ?? true; |
| 314 | const spent = limit.spentMicros ?? 0; |
| 315 | const of = limit.spendLimitMicros; |
| 316 | return ( |
| 317 | <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."> |
| 318 | {of != null && ( |
| 319 | <div className="mt-3"> |
| 320 | <div className="flex justify-between text-sm"> |
| 321 | <span className="text-muted">Spent this month</span> |
| 322 | <span className="tabular-nums"> |
| 323 | {money(spent)} <span className="text-faint">of {money(of)}</span> |
| 324 | </span> |
| 325 | </div> |
| 326 | <div className="relative mt-1.5 h-1.5 rounded-full bg-line" role="presentation"> |
| 327 | <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)}%` }} /> |
| 328 | {levels.filter((l) => l < 100).map((l) => ( |
| 329 | <span key={l} className="absolute -top-0.5 h-2.5 w-px bg-fg/40" style={{ left: `${l}%` }} /> |
| 330 | ))} |
| 331 | </div> |
| 332 | </div> |
| 333 | )} |
| 334 | {owner ? ( |
| 335 | <Form method="post" className="mt-4 space-y-3 text-sm"> |
| 336 | <input type="hidden" name="intent" value="budget" /> |
| 337 | <fieldset className="flex flex-wrap items-center gap-3"> |
| 338 | <legend className="mb-1.5 text-xs text-muted">Alert at</legend> |
| 339 | {[50, 75, 90, 100].map((level) => ( |
| 340 | <label key={level} className="flex cursor-pointer items-center gap-1.5 tabular-nums"> |
| 341 | <Checkbox name="alert" value={String(level)} defaultChecked={levels.includes(level)} /> |
| 342 | {level}% |
| 343 | </label> |
| 344 | ))} |
| 345 | </fieldset> |
| 346 | <label className="flex cursor-pointer flex-wrap items-center gap-2"> |
| 347 | <Checkbox name="pause" defaultChecked={pause} /> |
| 348 | Pause usage at 100% |
| 349 | <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span> |
| 350 | </label> |
| 351 | <label className="block"> |
| 352 | <span className="block text-xs text-muted">Webhook, told of each alert (optional)</span> |
| 353 | <input name="webhook" type="url" placeholder="https://hooks.example.com/g1t" defaultValue={limit.budgetWebhook ?? ""} className={`mt-1 ${FIELD} max-w-md`} /> |
| 354 | </label> |
| 355 | <SubmitButton variant="outline" match={{ intent: "budget" }} pending="Saving…"> |
| 356 | Save alerts |
| 357 | </SubmitButton> |
| 358 | </Form> |
| 359 | ) : ( |
| 360 | <p className="mt-3 text-sm text-muted"> |
| 361 | Alerts at {levels.map((l) => `${l}%`).join(", ") || "none"}; usage {pause ? "pauses" : "does not pause"} at 100%. |
| 362 | </p> |
| 363 | )} |
| 364 | <ErrorText>{error}</ErrorText> |
| 365 | </Card> |
| 366 | ); |
| 367 | } |
| 368 | |
| 369 | // --- Billing on Stripe --------------------------------------------------------------------------- |
| 370 | |
| 371 | /** |
| 372 | * Billing on Stripe: the saved card, read-only, and one way to Stripe's |
| 373 | * billing page, where the card, the billing address, tax IDs, invoices and |
| 374 | * receipts are managed. Owners and billing managers only. |
| 375 | */ |
| 376 | export function BillingOnStripeCard({ |
| 377 | details, |
| 378 | enabled, |
| 379 | live, |
| 380 | staff, |
| 381 | error, |
| 382 | }: { |
| 383 | details: BillingDetails | null; |
| 384 | enabled: boolean; |
| 385 | /** False while Stripe is in test mode, where cards are not real. */ |
| 386 | live: boolean; |
| 387 | staff: boolean; |
| 388 | error?: string; |
| 389 | }) { |
| 390 | const method = details?.paymentMethod; |
| 391 | return ( |
| 392 | <Card |
| 393 | id="stripe" |
| 394 | icon={<CreditCard size={16} />} |
| 395 | title="Billing on Stripe" |
| 396 | about="Your card, billing address, tax IDs, invoices and receipts are kept on Stripe's billing page. g1t never sees card numbers." |
| 397 | aside={ |
| 398 | enabled ? ( |
| 399 | <Form method="post"> |
| 400 | <SubmitButton variant="outline" name="intent" value="portal" pending="Opening Stripe…"> |
| 401 | Manage billing |
| 402 | <ArrowUpRight size={14} /> |
| 403 | </SubmitButton> |
| 404 | </Form> |
| 405 | ) : null |
| 406 | } |
| 407 | > |
| 408 | <div className="mt-3 text-sm"> |
| 409 | {method ? ( |
| 410 | <p className="flex flex-wrap items-center gap-2"> |
| 411 | <span className="rounded border border-line px-1.5 py-0.5 text-xs uppercase tracking-wide text-muted">{method.brand ?? method.kind}</span> |
| 412 | {method.last4 && ( |
| 413 | <span> |
| 414 | ending <span className="font-mono">{method.last4}</span> |
| 415 | </span> |
| 416 | )} |
| 417 | {method.expMonth && method.expYear && ( |
| 418 | <span className="text-muted"> |
| 419 | · expires {String(method.expMonth).padStart(2, "0")}/{method.expYear} |
| 420 | </span> |
| 421 | )} |
| 422 | </p> |
| 423 | ) : ( |
| 424 | <p className="text-muted">No card yet. Starting the plan saves the card you pay with.</p> |
| 425 | )} |
| 426 | {details?.unavailable && <p className="mt-2 text-xs text-warn">{details.unavailable}</p>} |
| 427 | {staff && !live && <p className="mt-2 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>} |
| 428 | </div> |
| 429 | <ErrorText>{error}</ErrorText> |
| 430 | </Card> |
| 431 | ); |
| 432 | } |
| 433 | |
| 434 | // --- Add-ons ------------------------------------------------------------------------------------ |
| 435 | |
| 436 | export function AddOns({ plan, security, error }: { plan: FeatureState | null; security: FeatureState | null; error?: string }) { |
| 437 | const planOn = !!plan?.on; |
| 438 | const rows: { key: string; name: string; about: string; price: string; on: boolean; label: string; control: ReactNode }[] = []; |
| 439 | if (security) { |
| 440 | rows.push({ |
| 441 | key: "security", |
| 442 | name: security.plan.title, |
| 443 | 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%.", |
| 444 | price: "With the plan", |
| 445 | on: security.on, |
| 446 | label: security.on ? "On" : "Needs the plan", |
| 447 | control: null, |
| 448 | }); |
| 449 | } |
| 450 | rows.push({ |
| 451 | key: "deployments", |
| 452 | name: "Production deployments", |
| 453 | about: "Previews per pull request and production on g1t.page, metered at cost plus 20%.", |
| 454 | price: "With the plan", |
| 455 | on: planOn, |
| 456 | label: planOn ? "On" : "Needs the plan", |
| 457 | control: null, |
| 458 | }); |
| 459 | return ( |
| 460 | <section id="add-ons" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface"> |
| 461 | <h2 className="px-5 pt-5 font-medium">Add-ons</h2> |
| 462 | <ul className="mt-3 divide-y divide-line border-t border-line"> |
| 463 | {rows.map((row) => ( |
| 464 | <li key={row.key} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-3 text-sm"> |
| 465 | <div className="min-w-0 flex-1 basis-60"> |
| 466 | <p className="font-medium">{row.name}</p> |
| 467 | <p className="text-xs text-muted">{row.about}</p> |
| 468 | </div> |
| 469 | <span className="tabular-nums text-muted">{row.price}</span> |
| 470 | <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> |
| 471 | {row.control} |
| 472 | </li> |
| 473 | ))} |
| 474 | </ul> |
| 475 | {error && ( |
| 476 | <div className="px-5 pb-4"> |
| 477 | <ErrorText>{error}</ErrorText> |
| 478 | </div> |
| 479 | )} |
| 480 | </section> |
| 481 | ); |
| 482 | } |
| 483 | |
| 484 | /** Billing on Stripe while Stripe is asked: the same shape. */ |
| 485 | export function StripeSkeleton() { |
| 486 | return ( |
| 487 | <div aria-busy="true"> |
| 488 | <span role="status" className="sr-only"> |
| 489 | Loading from Stripe… |
| 490 | </span> |
| 491 | {[5.5].map((rem, i) => ( |
| 492 | <div key={i} className="mb-6 rounded-xl border border-line bg-surface p-5" aria-hidden="true" style={{ minHeight: `${rem}rem` }}> |
| 493 | <Skeleton className="h-4 w-36" /> |
| 494 | <Skeleton className="mt-3 h-3 w-72 max-w-full" /> |
| 495 | <Skeleton className="mt-4 h-8 w-full" /> |
| 496 | </div> |
| 497 | ))} |
| 498 | </div> |
| 499 | ); |
| 500 | } |