'use client' import { type ComponentType, useCallback, useMemo, useRef } from 'react' import { ArrowRight, ChevronDown, ChipInput, chipVariants, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Search, } from '@sim/emcn' import Link from 'next/link' import { useParams } from 'next/navigation' import { debounce, useQueryStates } from 'nuqs' import { blockTypeToIconMap, formatIntegrationType, INTEGRATIONS, type Integration, } from '@/lib/integrations' import { getServiceConfigByProviderId } from '@/lib/oauth' import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration' import { ALL_CATEGORY, CONNECTED_LABEL, FEATURED_LABEL, integrationsParsers, integrationsUrlKeys, } from '@/app/workspace/[workspaceId]/integrations/search-params' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' /** Debounce window for `search` URL writes; the input itself stays instant. */ const SEARCH_DEBOUNCE_MS = 300 as const /** Slugs surfaced in the pinned Featured section, in display order. */ const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const const LINK_ROW_CLASSES = 'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' const LINK_ROW_TITLE_CLASSES = 'truncate text-[14px] text-[var(--text-body)]' const LINK_ROW_SUBTITLE_CLASSES = 'truncate text-[12px] text-[var(--text-muted)]' const LINK_ROW_ARROW_CLASSES = 'size-4 flex-shrink-0 text-[var(--text-icon)]' const FEATURED_INTEGRATIONS: readonly Integration[] = (() => { const bySlug = new Map(INTEGRATIONS.map((i) => [i.slug, i])) return FEATURED_SLUGS.map((slug) => bySlug.get(slug)).filter( (i): i is Integration => i !== undefined ) })() /** Lookup integration metadata by OAuth service display name (case-insensitive). */ const INTEGRATION_BY_LOWER_NAME: ReadonlyMap = new Map( INTEGRATIONS.map((i) => [i.name.toLowerCase(), i]) ) const ALL_CATEGORY_SECTIONS: readonly { label: string; integrations: Integration[] }[] = (() => { const grouped = new Map() for (const integration of INTEGRATIONS) { if (!integration.integrationType) continue const bucket = grouped.get(integration.integrationType) if (bucket) bucket.push(integration) else grouped.set(integration.integrationType, [integration]) } return Array.from(grouped, ([label, items]) => ({ label, integrations: [...items].sort((a, b) => a.name.localeCompare(b.name)), })).sort((a, b) => a.label.localeCompare(b.label)) })() interface IntegrationItemProps { blockType: string slug: string workspaceId: string name: string description?: string | null icon: ComponentType<{ className?: string }> } function IntegrationItem({ blockType, slug, workspaceId, name, description, icon: Icon, }: IntegrationItemProps) { return (
{name} {description && {description}}
) } interface ConnectedDisplayItem { credential: WorkspaceCredential name: string description: string serviceName: string integrationType: string | null blockType: string slug: string icon: ComponentType<{ className?: string }> } interface ConnectedItemProps { href: string blockType: string name: string description: string icon: ComponentType<{ className?: string }> } function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) { return (
{name} {description}
) } export function Integrations() { const scrollContainerRef = useRef(null) const params = useParams() const workspaceId = (params?.workspaceId as string) || '' const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] = useQueryStates(integrationsParsers, integrationsUrlKeys) /** * The input is controlled directly by the instant nuqs value; only the URL * write is debounced. Filtering below is cheap in-memory over a static list, * so it reads the instant value too. */ const setSearchTerm = useCallback( (value: string) => { const trimmed = value.trim() const next = trimmed.length > 0 ? trimmed : null setIntegrationFilters( { search: next }, next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } ) }, [setIntegrationFilters] ) const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({ workspaceId, enabled: Boolean(workspaceId), }) useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading }) const oauthCredentials = useMemo( () => credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'), [credentials] ) const connectedItems = useMemo(() => { return oauthCredentials.flatMap((credential) => { if (!credential.providerId) return [] const service = getServiceConfigByProviderId(credential.providerId) if (!service) return [] const integration = INTEGRATION_BY_LOWER_NAME.get(service.name.toLowerCase()) return [ { credential, name: credential.displayName, description: credential.description || `${service.name} integration`, serviceName: service.name, integrationType: integration?.integrationType ?? null, blockType: integration?.type ?? '', slug: integration?.slug ?? '', icon: service.icon as ComponentType<{ className?: string }>, }, ] }) }, [oauthCredentials]) const setSelectedCategory = useCallback( (category: string) => { setIntegrationFilters({ category }) }, [setIntegrationFilters] ) const categoryOptions = [ ALL_CATEGORY, ...(connectedItems.length > 0 ? [CONNECTED_LABEL] : []), FEATURED_LABEL, ...ALL_CATEGORY_SECTIONS.map((section) => section.label), ] const isAllCategorySelected = selectedCategory === ALL_CATEGORY const isFeaturedSelected = selectedCategory === FEATURED_LABEL const isConnectedSelected = selectedCategory === CONNECTED_LABEL const filteredCategorySections = useMemo(() => { // Connected-only view: integration sections are suppressed entirely. if (isConnectedSelected) return [] const normalizedSearch = urlSearchTerm.trim().toLowerCase() const matchesSearch = (integration: Integration) => !normalizedSearch || integration.name.toLowerCase().includes(normalizedSearch) || integration.description.toLowerCase().includes(normalizedSearch) if (isFeaturedSelected) { const items = FEATURED_INTEGRATIONS.filter(matchesSearch) return items.length > 0 ? [{ label: FEATURED_LABEL, integrations: items }] : [] } const matchesCategory = (integration: Integration) => isAllCategorySelected || integration.integrationType === selectedCategory // Featured is a curated home-row pin: hide it during search so results // are not duplicated between the Featured section and the category list. const featured = normalizedSearch ? [] : FEATURED_INTEGRATIONS.filter(matchesCategory) const featuredSection = featured.length > 0 ? [{ label: FEATURED_LABEL, integrations: featured }] : [] if (isAllCategorySelected) { const rest = ALL_CATEGORY_SECTIONS.map((section) => ({ label: section.label, integrations: section.integrations.filter(matchesSearch), })).filter((section) => section.integrations.length > 0) return [...featuredSection, ...rest] } const integrations = INTEGRATIONS.filter(matchesCategory) .filter(matchesSearch) .sort((a, b) => a.name.localeCompare(b.name)) return [ ...featuredSection, ...(integrations.length > 0 ? [{ label: selectedCategory, integrations }] : []), ] }, [ isAllCategorySelected, isConnectedSelected, isFeaturedSelected, urlSearchTerm, selectedCategory, ]) const visibleConnectedItems = useMemo(() => { // Featured-only view: Connected is suppressed (mirror behavior of the // Featured-only branch above, which renders only the Featured section). if (isFeaturedSelected) return [] const normalizedSearch = urlSearchTerm.trim().toLowerCase() return connectedItems.filter((item) => { const matchesCategory = isAllCategorySelected || isConnectedSelected || item.integrationType === selectedCategory if (!matchesCategory) return false if (!normalizedSearch) return true return ( item.name.toLowerCase().includes(normalizedSearch) || item.description.toLowerCase().includes(normalizedSearch) || item.serviceName.toLowerCase().includes(normalizedSearch) ) }) }, [ connectedItems, isAllCategorySelected, isConnectedSelected, isFeaturedSelected, urlSearchTerm, selectedCategory, ]) const showNoResults = Boolean(urlSearchTerm.trim() || !isAllCategorySelected) && filteredCategorySections.length === 0 && visibleConnectedItems.length === 0 return (
setSearchTerm(e.target.value)} disabled={credentialsLoading} /> {categoryOptions.map((category) => ( setSelectedCategory(category)}> {category === ALL_CATEGORY ? category : formatIntegrationType(category)} ))}
{visibleConnectedItems.length > 0 && ( {visibleConnectedItems.map((item) => ( ))} )} {filteredCategorySections.map((section) => ( {section.integrations.map((integration) => { const Icon = blockTypeToIconMap[integration.type] if (!Icon) return null return ( ) })} ))} {showNoResults && (
{urlSearchTerm.trim() ? `No integrations found matching “${urlSearchTerm}”` : 'No integrations in this category'}
)}
) }