{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "usage-based-pricing",
  "title": "Usage Based Pricing",
  "description": "A usage based pricing component",
  "dependencies": ["motion"],
  "registryDependencies": ["card", "utils"],
  "files": [
    {
      "path": "src/registry/billingsdk/usage-based-pricing.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport {\n  motion,\n  useSpring,\n  useTransform,\n  useMotionValueEvent,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\n\nexport type UsageBasedPricingProps = {\n  className?: string;\n  min?: number;\n  max?: number;\n  step?: number;\n  snapTo?: number;\n  value?: number;\n  defaultValue?: number;\n  onChange?: (value: number) => void;\n  onChangeEnd?: (value: number) => void;\n  currency?: string;\n  basePrice?: number;\n  includedCredits?: number;\n  unitPricePerCredit?: number;\n  title?: string;\n  subtitle?: string;\n};\n\nfunction clamp(v: number, min: number, max: number) {\n  return Math.min(Math.max(v, min), max);\n}\nfunction formatNumber(n: number) {\n  return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(\n    n,\n  );\n}\n\nexport function UsageBasedPricing({\n  className,\n  min = 4000,\n  max = 25001,\n  step = 1,\n  snapTo,\n  value: valueProp,\n  defaultValue = 4000,\n  onChange,\n  onChangeEnd,\n  currency = \"$\",\n  basePrice = 39.99,\n  includedCredits = 4000,\n  title = \"Pay as you use pricing\",\n  subtitle = \"Start with a flat monthly rate that includes 4,000 credits.\",\n}: UsageBasedPricingProps) {\n  const isControlled = typeof valueProp === \"number\";\n  const [uncontrolled, setUncontrolled] = useState(\n    clamp(defaultValue, min, max),\n  );\n  const value = isControlled\n    ? clamp(valueProp as number, min, max)\n    : uncontrolled;\n  const trackRef = useRef<HTMLDivElement | null>(null);\n  const [trackWidth, setTrackWidth] = useState(0);\n  const [posPct, setPosPct] = useState(\n    () => ((value - min) / (max - min)) * 100,\n  );\n  const animRef = useRef<number | null>(null);\n  const animStartRef = useRef<number>(0);\n  const animFromPctRef = useRef<number>(0);\n  const animToPctRef = useRef<number>(0);\n  const animDurationMs = 300;\n  const isPointerDownRef = useRef(false);\n  const hasMovedRef = useRef(false);\n  const suppressClickRef = useRef(false);\n\n  // measure track width for ticks\n  useLayoutEffect(() => {\n    const el = trackRef.current;\n    if (!el) return;\n    const update = () => setTrackWidth(el.clientWidth);\n    update();\n    const ro = new ResizeObserver(update);\n    ro.observe(el);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      ro.disconnect();\n      window.removeEventListener(\"resize\", update);\n    };\n  }, []);\n\n  const price = useMemo(() => {\n    // Pricing: basePrice covers includedCredits. For every additional 1,000 credits, add $10.\n    const extra = Math.max(0, value - includedCredits);\n    const thousandsOver = Math.ceil(extra / 1000);\n    const extraCost = thousandsOver * 10;\n    return basePrice + extraCost;\n  }, [value, includedCredits, basePrice]);\n\n  const priceSpring = useSpring(price, {\n    stiffness: 140,\n    damping: 18,\n    mass: 0.6,\n  });\n  useEffect(() => {\n    priceSpring.set(price);\n  }, [price, priceSpring]);\n  const priceRounded = useTransform(priceSpring, (p: number) => Math.max(0, p));\n  const [priceText, setPriceText] = useState(price.toFixed(2));\n  useMotionValueEvent(priceRounded, \"change\", (v) => {\n    setPriceText((v as number).toFixed(2));\n  });\n\n  // keep visual position in sync with external value changes (controlled)\n  useEffect(() => {\n    // avoid interrupting during user interactions\n    if (isPointerDownRef.current) return;\n    if (animRef.current) return;\n    const pctFromValue = ((value - min) / (max - min)) * 100;\n    setPosPct(clamp(pctFromValue, 0, 100));\n  }, [value, min, max]);\n\n  const pct = posPct;\n  const tickCount = useMemo(\n    () => Math.max(80, Math.floor((trackWidth || 1) / 6)),\n    [trackWidth],\n  );\n  const currentTickIndexFloat = useMemo(\n    () => (posPct / 100) * (tickCount - 1),\n    [posPct, tickCount],\n  );\n\n  const commitValue = (v: number, fireEnd = false) => {\n    const clamped = clamp(v, min, max);\n    if (!isControlled) setUncontrolled(clamped);\n    onChange?.(clamped);\n    if (fireEnd) onChangeEnd?.(clamped);\n  };\n\n  const quantize = (raw: number) => {\n    const stepped = Math.round(raw / step) * step;\n    return clamp(stepped, min, max);\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    e.currentTarget.setPointerCapture?.(e.pointerId);\n    isPointerDownRef.current = true;\n    hasMovedRef.current = false;\n    // do not update immediately; wait for move to avoid jump on simple click\n  };\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.buttons !== 1 || !isPointerDownRef.current) return;\n    hasMovedRef.current = true;\n    updateFromEvent(e, false);\n  };\n  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    // Only commit if there was a drag; a simple click will be handled by onClick animation\n    if (hasMovedRef.current) {\n      updateFromEvent(e, true);\n      // skip the trailing synthetic click fired after a drag\n      suppressClickRef.current = true;\n    }\n    e.currentTarget.releasePointerCapture?.(e.pointerId);\n    isPointerDownRef.current = false;\n    hasMovedRef.current = false;\n  };\n  const onPointerCancel = (e: React.PointerEvent<HTMLDivElement>) => {\n    e.currentTarget.releasePointerCapture?.(e.pointerId);\n    isPointerDownRef.current = false;\n    hasMovedRef.current = false;\n  };\n  // easing function for dot click animation\n  const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);\n  // animate to target value (used by clickable dots)\n  const animateTo = (targetValue: number) => {\n    // cancel any existing animation\n    if (animRef.current) cancelAnimationFrame(animRef.current);\n    const tnorm = (targetValue - min) / (max - min);\n    animFromPctRef.current = posPct;\n    animToPctRef.current = clamp(tnorm * 100, 0, 100);\n    animStartRef.current = performance.now();\n    const step = (now: number) => {\n      const elapsed = now - animStartRef.current;\n      const p = Math.min(1, elapsed / animDurationMs);\n      const k = easeOutCubic(p);\n      const currPct =\n        animFromPctRef.current +\n        (animToPctRef.current - animFromPctRef.current) * k;\n      setPosPct(currPct);\n      // live update the value with 100/snapTo increments during animation\n      const liveValue = min + (currPct / 100) * (max - min);\n      const moveSnap = snapTo && snapTo > 0 ? snapTo : 100;\n      let next = Math.round(liveValue / moveSnap) * moveSnap;\n      next = clamp(next, min, max);\n      commitValue(next, false);\n      if (p < 1) {\n        animRef.current = requestAnimationFrame(step);\n      } else {\n        // finalize exactly on target and fire end\n        commitValue(targetValue, true);\n        animRef.current = null;\n      }\n    };\n    animRef.current = requestAnimationFrame(step);\n  };\n  const updateFromEvent = (\n    e: React.PointerEvent<HTMLDivElement>,\n    isEnd: boolean,\n  ) => {\n    if (!trackRef.current) return;\n    const rect = trackRef.current.getBoundingClientRect();\n    const x = clamp(e.clientX - rect.left, 0, rect.width);\n    const t = x / rect.width;\n    const raw = min + t * (max - min);\n    // update transient visual percent immediately\n    setPosPct(t * 100);\n    // During move: update visuals and value in live increments (default 100 or snapTo)\n    if (!isEnd) {\n      const moveSnap = snapTo && snapTo > 0 ? snapTo : 100;\n      let next = Math.round(raw / moveSnap) * moveSnap;\n      next = clamp(next, min, max);\n      commitValue(next, false);\n      return;\n    }\n    // On end: snap to snapTo (if provided) or to step (>1) else to 100\n    let next = raw;\n    if (snapTo && snapTo > 0) {\n      next = Math.round(raw / snapTo) * snapTo;\n    } else if (step && step > 1) {\n      next = quantize(raw);\n    } else {\n      next = Math.round(raw / 100) * 100;\n    }\n    commitValue(next, true);\n  };\n\n  // Positions for labels centered under first and last 1000-multiple dots\n  const firstThousand = useMemo(() => Math.ceil(min / 1000) * 1000, [min]);\n  const lastThousand = useMemo(() => Math.floor(max / 1000) * 1000, [max]);\n  const startLabel = `${formatNumber(firstThousand)} credits`;\n  const endLabel = `${formatNumber(lastThousand)} credits`;\n\n  // Keyboard Accessibility\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    let delta = 0;\n    const baseStep = snapTo && snapTo > 0 ? snapTo : 100;\n    switch (e.key) {\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        delta = -(e.shiftKey ? baseStep * 5 : baseStep);\n        break;\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        delta = e.shiftKey ? baseStep * 5 : baseStep;\n        break;\n      case \"Home\":\n        commitValue(Math.round(min / baseStep) * baseStep, true);\n        e.preventDefault();\n        return;\n      case \"End\":\n        commitValue(Math.round(max / baseStep) * baseStep, true);\n        e.preventDefault();\n        return;\n      default:\n        return;\n    }\n    e.preventDefault();\n    const next = clamp(value + delta, min, max);\n    const snapped = Math.round(next / baseStep) * baseStep;\n    commitValue(snapped, true);\n  };\n\n  return (\n    <Card className={cn(\"mx-auto w-full max-w-3xl min-w-lg\", className)}>\n      <CardHeader className=\"text-left\">\n        <CardTitle>{title}</CardTitle>\n        <CardDescription>{subtitle}</CardDescription>\n      </CardHeader>\n      <CardContent>\n        <div className=\"mt-4 flex flex-col items-center gap-2\">\n          <div className=\"flex items-baseline gap-1\">\n            <span className=\"text-4xl font-bold tabular-nums\">\n              {currency}\n              {priceText}\n            </span>\n            <span className=\"text-muted-foreground text-sm\">/mo</span>\n          </div>\n          <p className=\"text-muted-foreground text-xs\">\n            {formatNumber(value)} credits per month\n          </p>\n        </div>\n\n        <div className=\"space-y-6\">\n          <div className=\"relative mb-6 h-0\">\n            <div className=\"absolute -top-10\" style={{ left: `${pct}%` }}>\n              <div className=\"bg-background -translate-x-1/2 rounded-md border px-3 py-1 text-xs shadow-sm\">\n                {formatNumber(value)}\n              </div>\n            </div>\n          </div>\n\n          <div\n            ref={trackRef}\n            className=\"relative h-12 select-none\"\n            onPointerDown={onPointerDown}\n            onPointerMove={onPointerMove}\n            onPointerUp={onPointerUp}\n            onPointerCancel={onPointerCancel}\n            onClick={(e) => {\n              if (suppressClickRef.current) {\n                suppressClickRef.current = false;\n                return;\n              }\n              if (!trackRef.current) return;\n              const rect = trackRef.current.getBoundingClientRect();\n              const x = clamp(e.clientX - rect.left, 0, rect.width);\n              const t = x / rect.width;\n              const raw = min + t * (max - min);\n              const baseSnap = snapTo && snapTo > 0 ? snapTo : 100;\n              const target = clamp(\n                Math.round(raw / baseSnap) * baseSnap,\n                min,\n                max,\n              );\n              animateTo(target);\n            }}\n          >\n            {/* Animated ruler ticks */}\n            <div className=\"pointer-events-none absolute inset-0\">\n              {Array.from({ length: tickCount }).map((_, i) => {\n                const left = (i / (tickCount - 1)) * 100;\n                const distFloat = Math.abs(currentTickIndexFloat - i);\n                const base = 10;\n                const peak = 12;\n                const spread = 2;\n                const factor = Math.max(0, 1 - distFloat / spread);\n                const height = base + peak * factor;\n                let color = \"bg-muted-foreground/40\";\n                if (distFloat < 0.5) color = \"bg-primary\";\n                else if (distFloat < 1.5) color = \"bg-primary/90\";\n                else if (distFloat < 2.5) color = \"bg-primary/70\";\n                const widthClass =\n                  distFloat < 0.5\n                    ? \"w-[3px]\"\n                    : distFloat < 3.5\n                      ? \"w-[2px]\"\n                      : \"w-px\";\n                return (\n                  <motion.div\n                    key={i}\n                    className={`absolute top-1/2 -translate-y-full ${widthClass} rounded-full ${color}`}\n                    style={{ left: `${left}%` }}\n                    animate={{ height }}\n                    transition={{ type: \"spring\", stiffness: 260, damping: 28 }}\n                  />\n                );\n              })}\n            </div>\n\n            {/* Clickable dots every 1000 credits */}\n            <div className=\"pointer-events-auto absolute inset-0\">\n              {(() => {\n                const first = Math.ceil(min / 1000) * 1000;\n                const dots: ReactNode[] = [];\n                for (let v = first; v <= max; v += 1000) {\n                  const t = (v - min) / (max - min);\n                  const left = `${t * 100}%`;\n                  const isActive = Math.round(value) === v;\n                  dots.push(\n                    <div\n                      key={`dot-${v}`}\n                      role=\"button\"\n                      tabIndex={0}\n                      aria-label={`${v.toLocaleString()} credits`}\n                      onClick={() => {\n                        animateTo(v);\n                      }}\n                      onKeyDown={(e) => {\n                        if (e.key === \"Enter\" || e.key === \" \") {\n                          e.preventDefault();\n                          animateTo(v);\n                        }\n                      }}\n                      className={`absolute rounded-full outline-none ${isActive ? \"bg-primary\" : \"bg-muted-foreground/70\"} focus:ring-primary/50 focus:ring-2`}\n                      style={{\n                        left,\n                        top: \"calc(50% + 14px)\",\n                        transform: \"translateX(-50%)\",\n                        width: \"4px\",\n                        height: \"4px\",\n                        cursor: \"pointer\",\n                      }}\n                    />,\n                  );\n                }\n                return dots;\n              })()}\n            </div>\n\n            <div\n              className=\"absolute top-1/2 -translate-y-1/2\"\n              style={{ left: `${pct}%` }}\n            >\n              <div\n                role=\"slider\"\n                aria-valuemin={min}\n                aria-valuemax={max}\n                aria-valuenow={value}\n                aria-valuetext={`${formatNumber(value)} credits`}\n                tabIndex={0}\n                onKeyDown={handleKeyDown}\n                className=\"sr-only\"\n              />\n            </div>\n          </div>\n\n          <div className=\"text-muted-foreground flex justify-between px-1 text-xs\">\n            <span>{startLabel}</span>\n            <span>{endLabel}</span>\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n\nexport default UsageBasedPricing;\n",
      "type": "registry:component",
      "target": "components/billingsdk/usage-based-pricing.tsx"
    },
    {
      "path": "src/registry/billingsdk/demo/usage-based-pricing-demo.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { UsageBasedPricing } from \"@/components/billingsdk/usage-based-pricing\";\n\nexport function UsageBasedPricingDemo() {\n  const [credits, setCredits] = useState(4000);\n  return (\n    <div className=\"flex flex-1 flex-col items-center justify-center space-y-6 text-center\">\n      <UsageBasedPricing\n        className=\"w-full max-w-5xl\"\n        min={4000}\n        max={25001}\n        snapTo={100}\n        currency=\"$\"\n        basePrice={39.99}\n        includedCredits={4000}\n        value={credits}\n        onChange={setCredits}\n        onChangeEnd={(v) => console.log(\"Committed:\", v)}\n        title=\"Pay-as-you-use pricing\"\n        subtitle=\"Start with a flat monthly rate that includes 4,000 credits.\"\n      />\n      <div className=\"text-muted-foreground text-xs\">\n        Current value: {credits.toLocaleString()} credits\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/usage-based-pricing-demo.tsx"
    }
  ],
  "type": "registry:block"
}
