import { useSyncExternalStore } from "react"; /** The structured part of a 402 Payment Required, as sent by the server (see paymentRequired in errors.ts). */ export type PaymentDetail = { message: string; meter: string; reason: "plan_limit" | "spending_limit" | "paid_feature"; /** Whether the viewer can pay for the blocked account; false for, say, a collaborator on someone's repo. */ manageable: boolean; account: { name: string; kind: string } | null; billingPath: string | null; }; const HTTP_PAYMENT_REQUIRED = 402; /** The payment detail of a thrown server error, or null when it is not an out-of-budget 402. */ export function paymentDetail(error: unknown): PaymentDetail | null { if (!(error instanceof Error)) return null; const { status, detail } = error as Error & { status?: number; detail?: Record | null }; if (status !== HTTP_PAYMENT_REQUIRED || !detail || detail.code !== "payment_required") return null; if (typeof detail.meter !== "string") return null; const reason = detail.reason === "spending_limit" || detail.reason === "paid_feature" ? detail.reason : "plan_limit"; const account = detail.account as { name?: unknown; kind?: unknown } | undefined; return { message: error.message, meter: detail.meter, reason, manageable: detail.manageable === true, account: account && typeof account.name === "string" ? { name: account.name, kind: String(account.kind ?? "user") } : null, billingPath: typeof detail.billingPath === "string" ? detail.billingPath : null, }; } /** For plain fetch routes (uploads): opens the upsell when a JSON 402 comes back. Returns whether it did. */ export function upsellFromResponse(status: number, payload: unknown): boolean { if (status !== HTTP_PAYMENT_REQUIRED || !payload || typeof payload !== "object") return false; const body = payload as Record; const error = Object.assign(new Error(typeof body.error === "string" ? body.error : "Payment required"), { status, detail: body, }); const detail = paymentDetail(error); if (detail) showUpsell(detail); return detail !== null; } let current: PaymentDetail | null = null; const listeners = new Set<() => void>(); function emit(): void { for (const listener of listeners) listener(); } /** Opens the global upsell dialog for this 402 (latest wins). */ export function showUpsell(detail: PaymentDetail): void { current = detail; emit(); } export function dismissUpsell(): void { current = null; emit(); } export function useUpsell(): PaymentDetail | null { return useSyncExternalStore( (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, () => current, () => null, ); }