{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "integration-hub",
  "title": "Integration Hub",
  "description": "Brand hub with a pyramid of connected apps and a looping pulse that travels down each branch.",
  "dependencies": [
    "framer-motion"
  ],
  "files": [
    {
      "path": "components/integrations/IntegrationHub.tsx",
      "content": "'use client';\n\nimport { motion } from 'framer-motion';\nimport { useId, useState, type CSSProperties } from 'react';\n\n// ---------------------------------------------------------------------------\n// Props\n// ---------------------------------------------------------------------------\n//\n// hub           Central brand node — name, optional logo URL, brand color\n// items         Connected apps arranged in a pyramid under the hub\n// accentColor   Stroke / pulse color for the connection lines\n// background    Section background (transparent by default)\n// className     Styles for the outer shell\n//\ntype HubNode = {\n  name: string;\n  logo?: string;\n  color?: string;\n};\n\ntype IntegrationItem = {\n  name: string;\n  logo?: string;\n  color?: string;\n  /** When false, the card renders but gets no connection line. Default true. */\n  connected?: boolean;\n};\n\ntype IntegrationHubProps = {\n  hub?: HubNode;\n  items?: IntegrationItem[];\n  accentColor?: string;\n  background?: string;\n  className?: string;\n};\n\n// ---------------------------------------------------------------------------\n// Defaults\n// ---------------------------------------------------------------------------\nconst ASSET = 'https://uniquel.io/images';\n\nconst DEFAULT_HUB: HubNode = {\n  name: 'Uniquel',\n  logo: `${ASSET}/uniquel-icon.png`,\n  color: '#FFFFFF',\n};\n\nconst DEFAULT_ITEMS: IntegrationItem[] = [\n  { name: 'Cursor', logo: `${ASSET}/works-with/cursor.png`, color: '#000000' },\n  { name: 'Claude', logo: `${ASSET}/works-with/claude.png`, color: '#D97757' },\n  { name: 'Figma', logo: `${ASSET}/works-with/figma.png`, color: '#F24E1E' },\n  { name: 'v0', logo: `${ASSET}/works-with/v0.png`, color: '#000000' },\n  { name: 'Lovable', logo: `${ASSET}/works-with/lovable.png`, color: '#FF6B6B' },\n  { name: 'Replit', logo: `${ASSET}/works-with/replit.png`, color: '#F26207' },\n  { name: 'Codex', logo: `${ASSET}/works-with/codex.png`, color: '#000000' },\n  { name: 'Base44', logo: `${ASSET}/works-with/base44.png`, color: '#6366F1' },\n  { name: 'Windsurf', logo: `${ASSET}/works-with/windsurf.png`, color: '#0EA5E9' },\n];\n\nconst HUB_SIZE = 84;\nconst ICON_SIZE = 52;\nconst ICON_GAP = 22;\nconst ROW_GAP = 70;\nconst PAD = 22;\nconst PAD_X = Math.round(PAD * 1.15);\nconst PAD_TOP = Math.round(PAD * 0.9);\nconst PAD_BOTTOM = Math.round(PAD * 1.75);\nconst HUB_RENDER_LIFT = 20;\nconst PULSE_MS = 2.4;\n\ntype LayoutNode = { item: IntegrationItem; x: number; y: number; i: number; row: number };\n\nfunction rowCounts(n: number): number[] {\n  if (n <= 0) return [];\n  if (n === 9) return [2, 3, 4];\n  const rows: number[] = [];\n  let left = n;\n  let size = Math.min(2, n);\n  while (left > 0) {\n    const take = Math.min(size, left);\n    rows.push(take);\n    left -= take;\n    size += 1;\n  }\n  return rows;\n}\n\nfunction stageSize(itemCount: number) {\n  const rows = rowCounts(itemCount);\n  const widest = Math.max(1, ...rows);\n  const contentW = widest * ICON_SIZE + Math.max(0, widest - 1) * ICON_GAP;\n  const width = contentW + PAD_X * 2;\n  const hubAnchorY = PAD_TOP + HUB_SIZE / 2;\n  const hubRenderY = hubAnchorY - HUB_RENDER_LIFT;\n  const rowStart = hubAnchorY + HUB_SIZE / 2 + 32;\n  const lastY = rowStart + Math.max(0, rows.length - 1) * ROW_GAP;\n  const contentHeight = lastY + ICON_SIZE / 2 + PAD_BOTTOM;\n  const height = Math.max(contentHeight, hubRenderY + HUB_SIZE / 2 + PAD_TOP);\n  return { width, height, hubX: width / 2, hubRenderY, rowStart };\n}\n\nfunction layoutItems(\n  items: IntegrationItem[],\n  width: number,\n  rowStart: number\n): LayoutNode[] {\n  const rows = rowCounts(items.length);\n  const nodes: LayoutNode[] = [];\n  let index = 0;\n\n  rows.forEach((count, rowIndex) => {\n    const y = rowStart + rowIndex * ROW_GAP;\n    const span = count * ICON_SIZE + (count - 1) * ICON_GAP;\n    const startX = (width - span) / 2 + ICON_SIZE / 2;\n    for (let c = 0; c < count; c++) {\n      nodes.push({\n        item: items[index],\n        x: startX + c * (ICON_SIZE + ICON_GAP),\n        y,\n        i: index,\n        row: rowIndex,\n      });\n      index += 1;\n    }\n  });\n\n  return nodes;\n}\n\nfunction linkPath(fromX: number, fromY: number, toX: number, toY: number) {\n  const midY = fromY + (toY - fromY) * 0.5;\n  return `M ${fromX} ${fromY} C ${fromX} ${midY}, ${toX} ${midY}, ${toX} ${toY}`;\n}\n\n/** Parent→child edges between adjacent rows; x-order preserved so paths never cross. */\nfunction buildEdges(\n  nodes: LayoutNode[],\n  hubX: number,\n  hubRenderY: number\n) {\n  const byRow = new Map<number, LayoutNode[]>();\n  nodes.forEach((n) => {\n    const list = byRow.get(n.row) ?? [];\n    list.push(n);\n    byRow.set(n.row, list);\n  });\n\n  const edges: { fromX: number; fromY: number; toX: number; toY: number; key: string }[] = [];\n  const rowIndexes = [...byRow.keys()].sort((a, b) => a - b);\n  if (!rowIndexes.length) return edges;\n\n  const wired = (n: LayoutNode) => n.item.connected !== false;\n  const hubBottomY = hubRenderY + HUB_SIZE / 2 - 4;\n\n  byRow.get(rowIndexes[0])!.filter(wired).forEach((child) => {\n    edges.push({\n      fromX: hubX,\n      fromY: hubBottomY,\n      toX: child.x,\n      toY: child.y - ICON_SIZE / 2,\n      key: `hub-${child.i}`,\n    });\n  });\n\n  for (let r = 0; r < rowIndexes.length - 1; r++) {\n    const parents = byRow.get(rowIndexes[r])!.filter(wired);\n    const children = byRow.get(rowIndexes[r + 1])!.filter(wired);\n    if (!parents.length || !children.length) continue;\n\n    children.forEach((child, ci) => {\n      const pi =\n        children.length === 1\n          ? Math.floor(parents.length / 2)\n          : Math.round((ci * (parents.length - 1)) / (children.length - 1));\n      const parent = parents[pi];\n      edges.push({\n        fromX: parent.x,\n        fromY: parent.y + ICON_SIZE / 2,\n        toX: child.x,\n        toY: child.y - ICON_SIZE / 2,\n        key: `${parent.i}-${child.i}`,\n      });\n    });\n  }\n\n  return edges;\n}\n\nfunction NodeCard({\n  name,\n  logo,\n  color,\n  size,\n}: {\n  name: string;\n  logo?: string;\n  color?: string;\n  size: number;\n}) {\n  const [failed, setFailed] = useState(false);\n  const initial = name.slice(0, 1).toUpperCase();\n  const showLogo = Boolean(logo) && !failed;\n\n  return (\n    <div\n      className='relative flex items-center justify-center rounded-[var(--radius-2xl,1rem)] bg-[var(--color-card,#fff)] shadow-[0_8px_24px_rgba(0,0,0,0.28)]'\n      style={{ width: size, height: size }}\n      title={name}\n    >\n      {showLogo ? (\n        <img\n          src={logo}\n          alt={name}\n          draggable={false}\n          onError={() => setFailed(true)}\n          className='h-[52%] w-[52%] object-contain'\n        />\n      ) : (\n        <span\n          className='flex h-[58%] w-[58%] items-center justify-center rounded-xl text-sm font-bold text-white'\n          style={{ backgroundColor: color || '#64748B' }}\n        >\n          {initial}\n        </span>\n      )}\n    </div>\n  );\n}\n\nexport default function IntegrationHub({\n  hub = DEFAULT_HUB,\n  items = DEFAULT_ITEMS,\n  accentColor = '#4BC4BE',\n  background = 'transparent',\n  className = '',\n}: IntegrationHubProps) {\n  const uid = useId().replace(/:/g, '');\n  const deck = items.length ? items : DEFAULT_ITEMS;\n  const { width, height, hubX, hubRenderY, rowStart } = stageSize(deck.length);\n  const nodes = layoutItems(deck, width, rowStart);\n  const edges = buildEdges(nodes, hubX, hubRenderY);\n  const hubColor = hub.color || DEFAULT_HUB.color || '#FFFFFF';\n\n  const rootStyle: CSSProperties = {\n    background,\n    width: '100%',\n  };\n\n  return (\n    <div\n      className={['relative flex w-full items-center justify-center overflow-visible', className]\n        .filter(Boolean)\n        .join(' ')}\n      style={rootStyle}\n      role='img'\n      aria-label={`${hub.name} connected to ${deck.map((d) => d.name).join(', ')}`}\n    >\n      <div className='relative' style={{ width, height }}>\n        <svg\n          aria-hidden\n          className='pointer-events-none absolute inset-0 h-full w-full'\n          viewBox={`0 0 ${width} ${height}`}\n          fill='none'\n        >\n          <defs>\n            <filter id={`glow-${uid}`} x='-50%' y='-50%' width='200%' height='200%'>\n              <feGaussianBlur stdDeviation='2.5' result='blur' />\n              <feMerge>\n                <feMergeNode in='blur' />\n                <feMergeNode in='SourceGraphic' />\n              </feMerge>\n            </filter>\n            <linearGradient id={`pulse-${uid}`} x1='0%' y1='0%' x2='0%' y2='100%'>\n              <stop offset='0%' stopColor={accentColor} stopOpacity='0' />\n              <stop offset='40%' stopColor={accentColor} stopOpacity='1' />\n              <stop offset='100%' stopColor={accentColor} stopOpacity='0.2' />\n            </linearGradient>\n          </defs>\n\n          {edges.map((edge, ei) => {\n            const d = linkPath(edge.fromX, edge.fromY, edge.toX, edge.toY);\n            return (\n              <g key={edge.key}>\n                <path\n                  d={d}\n                  stroke={accentColor}\n                  strokeOpacity={0.22}\n                  strokeWidth={1.25}\n                  strokeLinecap='round'\n                />\n                <motion.path\n                  d={d}\n                  stroke={`url(#pulse-${uid})`}\n                  strokeWidth={2}\n                  strokeLinecap='round'\n                  filter={`url(#glow-${uid})`}\n                  strokeDasharray='28 100'\n                  animate={{ strokeDashoffset: [128, 0] }}\n                  transition={{\n                    duration: PULSE_MS,\n                    repeat: Number.POSITIVE_INFINITY,\n                    ease: 'linear',\n                    delay: (ei % 3) * 0.18,\n                  }}\n                />\n                <motion.circle\n                  cx={edge.toX}\n                  cy={edge.toY}\n                  r={3}\n                  fill={accentColor}\n                  animate={{ opacity: [0.25, 1, 0.25], scale: [0.85, 1.15, 0.85] }}\n                  transition={{\n                    duration: PULSE_MS,\n                    repeat: Number.POSITIVE_INFINITY,\n                    ease: 'easeInOut',\n                    delay: (ei % 3) * 0.18 + PULSE_MS * 0.72,\n                  }}\n                />\n              </g>\n            );\n          })}\n        </svg>\n\n        {/* Hub */}\n        <div\n          className='absolute z-10 -translate-x-1/2 -translate-y-1/2'\n          style={{ left: hubX, top: hubRenderY }}\n        >\n          <div\n            className='flex items-center justify-center rounded-[var(--radius-2xl,1rem)] bg-[var(--color-card,#fff)] shadow-[0_12px_32px_rgba(0,0,0,0.12)]'\n            style={{\n              width: HUB_SIZE,\n              height: HUB_SIZE,\n              backgroundColor: hubColor,\n              boxShadow: `0 0 0 1px ${accentColor}66, 0 0 28px ${accentColor}40, 0 12px 32px rgba(0,0,0,0.12)`,\n            }}\n          >\n            {hub.logo ? (\n              <img\n                src={hub.logo}\n                alt={hub.name}\n                draggable={false}\n                className='h-[62%] w-[62%] object-contain'\n              />\n            ) : (\n              <span\n                className='select-none text-3xl font-black tracking-tight'\n                style={{ color: '#27265D' }}\n              >\n                {hub.name.slice(0, 1)}\n              </span>\n            )}\n          </div>\n        </div>\n\n        {/* Connected icons */}\n        {nodes.map((node) => (\n          <div\n            key={`node-${node.i}`}\n            className='absolute z-10 -translate-x-1/2 -translate-y-1/2'\n            style={{ left: node.x, top: node.y }}\n          >\n            <NodeCard\n              name={node.item.name}\n              logo={node.item.logo}\n              color={node.item.color}\n              size={ICON_SIZE}\n            />\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/integrations/IntegrationHub.tsx"
    }
  ],
  "categories": [
    "integrations"
  ],
  "type": "registry:component"
}