{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "revenue-ring-card",
  "title": "Revenue Ring Card",
  "description": "A centered revenue metric inside a dense segmented ring, with a legend and full-width action.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "card",
    "button",
    "tooltip"
  ],
  "files": [
    {
      "path": "registry/dashboardcn/blocks/revenue-ring-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRight, WalletCards } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { type NumberFormat } from \"@/registry/dashboardcn/lib/format\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card, CardContent } from \"@/components/ui/card\"\nimport { MetricValue } from \"@/registry/dashboardcn/ui/metric-value\"\nimport { RadialGauge } from \"@/registry/dashboardcn/ui/radial-gauge\"\n\nexport interface RevenueRingLegendItem {\n  label: string\n  color?: string\n}\n\nexport interface RevenueRingCardProps extends React.ComponentProps<typeof Card> {\n  label: string\n  value: number\n  format?: NumberFormat\n  currency?: string\n  progress: number\n  segments?: number\n  color?: string\n  trackColor?: string\n  icon?: React.ReactNode\n  legend?: RevenueRingLegendItem[]\n  action?: { label: string; href?: string; onClick?: () => void }\n}\n\n/** A centered metric inside a dense segmented ring, with a legend and full-width action. */\nfunction RevenueRingCard({\n  label,\n  value,\n  format = \"currency\",\n  currency = \"USD\",\n  progress,\n  segments = 56,\n  color = \"var(--foreground)\",\n  trackColor = \"var(--muted)\",\n  icon,\n  legend = [],\n  action,\n  className,\n  ...props\n}: RevenueRingCardProps) {\n  return (\n    <Card\n      data-slot=\"revenue-ring-card\"\n      className={cn(\"@container/card gap-5 overflow-hidden\", className)}\n      {...props}\n    >\n      <CardContent className=\"flex flex-col items-center gap-5\">\n        <RadialGauge\n          value={progress}\n          sweep={300}\n          startAngle={-150}\n          segments={segments}\n          gap={2.4}\n          thickness={10}\n          size={250}\n          color={color}\n          trackColor={trackColor}\n          animate\n          animationDuration={800}\n          className=\"max-w-full\"\n        >\n          <span className=\"bg-muted/70 mb-3 flex size-11 items-center justify-center rounded-full [&>svg]:size-4\">\n            {icon ?? <WalletCards />}\n          </span>\n          <span className=\"text-muted-foreground text-sm\">{label}</span>\n          <MetricValue\n            value={value}\n            format={format}\n            currency={currency}\n            className=\"mt-0.5 text-xl font-semibold tracking-tight\"\n          />\n        </RadialGauge>\n\n        {legend.length ? (\n          <div className=\"flex flex-wrap items-center justify-center gap-x-5 gap-y-2\">\n            {legend.map((item) => (\n              <span\n                key={item.label}\n                className=\"text-muted-foreground flex items-center gap-2 text-xs\"\n              >\n                <span\n                  className=\"size-2 rounded-full\"\n                  style={{ backgroundColor: item.color ?? \"var(--muted-foreground)\" }}\n                />\n                <span className=\"border-b border-dotted\">{item.label}</span>\n              </span>\n            ))}\n          </div>\n        ) : null}\n\n        {action ? (\n          <Button className=\"w-full\" asChild={Boolean(action.href)} onClick={action.onClick}>\n            {action.href ? (\n              <a href={action.href}>\n                {action.label}\n                <ArrowRight />\n              </a>\n            ) : (\n              <>\n                {action.label}\n                <ArrowRight />\n              </>\n            )}\n          </Button>\n        ) : null}\n      </CardContent>\n    </Card>\n  )\n}\n\nexport { RevenueRingCard }\n",
      "type": "registry:component"
    },
    {
      "path": "registry/dashboardcn/ui/radial-gauge.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface RadialGaugeProps extends React.ComponentProps<\"div\"> {\n  value: number\n  min?: number\n  max?: number\n  /** Diameter in pixels. */\n  size?: number\n  /** Stroke width in pixels. */\n  thickness?: number\n  /** Number of arc segments. 0 draws a continuous arc. */\n  segments?: number\n  /** Gap between segments in degrees. */\n  gap?: number\n  /** Angle where the arc starts, in degrees clockwise from 12 o'clock. */\n  startAngle?: number\n  /** Total sweep of the arc in degrees. 180 is a semicircle, 360 a ring. */\n  sweep?: number\n  /** Any CSS color. Defaults to chart-1. */\n  color?: string\n  trackColor?: string\n  /** Reveal filled segments in sequence when the gauge mounts. */\n  animate?: boolean\n  /** Total reveal time in milliseconds. */\n  animationDuration?: number\n  /** Content rendered in the middle of the gauge. */\n  children?: React.ReactNode\n}\n\n// Round so server and client render identical path strings.\nconst round = (n: number) => Math.round(n * 1000) / 1000\n\nfunction polar(cx: number, cy: number, r: number, angleDeg: number) {\n  const rad = ((angleDeg - 90) * Math.PI) / 180\n  return { x: round(cx + r * Math.cos(rad)), y: round(cy + r * Math.sin(rad)) }\n}\n\nfunction arcPath(cx: number, cy: number, r: number, from: number, to: number) {\n  const start = polar(cx, cy, r, from)\n  const end = polar(cx, cy, r, to)\n  const largeArc = to - from > 180 ? 1 : 0\n  return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`\n}\n\nfunction RadialGauge({\n  value,\n  min = 0,\n  max = 100,\n  size = 160,\n  thickness = 10,\n  segments = 0,\n  gap = 2,\n  startAngle,\n  sweep = 180,\n  color = \"var(--chart-1)\",\n  trackColor = \"var(--muted)\",\n  animate = false,\n  animationDuration = 700,\n  className,\n  children,\n  ...props\n}: RadialGaugeProps) {\n  const fraction = Math.min(1, Math.max(0, (value - min) / (max - min || 1)))\n  const start = startAngle ?? (sweep >= 360 ? 0 : -sweep / 2)\n  const r = size / 2 - thickness / 2\n  const cx = size / 2\n  const cy = size / 2\n  // Trim the box to the arc's vertical extent so a semicircle does not\n  // reserve a full circle of space.\n  const angles = Array.from({ length: 64 }, (_, i) => start + (sweep * i) / 63)\n  const ys = angles.map((a) => polar(cx, cy, r, a).y)\n  const top = Math.max(0, Math.min(...ys) - thickness / 2)\n  const bottom = Math.min(size, Math.max(...ys) + thickness / 2)\n  const height = bottom - top\n\n  const arcs: { from: number; to: number; filled: boolean }[] = []\n  if (segments > 0) {\n    const step = sweep / segments\n    for (let i = 0; i < segments; i++) {\n      const from = start + i * step + gap / 2\n      const to = start + (i + 1) * step - gap / 2\n      const mid = (i + 0.5) / segments\n      arcs.push({ from, to, filled: mid <= fraction })\n    }\n  }\n\n  return (\n    <div\n      data-slot=\"radial-gauge\"\n      role=\"meter\"\n      aria-valuemin={min}\n      aria-valuemax={max}\n      aria-valuenow={value}\n      className={cn(\"relative inline-flex shrink-0\", className)}\n      style={{ width: size, height }}\n      {...props}\n    >\n      <svg\n        width={size}\n        height={height}\n        viewBox={`0 ${top} ${size} ${height}`}\n        className=\"absolute inset-0\"\n        aria-hidden=\"true\"\n      >\n        {segments > 0 ? (\n          arcs.map((arc, index) => {\n            const path = arcPath(cx, cy, r, arc.from, arc.to)\n            const filledCount = Math.max(1, Math.round(fraction * segments))\n            const delay = (index / filledCount) * animationDuration * 0.8\n\n            return (\n              <g key={index}>\n                <path\n                  d={path}\n                  fill=\"none\"\n                  stroke={trackColor}\n                  strokeWidth={thickness}\n                  strokeLinecap=\"butt\"\n                />\n                {arc.filled ? (\n                  <path\n                    d={path}\n                    fill=\"none\"\n                    stroke={color}\n                    strokeWidth={thickness}\n                    strokeLinecap=\"butt\"\n                    className={cn(\n                      animate && \"motion-safe:animate-in motion-safe:fade-in\"\n                    )}\n                    style={\n                      animate\n                        ? {\n                            animationDelay: `${delay}ms`,\n                            animationDuration: `${Math.max(120, animationDuration * 0.25)}ms`,\n                            animationFillMode: \"both\",\n                          }\n                        : undefined\n                    }\n                  />\n                ) : null}\n              </g>\n            )\n          })\n        ) : (\n          <>\n            <path\n              d={arcPath(cx, cy, r, start, start + sweep - (sweep >= 360 ? 0.01 : 0))}\n              fill=\"none\"\n              stroke={trackColor}\n              strokeWidth={thickness}\n              strokeLinecap={sweep >= 360 ? \"butt\" : \"round\"}\n            />\n            {fraction > 0 ? (\n              <path\n                d={arcPath(cx, cy, r, start, start + sweep * fraction)}\n                fill=\"none\"\n                stroke={color}\n                strokeWidth={thickness}\n                strokeLinecap=\"round\"\n                className={cn(\n                  \"transition-[d] duration-500\",\n                  animate && \"motion-safe:animate-in motion-safe:fade-in\"\n                )}\n                style={\n                  animate\n                    ? { animationDuration: `${animationDuration}ms` }\n                    : undefined\n                }\n              />\n            ) : null}\n          </>\n        )}\n      </svg>\n      {children ? (\n        <div\n          className={cn(\n            \"absolute inset-x-0 flex flex-col items-center justify-center text-center\",\n            sweep > 200 ? \"inset-y-0\" : \"bottom-0\"\n          )}\n        >\n          {children}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nexport { RadialGauge }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/ui/metric-value.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber, type NumberFormat } from \"@/registry/dashboardcn/lib/format\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\n/** Values at or above this are abbreviated by default. */\nexport const DEFAULT_COMPACT_FROM = 100_000\n\nexport interface MetricValueProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** Strings render as-is. */\n  value: number | string\n  format?: NumberFormat\n  currency?: string\n  maximumFractionDigits?: number\n  /** Values at or above this are abbreviated (e.g. $158K) with the full value in a tooltip. Set to Infinity to always show the full value. */\n  compactFrom?: number\n  /** Text after the number, e.g. a unit. */\n  suffix?: React.ReactNode\n}\n\n/** A formatted number. Large values are abbreviated and reveal the full value on hover. */\nfunction MetricValue({\n  value,\n  format,\n  currency,\n  maximumFractionDigits,\n  compactFrom = DEFAULT_COMPACT_FROM,\n  suffix,\n  className,\n  ...props\n}: MetricValueProps) {\n  const classes = cn(\"tabular-nums\", className)\n\n  if (typeof value !== \"number\") {\n    return (\n      <span data-slot=\"metric-value\" className={classes} {...props}>\n        {value}\n        {suffix}\n      </span>\n    )\n  }\n\n  const full = formatNumber(value, { format, currency, maximumFractionDigits })\n  const abbreviate = format !== \"percent\" && Math.abs(value) >= compactFrom\n  if (!abbreviate) {\n    return (\n      <span data-slot=\"metric-value\" className={classes} {...props}>\n        {full}\n        {suffix}\n      </span>\n    )\n  }\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <span\n            data-slot=\"metric-value\"\n            data-compact=\"\"\n            className={cn(classes, \"cursor-default\")}\n            {...props}\n          >\n            {formatNumber(value, { format, currency, compact: true })}\n            {suffix}\n          </span>\n        </TooltipTrigger>\n        <TooltipContent className=\"tabular-nums\">\n          {full}\n          {suffix}\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  )\n}\n\nexport { MetricValue }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/lib/format.ts",
      "content": "export type NumberFormat = \"number\" | \"compact\" | \"currency\" | \"percent\"\n\nexport interface FormatNumberOptions {\n  format?: NumberFormat\n  /** ISO 4217 code, used when format is \"currency\". Defaults to USD. */\n  currency?: string\n  locale?: string\n  maximumFractionDigits?: number\n  /** Abbreviate large values, e.g. \"$158K\" instead of \"$158,143\". */\n  compact?: boolean\n}\n\nconst formatterCache = new Map<string, Intl.NumberFormat>()\n\nfunction getFormatter(locale: string, options: Intl.NumberFormatOptions) {\n  const key = locale + JSON.stringify(options)\n  let formatter = formatterCache.get(key)\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, options)\n    formatterCache.set(key, formatter)\n  }\n  return formatter\n}\n\n/**\n * Format a metric value for display.\n *\n * formatNumber(1234567)                          -> \"1,234,567\"\n * formatNumber(1234567, { format: \"compact\" })   -> \"1.2M\"\n * formatNumber(48.2, { format: \"currency\" })     -> \"$48.20\"\n * formatNumber(158143, { format: \"currency\", compact: true }) -> \"$158K\"\n * formatNumber(0.124, { format: \"percent\" })     -> \"12.4%\"\n */\nexport function formatNumber(\n  value: number,\n  {\n    format = \"number\",\n    currency = \"USD\",\n    locale = \"en-US\",\n    maximumFractionDigits,\n    compact = false,\n  }: FormatNumberOptions = {}\n): string {\n  if (!Number.isFinite(value)) return \"—\"\n\n  // Compact values keep three significant digits: $158K, $1.23M, 41.2K.\n  const compactOptions: Intl.NumberFormatOptions = compact\n    ? maximumFractionDigits === undefined\n      ? { notation: \"compact\", maximumSignificantDigits: 3 }\n      : { notation: \"compact\", maximumFractionDigits }\n    : {}\n  switch (format) {\n    case \"compact\":\n      return getFormatter(locale, {\n        notation: \"compact\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    case \"currency\":\n      return getFormatter(locale, {\n        style: \"currency\",\n        currency,\n        maximumFractionDigits: maximumFractionDigits ?? 2,\n        ...compactOptions,\n      }).format(value)\n    case \"percent\":\n      return getFormatter(locale, {\n        style: \"percent\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    default:\n      return getFormatter(locale, {\n        maximumFractionDigits: maximumFractionDigits ?? 0,\n        ...compactOptions,\n      }).format(value)\n  }\n}\n\n/**\n * Format a fractional change as a signed percentage.\n *\n * formatDelta(0.124)  -> \"+12.4%\"\n * formatDelta(-0.03)  -> \"-3.0%\"\n * formatDelta(0)      -> \"0.0%\"\n */\nexport function formatDelta(delta: number, locale = \"en-US\"): string {\n  if (!Number.isFinite(delta)) return \"—\"\n  return getFormatter(locale, {\n    style: \"percent\",\n    signDisplay: \"exceptZero\",\n    maximumFractionDigits: 1,\n    minimumFractionDigits: 1,\n  }).format(delta)\n}\n\n/**\n * Fractional change between two values. Returns 0 when there is no baseline.\n */\nexport function computeDelta(current: number, previous: number): number {\n  if (!previous) return 0\n  return (current - previous) / Math.abs(previous)\n}\n",
      "type": "registry:lib"
    }
  ],
  "categories": [
    "dashboard",
    "charts"
  ],
  "type": "registry:block"
}