{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"catalog-fms","type":"registry:block","title":"Catalog + FMS","description":"Storefront + admin UI for a Stripe catalog (laravel-catalog) with feature gating (laravel-fms): PricingTable, FeatureMatrix, FeatureGate, PlanFeaturesEditor. Framework-agnostic, controlled, JSON-driven \u2014 see the Shop-n-Sub starter kit.","package":"react-fancy","dependencies":["@particle-academy\/react-fancy"],"registryDependencies":[],"files":[{"path":"components\/fancy\/catalog-fms\/FeatureGate.tsx","content":"import type { ReactNode } from \"react\";\nimport { Button, Callout, cn } from \"@particle-academy\/react-fancy\";\nimport type { Entitlements } from \".\/types\";\n\nexport type FeatureGateMode = \"replace\" | \"blur\" | \"hide\";\n\nexport interface FeatureGateProps {\n  \/** Feature key to check in `entitlements`. *\/\n  feature: string;\n  \/** The viewer's entitlements (from your FMS check). *\/\n  entitlements: Entitlements;\n  \/** Human label for the locked prompt (defaults to the feature key). *\/\n  featureName?: string;\n  children: ReactNode;\n  \/**\n   * How to render when locked: `replace` (default) swaps in an upgrade prompt,\n   * `blur` overlays the (blurred) children, `hide` renders `fallback` or nothing.\n   *\/\n  mode?: FeatureGateMode;\n  \/** Custom locked UI \u2014 overrides the built-in prompt. Receives the reason. *\/\n  renderLocked?: (reason: LockedReason) => ReactNode;\n  \/** Rendered when `mode=\"hide\"` and locked. *\/\n  fallback?: ReactNode;\n  \/** CTA on the built-in prompt. *\/\n  onUpgrade?: () => void;\n  upgradeHref?: string;\n  upgradeLabel?: string;\n  className?: string;\n}\n\nexport type LockedReason =\n  | { kind: \"no-access\" }\n  | { kind: \"quota\"; used: number; limit: number };\n\n\/** Resolve whether a feature is currently usable (access + quota). *\/\nexport function gateStatus(\n  entitlements: Entitlements,\n  feature: string,\n): { unlocked: boolean; reason: LockedReason | null } {\n  const ent = entitlements.features[feature];\n  if (!ent || !ent.access) return { unlocked: false, reason: { kind: \"no-access\" } };\n  \/\/ Resource quota: if a finite limit + usage are known, enforce it.\n  if (ent.limit != null && ent.used != null && ent.used >= ent.limit) {\n    return { unlocked: false, reason: { kind: \"quota\", used: ent.used, limit: ent.limit } };\n  }\n  return { unlocked: true, reason: null };\n}\n\n\/**\n * Gate UI behind an FMS feature entitlement. When the viewer has access (and\n * any resource quota remains) it renders `children`; otherwise it shows an\n * upgrade prompt (or blurs \/ hides, per `mode`). Pure + controlled: you supply\n * `entitlements` from your own FMS check.\n *\/\nexport function FeatureGate({\n  feature,\n  entitlements,\n  featureName,\n  children,\n  mode = \"replace\",\n  renderLocked,\n  fallback,\n  onUpgrade,\n  upgradeHref,\n  upgradeLabel = \"Upgrade\",\n  className,\n}: FeatureGateProps) {\n  const { unlocked, reason } = gateStatus(entitlements, feature);\n  if (unlocked) return <>{children}<\/>;\n\n  if (mode === \"hide\") return <>{fallback ?? null}<\/>;\n\n  const name = featureName ?? feature;\n  const locked = renderLocked?.(reason!) ?? (\n    <Callout\n      color={reason?.kind === \"quota\" ? \"amber\" : \"blue\"}\n      className={cn(\"flex flex-col items-start gap-2\", className)}\n    >\n      <div className=\"font-medium\">\n        {reason?.kind === \"quota\"\n          ? `You've reached your ${name} limit (${reason.used}\/${reason.limit}).`\n          : `${name} is not included in your plan.`}\n      <\/div>\n      <p className=\"text-sm opacity-80\">\n        {reason?.kind === \"quota\"\n          ? \"Upgrade for a higher limit.\"\n          : `Upgrade to unlock ${name}.`}\n      <\/p>\n      {(onUpgrade || upgradeHref) &&\n        (upgradeHref ? (\n          <a href={upgradeHref}>\n            <Button color=\"blue\" size=\"sm\">\n              {upgradeLabel}\n            <\/Button>\n          <\/a>\n        ) : (\n          <Button color=\"blue\" size=\"sm\" onClick={onUpgrade}>\n            {upgradeLabel}\n          <\/Button>\n        ))}\n    <\/Callout>\n  );\n\n  if (mode === \"blur\") {\n    return (\n      <div data-fancy-feature-gate=\"\" data-locked=\"\" className={cn(\"relative\", className)}>\n        <div aria-hidden className=\"pointer-events-none select-none blur-sm\">\n          {children}\n        <\/div>\n        <div className=\"absolute inset-0 flex items-center justify-center bg-white\/40 p-4 backdrop-blur-[1px] dark:bg-zinc-950\/40\">\n          {locked}\n        <\/div>\n      <\/div>\n    );\n  }\n\n  return (\n    <div data-fancy-feature-gate=\"\" data-locked=\"\">\n      {locked}\n    <\/div>\n  );\n}\n\nFeatureGate.displayName = \"FeatureGate\";\n","type":"registry:ui","target":"components\/fancy\/catalog-fms\/FeatureGate.tsx"},{"path":"components\/fancy\/catalog-fms\/FeatureMatrix.tsx","content":"import { Fragment } from \"react\";\nimport { cn } from \"@particle-academy\/react-fancy\";\nimport type { FeatureDef, Plan, PlanFeatureValue } from \".\/types\";\n\nexport interface FeatureMatrixProps {\n  plans: Plan[];\n  features: FeatureDef[];\n  \/** Order of feature `group`s; ungrouped features render first, un-headed. *\/\n  groupOrder?: string[];\n  \/** Sticky the first column + header (default true). *\/\n  sticky?: boolean;\n  className?: string;\n}\n\n\/**\n * A plan \u00d7 feature comparison table. Rows are features (optionally grouped),\n * columns are plans; each cell renders the plan's `features[key]` value \u2014\n * a check\/dash for boolean features, a limit (or \"Unlimited\") for resource\n * features. Framework-agnostic: feed it normalized `Plan[]` + `FeatureDef[]`.\n *\/\nexport function FeatureMatrix({\n  plans,\n  features,\n  groupOrder,\n  sticky = true,\n  className,\n}: FeatureMatrixProps) {\n  \/\/ Bucket features by group, preserving order.\n  const groups: { group: string | null; items: FeatureDef[] }[] = [];\n  const index = new Map<string | null, FeatureDef[]>();\n  for (const f of features) {\n    const g = f.group ?? null;\n    if (!index.has(g)) {\n      index.set(g, []);\n      groups.push({ group: g, items: index.get(g)! });\n    }\n    index.get(g)!.push(f);\n  }\n  if (groupOrder) {\n    groups.sort(\n      (a, b) =>\n        (a.group ? groupOrder.indexOf(a.group) : -1) -\n        (b.group ? groupOrder.indexOf(b.group) : -1),\n    );\n  }\n\n  return (\n    <div\n      data-fancy-feature-matrix=\"\"\n      className={cn(\"overflow-x-auto rounded-xl border border-zinc-200 dark:border-zinc-700\", className)}\n    >\n      <table className=\"w-full border-collapse text-sm\">\n        <thead>\n          <tr className=\"border-b border-zinc-200 dark:border-zinc-700\">\n            <th\n              className={cn(\n                \"p-3 text-left font-medium text-zinc-500\",\n                sticky && \"sticky left-0 bg-white dark:bg-zinc-900\",\n              )}\n            >\n              Features\n            <\/th>\n            {plans.map((plan) => (\n              <th\n                key={plan.id}\n                className={cn(\n                  \"p-3 text-center font-semibold text-zinc-900 dark:text-zinc-100\",\n                  plan.recommended && \"text-blue-600 dark:text-blue-400\",\n                )}\n              >\n                {plan.name}\n              <\/th>\n            ))}\n          <\/tr>\n        <\/thead>\n        <tbody>\n          {groups.map(({ group, items }) => (\n            <Fragment key={group ?? \"_\"}>\n              {group && (\n                <tr className=\"bg-zinc-50 dark:bg-zinc-800\/50\">\n                  <td\n                    colSpan={plans.length + 1}\n                    className=\"px-3 py-2 text-xs font-semibold uppercase tracking-wide text-zinc-400\"\n                  >\n                    {group}\n                  <\/td>\n                <\/tr>\n              )}\n              {items.map((feature) => (\n                <tr\n                  key={feature.key}\n                  className=\"border-b border-zinc-100 last:border-0 dark:border-zinc-800\"\n                >\n                  <td\n                    className={cn(\n                      \"p-3 text-left text-zinc-700 dark:text-zinc-300\",\n                      sticky && \"sticky left-0 bg-white dark:bg-zinc-900\",\n                    )}\n                  >\n                    <span className=\"font-medium\">{feature.name}<\/span>\n                    {feature.description && (\n                      <span className=\"block text-xs text-zinc-400\">{feature.description}<\/span>\n                    )}\n                  <\/td>\n                  {plans.map((plan) => (\n                    <td key={plan.id} className=\"p-3 text-center align-middle\">\n                      <Cell value={plan.features?.[feature.key]} unit={feature.unit} \/>\n                    <\/td>\n                  ))}\n                <\/tr>\n              ))}\n            <\/Fragment>\n          ))}\n        <\/tbody>\n      <\/table>\n    <\/div>\n  );\n}\n\nfunction Cell({ value, unit }: { value?: PlanFeatureValue; unit?: string }) {\n  if (!value || value.enabled === false) {\n    return <DashIcon className=\"mx-auto h-4 w-4 text-zinc-300 dark:text-zinc-600\" \/>;\n  }\n  if (value.type === \"boolean\") {\n    return <CheckIcon className=\"mx-auto h-5 w-5 text-green-500\" \/>;\n  }\n  \/\/ resource\n  if (value.limit === null) {\n    return <span className=\"font-medium text-zinc-700 dark:text-zinc-200\">Unlimited<\/span>;\n  }\n  return (\n    <span className=\"font-medium tabular-nums text-zinc-700 dark:text-zinc-200\">\n      {value.limit.toLocaleString()}\n      {unit ? ` ${unit}` : \"\"}\n    <\/span>\n  );\n}\n\nfunction CheckIcon({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 20 20\" fill=\"currentColor\" className={className} aria-label=\"Included\">\n      <path\n        fillRule=\"evenodd\"\n        d=\"M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0L3.3 10.7a1 1 0 1 1 1.4-1.4l3.3 3.29 6.8-6.8a1 1 0 0 1 1.4 0Z\"\n        clipRule=\"evenodd\"\n      \/>\n    <\/svg>\n  );\n}\n\nfunction DashIcon({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 20 20\" fill=\"currentColor\" className={className} aria-label=\"Not included\">\n      <path d=\"M5 9.25h10a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z\" \/>\n    <\/svg>\n  );\n}\n\nFeatureMatrix.displayName = \"FeatureMatrix\";\n","type":"registry:ui","target":"components\/fancy\/catalog-fms\/FeatureMatrix.tsx"},{"path":"components\/fancy\/catalog-fms\/PlanFeaturesEditor.tsx","content":"import { Badge, Input, Switch, cn } from \"@particle-academy\/react-fancy\";\nimport type { FeatureDef, PlanFeatureValue } from \".\/types\";\n\nexport interface PlanFeaturesEditorProps {\n  \/** All features that can be attached (e.g. from your FMS registry). *\/\n  features: FeatureDef[];\n  \/** Controlled map of feature key \u2192 value. Pair with `onChange`. *\/\n  value: Record<string, PlanFeatureValue>;\n  onChange: (next: Record<string, PlanFeatureValue>) => void;\n  className?: string;\n}\n\n\/**\n * Admin editor for a plan's feature entitlements: toggle each feature on\/off and,\n * for resource features, set a per-plan limit (blank = unlimited). Controlled \u2014\n * `value` is a `{ key: PlanFeatureValue }` map you persist (e.g. to the\n * laravel-catalog `product_feature_configs` pivot: `enabled` + `included_quantity`).\n *\/\nexport function PlanFeaturesEditor({\n  features,\n  value,\n  onChange,\n  className,\n}: PlanFeaturesEditorProps) {\n  const setFeature = (key: string, next: PlanFeatureValue | undefined) => {\n    const copy = { ...value };\n    if (next === undefined) delete copy[key];\n    else copy[key] = next;\n    onChange(copy);\n  };\n\n  return (\n    <div\n      data-fancy-plan-features-editor=\"\"\n      className={cn(\"divide-y divide-zinc-100 rounded-xl border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-700\", className)}\n    >\n      {features.map((feature) => {\n        const current = value[feature.key];\n        const enabled = current?.enabled ?? false;\n        const isResource = feature.type === \"resource\";\n        const limit = current && current.type === \"resource\" ? current.limit : null;\n\n        const toggle = (on: boolean) => {\n          if (!on) {\n            setFeature(feature.key, undefined);\n            return;\n          }\n          setFeature(\n            feature.key,\n            isResource\n              ? { type: \"resource\", enabled: true, limit }\n              : { type: \"boolean\", enabled: true },\n          );\n        };\n\n        const setLimit = (raw: string) => {\n          const trimmed = raw.trim();\n          const next = trimmed === \"\" ? null : Math.max(0, Math.floor(Number(trimmed) || 0));\n          setFeature(feature.key, { type: \"resource\", enabled: true, limit: next });\n        };\n\n        return (\n          <div key={feature.key} className=\"flex items-center gap-4 p-3\">\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"flex items-center gap-2\">\n                <span className=\"font-medium text-zinc-800 dark:text-zinc-100\">\n                  {feature.name}\n                <\/span>\n                <Badge size=\"sm\" variant=\"soft\" color={isResource ? \"violet\" : \"zinc\"}>\n                  {feature.type}\n                <\/Badge>\n              <\/div>\n              {feature.description && (\n                <p className=\"truncate text-xs text-zinc-400\">{feature.description}<\/p>\n              )}\n            <\/div>\n\n            {isResource && enabled && (\n              <div className=\"w-40 shrink-0\">\n                <Input\n                  type=\"number\"\n                  size=\"sm\"\n                  value={limit === null ? \"\" : String(limit)}\n                  placeholder=\"Unlimited\"\n                  onValueChange={setLimit}\n                  trailing={feature.unit ?? undefined}\n                  aria-label={`${feature.name} limit`}\n                \/>\n              <\/div>\n            )}\n\n            <Switch\n              checked={enabled}\n              onCheckedChange={toggle}\n              aria-label={`Toggle ${feature.name}`}\n            \/>\n          <\/div>\n        );\n      })}\n\n      {features.length === 0 && (\n        <div className=\"p-4 text-center text-sm text-zinc-400\">\n          No features registered.\n        <\/div>\n      )}\n    <\/div>\n  );\n}\n\nPlanFeaturesEditor.displayName = \"PlanFeaturesEditor\";\n","type":"registry:ui","target":"components\/fancy\/catalog-fms\/PlanFeaturesEditor.tsx"},{"path":"components\/fancy\/catalog-fms\/PricingTable.tsx","content":"import { useState, type ReactNode } from \"react\";\nimport { Badge, Button, cn } from \"@particle-academy\/react-fancy\";\nimport type { BillingInterval, Plan, PlanPrice } from \".\/types\";\nimport {\n  formatMoney,\n  intervalsFromPlans,\n  intervalSuffix,\n  priceForInterval,\n} from \".\/format\";\n\nexport interface PricingTableProps {\n  plans: Plan[];\n  \/** Controlled selected billing interval. Pair with `onIntervalChange`. *\/\n  interval?: BillingInterval;\n  \/** Initial interval when uncontrolled. *\/\n  defaultInterval?: BillingInterval;\n  onIntervalChange?: (interval: BillingInterval) => void;\n  \/** Limit \/ order the toggle options. Defaults to the intervals the plans offer. *\/\n  intervals?: BillingInterval[];\n  \/** Hide the interval toggle (e.g. single-interval catalogs). *\/\n  hideIntervalToggle?: boolean;\n  \/** Fired when a plan's CTA is clicked. *\/\n  onSelectPlan?: (planId: string, price: PlanPrice | undefined) => void;\n  \/** Map an interval to a toggle label (e.g. add a \"save 20%\" hint on year). *\/\n  intervalLabel?: (interval: BillingInterval) => ReactNode;\n  className?: string;\n}\n\nconst DEFAULT_INTERVAL_LABEL: Record<BillingInterval, string> = {\n  day: \"Daily\",\n  week: \"Weekly\",\n  month: \"Monthly\",\n  year: \"Yearly\",\n  one_time: \"One-time\",\n};\n\n\/**\n * A storefront pricing table: a card per plan with a controlled billing-interval\n * toggle. Framework-agnostic \u2014 feed it normalized `Plan[]` (see .\/types) and\n * handle `onSelectPlan` (start checkout, redirect, etc.).\n *\/\nexport function PricingTable({\n  plans,\n  interval,\n  defaultInterval,\n  onIntervalChange,\n  intervals,\n  hideIntervalToggle,\n  onSelectPlan,\n  intervalLabel,\n  className,\n}: PricingTableProps) {\n  const options = intervals ?? intervalsFromPlans(plans);\n  const fallback = defaultInterval ?? options[0] ?? \"month\";\n  const [internal, setInternal] = useState<BillingInterval>(fallback);\n  const active = interval ?? internal;\n\n  const setInterval = (next: BillingInterval) => {\n    if (interval === undefined) setInternal(next);\n    onIntervalChange?.(next);\n  };\n\n  const showToggle = !hideIntervalToggle && options.length > 1;\n\n  return (\n    <div data-fancy-pricing-table=\"\" className={cn(\"flex flex-col gap-6\", className)}>\n      {showToggle && (\n        <div className=\"flex justify-center\">\n          <div\n            role=\"tablist\"\n            aria-label=\"Billing interval\"\n            className=\"inline-flex rounded-lg border border-zinc-200 bg-zinc-100 p-1 dark:border-zinc-700 dark:bg-zinc-800\"\n          >\n            {options.map((opt) => (\n              <button\n                key={opt}\n                role=\"tab\"\n                aria-selected={opt === active}\n                onClick={() => setInterval(opt)}\n                className={cn(\n                  \"rounded-md px-4 py-1.5 text-sm font-medium transition-colors\",\n                  opt === active\n                    ? \"bg-white text-zinc-900 shadow-sm dark:bg-zinc-950 dark:text-zinc-100\"\n                    : \"text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200\",\n                )}\n              >\n                {intervalLabel?.(opt) ?? DEFAULT_INTERVAL_LABEL[opt]}\n              <\/button>\n            ))}\n          <\/div>\n        <\/div>\n      )}\n\n      <div\n        className=\"grid gap-6\"\n        style={{ gridTemplateColumns: `repeat(${Math.min(plans.length, 4)}, minmax(0, 1fr))` }}\n      >\n        {plans.map((plan) => {\n          const price = priceForInterval(plan.prices, active);\n          const cta =\n            plan.ctaLabel ?? (plan.current ? \"Current plan\" : `Choose ${plan.name}`);\n          return (\n            <div\n              key={plan.id}\n              data-fancy-plan-card=\"\"\n              data-recommended={plan.recommended ? \"\" : undefined}\n              className={cn(\n                \"relative flex flex-col rounded-2xl border bg-white p-6 dark:bg-zinc-900\",\n                plan.recommended\n                  ? \"border-blue-500 ring-1 ring-blue-500 shadow-lg dark:border-blue-400 dark:ring-blue-400\"\n                  : \"border-zinc-200 dark:border-zinc-700\",\n              )}\n            >\n              {plan.recommended && (\n                <div className=\"absolute -top-3 left-1\/2 -translate-x-1\/2\">\n                  <Badge color=\"blue\" variant=\"solid\">\n                    {plan.badge ?? \"Most popular\"}\n                  <\/Badge>\n                <\/div>\n              )}\n\n              <h3 className=\"text-lg font-semibold text-zinc-900 dark:text-zinc-100\">\n                {plan.name}\n              <\/h3>\n              {plan.description && (\n                <p className=\"mt-1 text-sm text-zinc-500\">{plan.description}<\/p>\n              )}\n\n              <div className=\"mt-4 flex items-baseline gap-1\">\n                {price ? (\n                  <>\n                    <span className=\"text-3xl font-bold tabular-nums text-zinc-900 dark:text-zinc-100\">\n                      {formatMoney(price.amount, price.currency)}\n                    <\/span>\n                    <span className=\"text-sm text-zinc-500\">\n                      {intervalSuffix(price.interval, price.intervalCount)}\n                    <\/span>\n                  <\/>\n                ) : (\n                  <span className=\"text-sm text-zinc-400\">Not available {DEFAULT_INTERVAL_LABEL[active].toLowerCase()}<\/span>\n                )}\n              <\/div>\n\n              {plan.highlights && plan.highlights.length > 0 && (\n                <ul className=\"mt-5 flex flex-1 flex-col gap-2 text-sm text-zinc-600 dark:text-zinc-300\">\n                  {plan.highlights.map((h, i) => (\n                    <li key={i} className=\"flex items-start gap-2\">\n                      <CheckIcon className=\"mt-0.5 h-4 w-4 shrink-0 text-green-500\" \/>\n                      <span>{h}<\/span>\n                    <\/li>\n                  ))}\n                <\/ul>\n              )}\n\n              <div className={cn(\"pt-6\", !plan.highlights?.length && \"mt-auto\")}>\n                <Button\n                  color={plan.recommended ? \"blue\" : \"zinc\"}\n                  variant={plan.current ? \"ghost\" : \"default\"}\n                  disabled={plan.disabled || plan.current || !price}\n                  onClick={() => onSelectPlan?.(plan.id, price)}\n                  className=\"w-full justify-center\"\n                >\n                  {cta}\n                <\/Button>\n              <\/div>\n            <\/div>\n          );\n        })}\n      <\/div>\n    <\/div>\n  );\n}\n\nfunction CheckIcon({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 20 20\" fill=\"currentColor\" className={className} aria-hidden>\n      <path\n        fillRule=\"evenodd\"\n        d=\"M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0L3.3 10.7a1 1 0 1 1 1.4-1.4l3.3 3.29 6.8-6.8a1 1 0 0 1 1.4 0Z\"\n        clipRule=\"evenodd\"\n      \/>\n    <\/svg>\n  );\n}\n\nPricingTable.displayName = \"PricingTable\";\n","type":"registry:ui","target":"components\/fancy\/catalog-fms\/PricingTable.tsx"},{"path":"components\/fancy\/catalog-fms\/format.ts","content":"import type { BillingInterval, PlanPrice } from \".\/types\";\n\n\/** Currencies with no minor unit (amounts are whole, not \/100). *\/\nconst ZERO_DECIMAL = new Set([\n  \"bif\", \"clp\", \"djf\", \"gnf\", \"jpy\", \"kmf\", \"krw\", \"mga\", \"pyg\",\n  \"rwf\", \"ugx\", \"vnd\", \"vuv\", \"xaf\", \"xof\", \"xpf\",\n]);\n\n\/** Format a minor-unit amount as a currency string, e.g. 2999 USD \u2192 \"$29.99\". *\/\nexport function formatMoney(amount: number, currency: string): string {\n  const cur = currency.toLowerCase();\n  const value = ZERO_DECIMAL.has(cur) ? amount : amount \/ 100;\n  try {\n    return new Intl.NumberFormat(undefined, {\n      style: \"currency\",\n      currency: currency.toUpperCase(),\n      minimumFractionDigits: ZERO_DECIMAL.has(cur) ? 0 : undefined,\n    }).format(value);\n  } catch {\n    \/\/ Unknown currency code \u2014 fall back to a plain number + code.\n    return `${value} ${currency.toUpperCase()}`;\n  }\n}\n\nconst SHORT: Record<BillingInterval, string> = {\n  day: \"day\",\n  week: \"wk\",\n  month: \"mo\",\n  year: \"yr\",\n  one_time: \"\",\n};\n\n\/** Suffix for a price, e.g. \"\/mo\", \"\/3 mo\", or \"\" for one-time. *\/\nexport function intervalSuffix(interval: BillingInterval, count = 1): string {\n  if (interval === \"one_time\") return \"\";\n  const unit = SHORT[interval];\n  return count > 1 ? `\/${count} ${unit}` : `\/${unit}`;\n}\n\n\/** Pick a plan's price for a given interval (or its first\/only price). *\/\nexport function priceForInterval(\n  prices: PlanPrice[],\n  interval: BillingInterval,\n): PlanPrice | undefined {\n  return prices.find((p) => p.interval === interval) ?? prices[0];\n}\n\n\/** The distinct, sorted set of billing intervals across a list of plans. *\/\nexport function intervalsFromPlans(\n  plans: { prices: PlanPrice[] }[],\n): BillingInterval[] {\n  const order: BillingInterval[] = [\"day\", \"week\", \"month\", \"year\", \"one_time\"];\n  const seen = new Set<BillingInterval>();\n  for (const plan of plans) for (const p of plan.prices) seen.add(p.interval);\n  return order.filter((i) => seen.has(i));\n}\n","type":"registry:ui","target":"components\/fancy\/catalog-fms\/format.ts"},{"path":"components\/fancy\/catalog-fms\/index.ts","content":"\/**\n * Catalog + FMS UI block \u2014 framework-agnostic React components for a\n * Stripe-style catalog (laravel-catalog) with feature management (laravel-fms).\n *\n * Pair these with the example Inertia pages + controllers shipped in this block,\n * or wire them to any backend that normalizes data into the shapes in .\/types.\n *\/\nexport { PricingTable } from \".\/PricingTable\";\nexport type { PricingTableProps } from \".\/PricingTable\";\n\nexport { FeatureMatrix } from \".\/FeatureMatrix\";\nexport type { FeatureMatrixProps } from \".\/FeatureMatrix\";\n\nexport { FeatureGate, gateStatus } from \".\/FeatureGate\";\nexport type { FeatureGateProps, FeatureGateMode, LockedReason } from \".\/FeatureGate\";\n\nexport { PlanFeaturesEditor } from \".\/PlanFeaturesEditor\";\nexport type { PlanFeaturesEditorProps } from \".\/PlanFeaturesEditor\";\n\nexport {\n  formatMoney,\n  intervalSuffix,\n  priceForInterval,\n  intervalsFromPlans,\n} from \".\/format\";\n\nexport type {\n  BillingInterval,\n  PlanPrice,\n  PlanFeatureValue,\n  Plan,\n  FeatureDef,\n  Entitlement,\n  Entitlements,\n} from \".\/types\";\n","type":"registry:ui","target":"components\/fancy\/catalog-fms\/index.ts"},{"path":"components\/fancy\/catalog-fms\/types.ts","content":"\/**\n * Catalog + FMS UI \u2014 shared types.\n *\n * These are framework-agnostic, JSON-friendly shapes: a backend (Laravel\n * laravel-catalog + laravel-fms, or anything else) normalizes its data into\n * these and the components render. Nothing here imports React or Laravel.\n *\/\n\n\/** Stripe-style recurring interval (or `one_time` for non-recurring prices). *\/\nexport type BillingInterval = \"day\" | \"week\" | \"month\" | \"year\" | \"one_time\";\n\nexport interface PlanPrice {\n  id: string;\n  \/** Amount in the currency's MINOR units (e.g. cents). *\/\n  amount: number;\n  \/** ISO 4217 currency code, lower or upper case (e.g. `\"usd\"`). *\/\n  currency: string;\n  interval: BillingInterval;\n  \/** e.g. `3` for \"every 3 months\". Defaults to 1. *\/\n  intervalCount?: number;\n}\n\n\/** What a plan grants for one feature \u2014 used to build the comparison matrix. *\/\nexport type PlanFeatureValue =\n  | { type: \"boolean\"; enabled: boolean }\n  \/** `limit: null` = unlimited; `enabled: false` = not included. *\/\n  | { type: \"resource\"; enabled: boolean; limit: number | null };\n\nexport interface Plan {\n  id: string;\n  name: string;\n  description?: string;\n  \/** Visually emphasize this plan (and show the `badge`). *\/\n  recommended?: boolean;\n  \/** Ribbon text on the card, e.g. \"Most popular\". *\/\n  badge?: string;\n  \/** One price per interval the plan offers (e.g. a month + a year price). *\/\n  prices: PlanPrice[];\n  \/** Short bullets shown on the pricing card. *\/\n  highlights?: string[];\n  \/** Feature key \u2192 value, for `<FeatureMatrix>`. *\/\n  features?: Record<string, PlanFeatureValue>;\n  \/** The viewer is currently on this plan. *\/\n  current?: boolean;\n  \/** Override the CTA label (default derives from `current`). *\/\n  ctaLabel?: string;\n  disabled?: boolean;\n}\n\nexport interface FeatureDef {\n  key: string;\n  name: string;\n  description?: string;\n  type: \"boolean\" | \"resource\";\n  \/** Optional section grouping in the matrix. *\/\n  group?: string;\n  \/** Unit for resource features, e.g. \"seats\", \"GB\". *\/\n  unit?: string;\n}\n\n\/** The viewer's access to a single feature \u2014 used by `<FeatureGate>`. *\/\nexport interface Entitlement {\n  access: boolean;\n  \/** Resource cap (`null` = unlimited). Omit for boolean features. *\/\n  limit?: number | null;\n  \/** Resource consumed so far (for quota gating). *\/\n  used?: number;\n}\n\nexport interface Entitlements {\n  \/** The viewer's current plan id (optional, for context). *\/\n  planId?: string;\n  \/** Feature key \u2192 entitlement. *\/\n  features: Record<string, Entitlement>;\n}\n","type":"registry:ui","target":"components\/fancy\/catalog-fms\/types.ts"}]}