"use client";
import { ArrowUpRight, Search, X } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
} from "motion/react";
import { type CSSProperties, type ReactNode, useId, useMemo, useState } from "react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface Integration {
id: string;
name: string;
category: string;
description?: ReactNode;
/** Icon or logo node; sized to fit a small tile. */
icon: ReactNode;
href?: string;
/** Small label on the card, e.g. "Beta" or "New". */
badge?: ReactNode;
/** Extra text matched by search (aliases, keywords). */
keywords?: string[];
}
export interface IntegrationsSectionProps {
integrations: Integration[];
/** The hub at the center of the orbit (your product's logo). */
centerLogo: ReactNode;
/** Accessible name for the hub. Default "Our product". */
centerLabel?: string;
/** Ids shown on the orbit, inner ring first. Defaults to the first 14 integrations. */
orbitIds?: string[];
/** Icons on the inner ring; the rest go on the outer ring. Default 5. */
innerCount?: number;
/** Seconds per inner-ring revolution; the outer ring is 1.5× slower and turns the other way. Default 40. */
speed?: number;
/** Category chips in order. Derived from `integrations` when omitted. */
categories?: string[];
/** Label of the chip that shows everything. Default "All". */
allLabel?: string;
/** Controlled category ("" or `allLabel` means all). */
category?: string;
defaultCategory?: string;
onCategoryChange?: (category: string) => void;
/** Show the search field. Default true. */
searchable?: boolean;
searchPlaceholder?: string;
eyebrow?: ReactNode;
heading?: ReactNode;
description?: ReactNode;
className?: string;
/** Classes for the scrolling card list (e.g. a max height). */
listClassName?: string;
}
interface OrbitIcon {
integration: Integration;
angle: number;
}
function spread(items: Integration[], offset: number): OrbitIcon[] {
return items.map((integration, index) => ({
integration,
angle: offset + (360 / Math.max(1, items.length)) * index,
}));
}
/** Percent position of a point on a circle inscribed in its box. */
function onCircle(angle: number): CSSProperties {
const rad = (angle * Math.PI) / 180;
// Rounded so server and client stringify the same value.
const round = (n: number) => Math.round(n * 1000) / 1000;
return {
left: `${round(50 + 50 * Math.cos(rad))}%`,
top: `${round(50 + 50 * Math.sin(rad))}%`,
};
}
function OrbitRing({
icons,
inset,
duration,
reverse,
animated,
}: {
icons: OrbitIcon[];
inset: string;
duration: number;
reverse: boolean;
animated: boolean;
}) {
// The whole HTML ring rotates (CSS `spin`); each icon runs the same spin
// in the opposite direction at the same duration, so it stays upright.
const spin: CSSProperties = animated
? {
animationDuration: `${duration}s`,
animationDirection: reverse ? "reverse" : "normal",
}
: {};
const counter: CSSProperties = animated
? {
animationDuration: `${duration}s`,
animationDirection: reverse ? "normal" : "reverse",
}
: {};
return (