Skip to content
536 linesCodeBlameRaw
1/**
2 * Billing settings' newer sections: the plan at a glance with the upcoming
3 * invoice, 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 */
9import { ArrowUpRight, Bell, CreditCard, FileText, Mail, ReceiptText, Sparkles } from "lucide-react";
10import type { ReactNode } from "react";
11import { Form, Link } from "react-router";
12
13import type { BillingDetails, FeatureState, Limit, UsageReport } from "@g1t/contracts";
14
15import { PLUS_TAX, type PlanStatus } from "../lib/billing";
16import { money, wholeDollars } from "../lib/money";
17import { Card } from "./billing";
18import { ErrorText, SubmitButton } from "./ui";
19import { Checkbox } from "./ui/checkbox";
20import { SelectField } from "./ui/select";
21import { Skeleton } from "./ui/skeleton";
22
23const 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// --- The plan ---------------------------------------------------------------------------------
27
28/**
29 * The plan at a glance: its name and standing, the billing period, the
30 * included usage, and the upcoming invoice, from g1t's own ledger.
31 */
32export function PlanSummary({
33 slug,
34 state,
35 status,
36 details,
37 report,
38 owner,
39 enabled,
40 staff,
41 error,
42}: {
43 slug: string;
44 state: FeatureState | null;
45 status: PlanStatus;
46 details: BillingDetails | null;
47 report: UsageReport | null;
48 owner: boolean;
49 enabled: boolean;
50 staff: boolean;
51 error?: string;
52}) {
53 const plan = state?.plan;
54 const subscription = state?.subscription ?? null;
55 const on = ["paid", "canceling", "comped", "enterprise", "past_due"].includes(status.kind);
56 const now = new Date();
57 const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
58 const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
59 const period = `${start.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })} – ${end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })}`;
60 const upcoming = details?.upcoming;
61 return (
62 <Card
63 id="plan"
64 tone={status.kind === "past_due" || status.kind === "canceling" ? "warn" : on ? "accent" : "plain"}
65 icon={<Sparkles size={16} />}
66 title={
67 <>
68 {plan?.title ?? "g1t"}
69 <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"}`}>
70 {status.label}
71 {status.kind === "canceling" && subscription?.periodEnd ? `, ${new Date(subscription.periodEnd).toLocaleDateString()}` : ""}
72 </span>
73 </>
74 }
75 about={
76 on
77 ? `Billing period ${period}. One price for the workspace, never per seat.`
78 : "The forge is free: repositories, issues, pull requests, reviews, search and the audit log. Checks, workflows and deployments need the plan, or the trial."
79 }
80 aside={
81 <p className="shrink-0 sm:text-right">
82 <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span>
83 <span className="text-sm text-muted"> / month</span>
84 <span className="block text-xs text-faint">with {wholeDollars(report?.included?.of ?? 10_000_000)} of usage included</span>
85 {status.kind !== "comped" && status.kind !== "enterprise" && (
86 <span className="block text-xs text-faint">
87 {plan?.cardFeeCents ? `+ ${money(plan.cardFeeCents * 10_000)} card processing fee, ` : ""}
88 {plan?.cardFeeCents ? PLUS_TAX : "Plus tax where it applies"}
89 </span>
90 )}
91 </p>
92 }
93 >
94 <div className="mt-4 grid gap-4 sm:grid-cols-2">
95 {report?.included ? (
96 <div className="rounded-lg border border-line bg-bg/40 p-3">
97 <p className="flex justify-between text-sm">
98 <span className="text-muted">Included usage</span>
99 <span className="tabular-nums">
100 {money(report.included.used)} <span className="text-faint">/ {money(report.included.of)}</span>
101 </span>
102 </p>
103 <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
104 <div className="h-full rounded-full bg-accent" style={{ width: `${Math.min(100, (report.included.used / Math.max(1, report.included.of)) * 100)}%` }} />
105 </div>
106 <p className="mt-1.5 text-xs text-faint">Used first; starts again on the 1st. Unused usage does not carry over.</p>
107 </div>
108 ) : (
109 <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm">
110 <p className="text-muted">Usage at price this month</p>
111 <p className="mt-1 text-lg font-semibold tabular-nums">{report ? money(report.totals.priceMicros) : "—"}</p>
112 {(report?.discountPercent ?? 0) > 0 && report && (
113 <p className="text-xs text-faint">
114 Discount ({report.discountPercent}%) −{money(report.totals.discountMicros)} · charged {money(report.totals.chargedMicros)}
115 </p>
116 )}
117 </div>
118 )}
119 <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm">
120 <p className="flex justify-between">
121 <span className="text-muted">Upcoming invoice</span>
122 <span className="text-lg font-semibold tabular-nums">{upcoming ? money(upcoming.totalMicros) : "—"}</span>
123 </p>
124 <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>
125 {upcoming && (
126 <details className="mt-1.5">
127 <summary className="cursor-pointer text-xs text-muted hover:text-fg">View upcoming invoice</summary>
128 <dl className="mt-1.5 space-y-1 text-xs">
129 <div className="flex justify-between">
130 <dt className="text-muted">Plan and add-ons</dt>
131 <dd className="tabular-nums">{money(upcoming.subscriptionsMicros)}</dd>
132 </div>
133 <div className="flex justify-between">
134 <dt className="text-muted">Usage owed, after included usage, credit and discount</dt>
135 <dd className="tabular-nums">{money(upcoming.usageMicros)}</dd>
136 </div>
137 </dl>
138 </details>
139 )}
140 </div>
141 </div>
142 <div className="mt-4 flex flex-wrap items-center gap-2">
143 <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">
144 <FileText size={14} /> Usage
145 </Link>
146 <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">
147 <ReceiptText size={14} /> Invoices
148 </a>
149 {owner && enabled && (
150 <Form method="post" className="contents">
151 <input type="hidden" name="feature" value="plan" />
152 {status.kind === "free" || status.kind === "trial" ? (
153 <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…">
154 <CreditCard size={14} />
155 Start the g1t plan
156 </SubmitButton>
157 ) : status.kind === "canceling" ? (
158 <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…">
159 Keep the plan
160 </SubmitButton>
161 ) : status.kind === "paid" && subscription ? (
162 <SubmitButton variant="outline" name="intent" value="cancel" pending="Saving…">
163 Downgrade to free at the period's end
164 </SubmitButton>
165 ) : null}
166 </Form>
167 )}
168 <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">
169 <Mail size={14} /> Custom needs? Contact us
170 </a>
171 </div>
172 {staff && enabled && (status.kind === "free" || status.kind === "trial") && (
173 <p className="mt-3 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>
174 )}
175 {(status.kind === "free" || status.kind === "trial") && (
176 <p className="mt-3 text-xs text-faint">
177 Your first month starts at a {wholeDollars(100_000_000)} limit on usage past
178 what is included, so a stolen card cannot run up a bill.
179 </p>
180 )}
181 <ErrorText>{error}</ErrorText>
182 </Card>
183 );
184}
185
186// --- Budget alerts --------------------------------------------------------------------------------
187
188/** The budget's alerts, whether it pauses usage, and its webhook: beside the spend limit. */
189export function BudgetAlerts({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) {
190 const levels = limit.alertLevels ?? [100, 90, 75, 50];
191 const pause = limit.pauseAtLimit ?? true;
192 const spent = limit.spentMicros ?? 0;
193 const of = limit.spendLimitMicros;
194 return (
195 <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.">
196 {of != null && (
197 <div className="mt-3">
198 <div className="flex justify-between text-sm">
199 <span className="text-muted">Spent this month</span>
200 <span className="tabular-nums">
201 {money(spent)} <span className="text-faint">of {money(of)}</span>
202 </span>
203 </div>
204 <div className="relative mt-1.5 h-1.5 rounded-full bg-line" role="presentation">
205 <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)}%` }} />
206 {levels.filter((l) => l < 100).map((l) => (
207 <span key={l} className="absolute -top-0.5 h-2.5 w-px bg-fg/40" style={{ left: `${l}%` }} />
208 ))}
209 </div>
210 </div>
211 )}
212 {owner ? (
213 <Form method="post" className="mt-4 space-y-3 text-sm">
214 <input type="hidden" name="intent" value="budget" />
215 <fieldset className="flex flex-wrap items-center gap-3">
216 <legend className="mb-1.5 text-xs text-muted">Alert at</legend>
217 {[50, 75, 90, 100].map((level) => (
218 <label key={level} className="flex cursor-pointer items-center gap-1.5 tabular-nums">
219 <Checkbox name="alert" value={String(level)} defaultChecked={levels.includes(level)} />
220 {level}%
221 </label>
222 ))}
223 </fieldset>
224 <label className="flex cursor-pointer flex-wrap items-center gap-2">
225 <Checkbox name="pause" defaultChecked={pause} />
226 Pause usage at 100%
227 <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span>
228 </label>
229 <label className="block">
230 <span className="block text-xs text-muted">Webhook, told of each alert (optional)</span>
231 <input name="webhook" type="url" placeholder="https://hooks.example.com/g1t" defaultValue={limit.budgetWebhook ?? ""} className={`mt-1 ${FIELD} max-w-md`} />
232 </label>
233 <SubmitButton variant="outline" match={{ intent: "budget" }} pending="Saving…">
234 Save alerts
235 </SubmitButton>
236 </Form>
237 ) : (
238 <p className="mt-3 text-sm text-muted">
239 Alerts at {levels.map((l) => `${l}%`).join(", ") || "none"}; usage {pause ? "pauses" : "does not pause"} at 100%.
240 </p>
241 )}
242 <ErrorText>{error}</ErrorText>
243 </Card>
244 );
245}
246
247// --- Payment method -------------------------------------------------------------------------------
248
249export function PaymentMethodCard({ details, owner, enabled, staff, error }: { details: BillingDetails | null; owner: boolean; enabled: boolean; staff: boolean; error?: string }) {
250 const method = details?.paymentMethod;
251 return (
252 <Card
253 id="payment"
254 icon={<CreditCard size={16} />}
255 title="Payment method"
256 about="Cards are added, removed and made the default on Stripe's billing page. g1t never sees card numbers."
257 aside={
258 owner && enabled ? (
259 <Form method="post">
260 <SubmitButton variant="outline" name="intent" value="portal" pending="Opening Stripe…">
261 Manage in Stripe
262 <ArrowUpRight size={14} />
263 </SubmitButton>
264 </Form>
265 ) : null
266 }
267 >
268 <div className="mt-3 text-sm">
269 {method ? (
270 <p className="flex flex-wrap items-center gap-2">
271 <span className="rounded border border-line px-1.5 py-0.5 text-xs uppercase tracking-wide text-muted">{method.brand ?? method.kind}</span>
272 {method.last4 && (
273 <span>
274 ending <span className="font-mono">{method.last4}</span>
275 </span>
276 )}
277 {method.expMonth && method.expYear && (
278 <span className="text-muted">
279 · expires {String(method.expMonth).padStart(2, "0")}/{method.expYear}
280 </span>
281 )}
282 <span className="text-xs text-faint">default</span>
283 </p>
284 ) : (
285 <p className="text-muted">No payment method yet. Starting the plan saves the card you pay with.</p>
286 )}
287 {details?.unavailable && <p className="mt-2 text-xs text-warn">{details.unavailable}</p>}
288 {staff && <p className="mt-2 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>}
289 </div>
290 <ErrorText>{error}</ErrorText>
291 </Card>
292 );
293}
294
295// --- Add-ons ------------------------------------------------------------------------------------
296
297export function AddOns({ plan, security, error }: { plan: FeatureState | null; security: FeatureState | null; error?: string }) {
298 const planOn = !!plan?.on;
299 const rows: { key: string; name: string; about: string; price: string; on: boolean; label: string; control: ReactNode }[] = [];
300 if (security) {
301 rows.push({
302 key: "security",
303 name: security.plan.title,
304 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%.",
305 price: "With the plan",
306 on: security.on,
307 label: security.on ? "On" : "Needs the plan",
308 control: null,
309 });
310 }
311 rows.push({
312 key: "deployments",
313 name: "Production deployments",
314 about: "Previews per pull request and production on g1t.page, metered at cost plus 20%.",
315 price: "With the plan",
316 on: planOn,
317 label: planOn ? "On" : "Needs the plan",
318 control: null,
319 });
320 return (
321 <section id="add-ons" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface">
322 <h2 className="px-5 pt-5 font-medium">Add-ons</h2>
323 <ul className="mt-3 divide-y divide-line border-t border-line">
324 {rows.map((row) => (
325 <li key={row.key} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-3 text-sm">
326 <div className="min-w-0 flex-1 basis-60">
327 <p className="font-medium">{row.name}</p>
328 <p className="text-xs text-muted">{row.about}</p>
329 </div>
330 <span className="tabular-nums text-muted">{row.price}</span>
331 <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>
332 {row.control}
333 </li>
334 ))}
335 </ul>
336 {error && (
337 <div className="px-5 pb-4">
338 <ErrorText>{error}</ErrorText>
339 </div>
340 )}
341 </section>
342 );
343}
344
345// --- Invoice details ------------------------------------------------------------------------------
346
347const TAX_IDS: [string, string][] = [
348 ["", "None"],
349 ["eu_vat", "EU VAT"],
350 ["gb_vat", "UK VAT"],
351 ["us_ein", "US EIN"],
352 ["ca_bn", "Canadian BN"],
353 ["au_abn", "Australian ABN"],
354 ["ch_vat", "Swiss VAT"],
355 ["no_vat", "Norwegian VAT"],
356 ["in_gst", "Indian GST"],
357 ["jp_cn", "Japanese corporate number"],
358 ["br_cnpj", "Brazilian CNPJ"],
359 ["mx_rfc", "Mexican RFC"],
360 ["sg_uen", "Singapore UEN"],
361 ["nz_gst", "New Zealand GST"],
362 ["za_vat", "South African VAT"],
363];
364
365/** What Stripe's check of a tax ID found. */
366const TAX_ID_STATUS: Record<string, string> = {
367 verified: "Verified by Stripe.",
368 pending: "Stripe is checking it.",
369 unverified: "Stripe could not verify it. Check the number.",
370 unavailable: "Stripe cannot check this kind of ID.",
371};
372
373const LANGUAGES: [string, string][] = [
374 ["", "Automatic"],
375 ["en", "English"],
376 ["en-GB", "English (UK)"],
377 ["de", "German"],
378 ["fr", "French"],
379 ["es", "Spanish"],
380 ["it", "Italian"],
381 ["nl", "Dutch"],
382 ["pt-BR", "Portuguese (Brazil)"],
383 ["ja", "Japanese"],
384 ["zh", "Chinese"],
385];
386
387export function InvoiceDetailsCard({ details, owner, enabled, error }: { details: BillingDetails | null; owner: boolean; enabled: boolean; error?: string }) {
388 const a = details?.address;
389 const disabled = !owner || !enabled;
390 return (
391 <Card
392 id="details"
393 icon={<ReceiptText size={16} />}
394 title="Invoice details"
395 about="Who invoices are for. Kept on the workspace's Stripe customer and printed on every invoice. Tax is worked out from the address."
396 >
397 {details?.customer && !details.taxLocation && (
398 <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"}`}>
399 {details.taxAddressNeededAt
400 ? "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."
401 : "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."}
402 </p>
403 )}
404 {(details?.taxExempt === "exempt" || details?.taxExempt === "reverse") && (
405 <p className="mt-3 text-xs text-muted">
406 {details.taxExempt === "exempt" ? "Tax exempt: no tax is added." : "Reverse charge: you account for the tax yourself, and invoices say so."}
407 </p>
408 )}
409 <Form method="post" className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
410 <input type="hidden" name="intent" value="details" />
411 <fieldset disabled={disabled} className="contents">
412 <label className="block">
413 <span className="text-xs text-muted">Invoice email</span>
414 <input name="email" type="email" defaultValue={details?.email ?? ""} className={`mt-1 ${FIELD}`} />
415 </label>
416 <label className="block">
417 <span className="text-xs text-muted">Company name</span>
418 <input name="name" defaultValue={details?.name ?? ""} className={`mt-1 ${FIELD}`} />
419 </label>
420 <label className="block sm:col-span-2">
421 <span className="text-xs text-muted">Address</span>
422 <input name="line1" placeholder="Street" defaultValue={a?.line1 ?? ""} className={`mt-1 ${FIELD}`} />
423 <input name="line2" placeholder="Suite, floor (optional)" aria-label="Address line 2" defaultValue={a?.line2 ?? ""} className={`mt-2 ${FIELD}`} />
424 </label>
425 <div className="grid grid-cols-2 gap-3 sm:col-span-2 sm:grid-cols-4">
426 <input name="city" placeholder="City" aria-label="City" defaultValue={a?.city ?? ""} className={FIELD} />
427 <input name="state" placeholder="State or region" aria-label="State or region" defaultValue={a?.state ?? ""} className={FIELD} />
428 <input name="postalCode" placeholder="Postal code" aria-label="Postal code" defaultValue={a?.postalCode ?? ""} className={FIELD} />
429 <input name="country" placeholder="Country (US)" aria-label="Country, two letters" maxLength={2} defaultValue={a?.country ?? ""} className={`${FIELD} uppercase`} />
430 </div>
431 <label className="block">
432 <span className="text-xs text-muted">Tax ID</span>
433 <span className="mt-1 flex gap-2">
434 <SelectField
435 name="taxIdType"
436 defaultValue={details?.taxIdType ?? ""}
437 aria-label="Kind of tax ID"
438 className="h-auto w-auto shrink-0 px-2.5 py-1.5"
439 options={TAX_IDS.map(([value, label]) => ({ value, label }))}
440 />
441 <input name="taxId" defaultValue={details?.taxId ?? ""} aria-label="Tax ID" className={FIELD} />
442 </span>
443 {details?.taxId && details.taxIdStatus && (
444 <span className="mt-1 block text-xs text-faint">{TAX_ID_STATUS[details.taxIdStatus] ?? `Stripe's check: ${details.taxIdStatus}`}</span>
445 )}
446 </label>
447 <label className="block">
448 <span className="text-xs text-muted">Purchase order</span>
449 <input name="poNumber" defaultValue={details?.poNumber ?? ""} className={`mt-1 ${FIELD}`} />
450 </label>
451 <label className="block">
452 <span className="text-xs text-muted">Invoice language</span>
453 <SelectField
454 name="language"
455 defaultValue={details?.language ?? ""}
456 aria-label="Invoice language"
457 className="mt-1 h-auto px-2.5 py-1.5"
458 options={LANGUAGES.map(([value, label]) => ({ value, label }))}
459 />
460 </label>
461 {!disabled && (
462 <div className="flex items-end">
463 <SubmitButton variant="outline" match={{ intent: "details" }} pending="Saving…">
464 Save invoice details
465 </SubmitButton>
466 </div>
467 )}
468 </fieldset>
469 </Form>
470 {!owner && <p className="mt-3 text-xs text-faint">An owner changes these.</p>}
471 <ErrorText>{error}</ErrorText>
472 </Card>
473 );
474}
475
476// --- Invoices ---------------------------------------------------------------------------------------
477
478const STATUS: Record<string, string> = { paid: "Paid", open: "Open", void: "Void", uncollectible: "Uncollectible", draft: "Draft" };
479
480export function InvoicesCard({ details }: { details: BillingDetails | null }) {
481 const invoices = details?.invoices ?? [];
482 return (
483 <section id="invoices" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface">
484 <div className="px-5 pt-5">
485 <h2 className="font-medium">Invoices</h2>
486 <p className="mt-1 text-sm text-muted">The plan, add-ons and each month's usage, as Stripe sent them.</p>
487 </div>
488 {invoices.length === 0 ? (
489 <p className="px-5 py-5 text-sm text-faint">No invoices yet.</p>
490 ) : (
491 <ul className="mt-3 divide-y divide-line border-t border-line">
492 {invoices.map((invoice) => (
493 <li key={invoice.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-5 py-2.5 text-sm">
494 <span className="w-24 shrink-0 text-muted tabular-nums">{new Date(invoice.createdAt).toLocaleDateString("en-US", { timeZone: "UTC" })}</span>
495 <span className="min-w-0 flex-1 truncate">{invoice.description ?? invoice.number ?? invoice.id}</span>
496 <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"}`}>
497 {STATUS[invoice.status] ?? invoice.status}
498 </span>
499 <span className="w-20 text-right tabular-nums">{money(invoice.totalCents * 10_000)}</span>
500 <span className="flex gap-3 text-xs">
501 {invoice.hostedUrl && (
502 <a href={invoice.hostedUrl} className="text-muted hover:text-fg">
503 View
504 </a>
505 )}
506 {invoice.pdfUrl && (
507 <a href={invoice.pdfUrl} className="text-muted hover:text-fg">
508 PDF
509 </a>
510 )}
511 </span>
512 </li>
513 ))}
514 </ul>
515 )}
516 </section>
517 );
518}
519
520/** Stripe's sections while Stripe is asked: the same shapes. */
521export function StripeSkeleton() {
522 return (
523 <div aria-busy="true">
524 <span role="status" className="sr-only">
525 Loading from Stripe…
526 </span>
527 {[5.5, 14, 8].map((rem, i) => (
528 <div key={i} className="mb-6 rounded-xl border border-line bg-surface p-5" aria-hidden="true" style={{ minHeight: `${rem}rem` }}>
529 <Skeleton className="h-4 w-36" />
530 <Skeleton className="mt-3 h-3 w-72 max-w-full" />
531 <Skeleton className="mt-4 h-8 w-full" />
532 </div>
533 ))}
534 </div>
535 );
536}