import { BanIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, CircleMinusIcon, CirclePauseIcon, CircleSlashIcon, CircleXIcon, ClockIcon, LoaderCircleIcon, LockIcon, MailIcon, type LucideIcon, } from "lucide-react"; import type { ReactNode } from "react"; import { ReasonTooltip } from "./disabled-reason"; import { SEMANTIC_TONE_META, StatusDot, type SemanticTone } from "./status-dot"; import { cn } from "../lib/cn"; /* ---------------------------------------------------------------------------- StatusBadge (design brief 7.12) - the one status vocabulary. Always a dot (or an icon) plus a sentence-case label, never color alone. Tones: green is fine, purple needs you, amber is working, red failed, grey is paused or off. Plans, scopes, types and counts are MetaChips, not statuses; "Primary" is a role, so it is a MetaChip too. Three looks, one vocabulary: - "dot" A. Dot and label, no pill. The quietest; for rows. - "outline" B. 22px bordered pill. The Capabilities chip; headers, sheets. - "tinted" C. Soft status fill. Louder; for alerts. -------------------------------------------------------------------------- */ export type StatusBadgeVariant = "dot" | "outline" | "tinted"; interface ProductStatusMeta { label: string; tone: SemanticTone; /** The icon used when `icon="auto"`. Falls back to the tone's icon. */ icon?: LucideIcon | undefined; /** Live states pulse their dot (reduced-motion safe). */ live?: boolean | undefined; } /** * Every status the product shows, with its one label and one tone. Add a * status here rather than inventing a label at the call site. */ export const PRODUCT_STATUSES = { // Green: fine. connected: { label: "Connected", tone: "success" }, active: { label: "Active", tone: "success" }, succeeded: { label: "Succeeded", tone: "success" }, installed: { label: "Installed", tone: "success" }, ready: { label: "Ready", tone: "success" }, // Purple: needs you. needs_you: { label: "Needs you", tone: "attention" }, needs_reconnect: { label: "Needs reconnect", tone: "attention" }, token_expired: { label: "Token expired", tone: "attention" }, locked: { label: "Locked", tone: "attention", icon: LockIcon }, incomplete: { label: "Incomplete", tone: "attention" }, not_finished: { label: "Not finished", tone: "attention" }, expiring: { label: "Expiring", tone: "attention", icon: ClockIcon }, pending_review: { label: "Pending review", tone: "attention" }, near_limit: { label: "Near limit", tone: "attention" }, // Amber: working. running: { label: "Running", tone: "progress", live: true }, syncing: { label: "Syncing", tone: "progress", live: true }, rate_limited: { label: "Rate limited", tone: "progress" }, // Red: failed. failed: { label: "Failed", tone: "danger" }, sync_failed: { label: "Sync failed", tone: "danger" }, expired: { label: "Expired", tone: "danger", icon: ClockIcon }, invite_failed: { label: "Invitation failed", tone: "danger", icon: MailIcon }, out_of_usage: { label: "Out of usage", tone: "danger" }, limit_reached: { label: "Limit reached", tone: "danger" }, // Grey: paused or off. paused: { label: "Paused", tone: "neutral", icon: CirclePauseIcon }, disabled: { label: "Disabled", tone: "neutral" }, queued: { label: "Queued", tone: "neutral", icon: CircleDashedIcon }, invited: { label: "Invited", tone: "neutral", icon: MailIcon }, not_connected: { label: "Not connected", tone: "neutral" }, unavailable: { label: "Unavailable", tone: "neutral", icon: CircleSlashIcon }, offline: { label: "Offline", tone: "neutral", icon: CircleSlashIcon }, suspended: { label: "Suspended", tone: "neutral", icon: BanIcon }, revoked: { label: "Revoked", tone: "neutral", icon: BanIcon }, never_run: { label: "Never run", tone: "neutral", icon: CircleDashedIcon }, off: { label: "Off", tone: "neutral" }, } as const satisfies Record; export type ProductStatus = keyof typeof PRODUCT_STATUSES; export const PRODUCT_STATUS_KEYS = Object.keys(PRODUCT_STATUSES) as ProductStatus[]; const TONE_ICON: Record = { success: CircleCheckIcon, attention: CircleAlertIcon, progress: LoaderCircleIcon, danger: CircleXIcon, neutral: CircleMinusIcon, }; const VARIANT_CLASS: Record = { dot: "gap-1.5 text-xs leading-4 font-medium text-fg-muted", outline: "h-5.5 gap-1.5 rounded-full border border-border bg-surface px-2 text-2xs font-medium text-fg", tinted: "h-5.5 gap-1.5 rounded-full border px-2 text-2xs font-medium", }; /** Resolves the label and tone for a status key or an explicit tone. */ export function resolveStatus( status: ProductStatus | undefined, tone: SemanticTone | undefined, ): { label: string | null; tone: SemanticTone; meta: ProductStatusMeta | null } { const meta: ProductStatusMeta | null = status ? PRODUCT_STATUSES[status] : null; return { label: meta?.label ?? null, tone: tone ?? meta?.tone ?? "neutral", meta }; } export interface StatusBadgeProps { /** A known product status. Gives the label, tone and icon. */ status?: ProductStatus | undefined; /** Overrides the status tone, or sets it for a custom label. */ tone?: SemanticTone | undefined; /** Overrides the label, for example "Invited ยท expires in 5 days". */ children?: ReactNode | undefined; /** A (dot), B (outline, the default) or C (tinted). */ variant?: StatusBadgeVariant | undefined; /** "auto" uses the status icon instead of the dot; pass a node for a custom one. */ icon?: "auto" | ReactNode | undefined; /** Pulse the dot. Defaults to on for live statuses (Running, Syncing). */ pulse?: boolean | undefined; /** * Why this status, and who can fix it. Shown in a touch-safe tooltip and * read by screen readers; the badge becomes focusable. */ reason?: ReactNode | undefined; className?: string | undefined; } export function StatusBadge({ status, tone: toneProp, children, variant = "outline", icon, pulse, reason, className, }: StatusBadgeProps) { const { label, tone, meta } = resolveStatus(status, toneProp); const toneMeta = SEMANTIC_TONE_META[tone]; const text = children ?? label; const live = pulse ?? meta?.live ?? false; let marker: ReactNode; if (icon === "auto") { const Icon = meta?.icon ?? TONE_ICON[tone]; marker = (