/**
* Resolve the empty-state onboarding-card icon from a backend
* `SlashCommandSummary.iconName`.
*
* Primary keys match the production `/api/docs/commands` contract
* (multi-platform-hub) — short, source-system-style identifiers:
*
* - `clickup` → ClickUp brand glyph (mono grey)
* - `slack` → Slack brand glyph (mono grey)
* - `hubspot` → HubSpot brand glyph (mono grey)
* - `github` → GitHub brand glyph
* - `openframe` → OpenFrame logo
* - `newspaper` / `rocket` / `briefcase` / `graduation-cap`
* / `video` / `calendar` / `headphones` → v2 generic glyphs
* mapped to the closest available icon in `icons-v2-generated`
*
* Figma-canonical keys (`clickup-logo-grey`, `logo-openframe`,
* `rocket-02`, …) and a handful of generic v2 fall-throughs
* (`search`, `ticket`, `bug`, …) are kept as aliases so the
* Storybook fixtures and any older feed entries still resolve.
*
* Any `iconName` NOT in the curated map below is resolved generically
* against the entire `icons-v2-generated` set by naming convention
* (`chart-pie` → `ChartPieIcon`, `bank` → `BankIcon`, …) — see
* `resolveFromLibrary`. So the full icon library is supported, and the
* curated map only exists for aliases that don't map 1:1. Keys that
* name no real icon (and `null`) fall back to `FileIcon` so a
* misconfigured row still renders.
*/
import type { ComponentType, CSSProperties } from 'react'
import { getIconComponent } from './icon-registry'
import * as IconsV2 from '../../icons-v2-generated'
import { ClickupLogoGreyIcon } from '../../icons-v2-generated/brand-logos/clickup-logo-grey-icon'
import { GithubIcon } from '../../icons-v2-generated/brand-logos/github-icon'
import { HubspotLogoGreyIcon } from '../../icons-v2-generated/brand-logos/hubspot-logo-grey-icon'
import { SlackLogoGreyIcon } from '../../icons-v2-generated/brand-logos/slack-logo-grey-icon'
import { ChatsIcon } from '../../icons-v2-generated/communication/chats-icon'
import { BookIcon } from '../../icons-v2-generated/school/book-icon'
import { GraduationCapIcon } from '../../icons-v2-generated/school/graduation-cap-icon'
import { BracketCurlyIcon } from '../../icons-v2-generated/coding/bracket-curly-icon'
import { BugIcon } from '../../icons-v2-generated/coding/bug-icon'
import { CalendarIcon } from '../../icons-v2-generated/date-and-time/calendar-icon'
import { BriefcaseIcon } from '../../icons-v2-generated/map-and-travel/briefcase-icon'
import { CompassIcon } from '../../icons-v2-generated/map-and-travel/compass-icon'
import { FileIcon } from '../../icons-v2-generated/documents/file-icon'
import { LayersIcon } from '../../icons-v2-generated/design/layers-icon'
import { NewspaperIcon } from '../../icons-v2-generated/documents/newspaper-icon'
import { Rocket02Icon } from '../../icons-v2-generated/vehicles-and-delivery/rocket-02-icon'
import { HeadphoneIcon } from '../../icons-v2-generated/audio-and-visual/headphone-icon'
import { VideoRecorderIcon } from '../../icons-v2-generated/audio-and-visual/video-recorder-icon'
import { SearchIcon } from '../../icons-v2-generated/interface/search-icon'
import { TicketIcon } from '../../icons-v2-generated/shopping/ticket-icon'
import { OpenFrameLogo } from '../../icons/openframe-logo'
import { OpenmspLogo } from '../../icons/openmsp-logo'
import { FlamingoLogo } from '../../icons/flamingo-logo'
import { Megaphone, Bell, Info, Star, Package as PackageGlyph } from 'lucide-react'
/** Shape used by every icon registered here (matches `icons-v2-generated`). */
export type IconComponent = ComponentType<{
size?: number
className?: string
color?: string
}>
/** Wrap a brand logo (className/SVG-sized) so it satisfies the `size`-prop
* `IconComponent` contract — drives size via `style` (CSS wins over
* the logo's hardcoded width/height), keeping the logo's own brand fill. */
function sizedLogo(
Logo: ComponentType<{ className?: string; style?: CSSProperties }>,
): IconComponent {
return function SizedLogo({ size = 16, className }) {
return
}
}
const OpenmspLogoIcon = sizedLogo(OpenmspLogo)
const FlamingoLogoIcon = sizedLogo(FlamingoLogo)
/**
* `OpenFrameLogo` from `components/icons/` predates the v2 set and:
* 1. hardcodes `width="32" height="32"` AFTER spreading `{...props}`,
* so we can't override via the `width` / `height` props — we drive
* size through `style` instead (CSS attribute wins over the HTML
* `width` / `height` attributes).
* 2. defaults `lowerPathColor` to white and `upperPathColor` to
* `#1A1A1A` — the dark frame disappears on the chat's `ods-bg`
* surface. We pin both paths to `currentColor` so the glyph tints
* to whatever the card icon slot is (`ods-text-secondary` ≈ #888).
*/
function LogoOpenframeIcon({
size = 16,
className,
color,
}: {
size?: number
className?: string
color?: string
}) {
// Forward `color` (from `icon_props.color`) like every other resolver-backed
// glyph; default to `currentColor` so the logo tints to its slot.
const tint = color ?? 'currentColor'
return (
)
}
/**
* Static registry — backend `iconName` → React component. Brand-grey
* logos live in `icons-v2-generated/brand-logos/`; the rest are
* monochrome v2 glyphs that inherit `currentColor` from the card slot
* (`text-ods-text-secondary`).
*/
export const ICON_ALIASES: Record = {
// Production `/api/docs/commands` keys (multi-platform-hub contract)
clickup: ClickupLogoGreyIcon,
slack: SlackLogoGreyIcon,
hubspot: HubspotLogoGreyIcon,
github: GithubIcon,
openframe: LogoOpenframeIcon,
newspaper: NewspaperIcon,
rocket: Rocket02Icon,
briefcase: BriefcaseIcon,
'graduation-cap': GraduationCapIcon,
video: VideoRecorderIcon,
headphones: HeadphoneIcon,
calendar: CalendarIcon,
compass: CompassIcon,
// Figma-canonical aliases (node 7363:205938) — kept for Storybook
// fixtures and any older feed entries still using the long names.
'clickup-logo': ClickupLogoGreyIcon,
'clickup-logo-grey': ClickupLogoGreyIcon,
'slack-logo': SlackLogoGreyIcon,
'slack-logo-grey': SlackLogoGreyIcon,
'hubspot-logo': HubspotLogoGreyIcon,
'hubspot-logo-grey': HubspotLogoGreyIcon,
'github-logo': GithubIcon,
'logo-openframe': LogoOpenframeIcon,
'rocket-01': Rocket02Icon,
'rocket-02': Rocket02Icon,
// Announcement-bar legacy names — folded in so resolveIcon covers them too
// (was the separate `renderSvgIcon` map; now ONE resolver everywhere).
'openframe-logo': LogoOpenframeIcon,
'openmsp-logo': OpenmspLogoIcon,
flamingo: FlamingoLogoIcon,
'flamingo-logo': FlamingoLogoIcon,
megaphone: Megaphone,
bell: Bell,
info: Info,
star: Star,
package: PackageGlyph,
// Generic v2-native keys — useful fall-throughs for slash commands that
// don't have a brand-specific glyph in the design.
search: SearchIcon,
ticket: TicketIcon,
book: BookIcon,
'book-open': BookIcon,
bug: BugIcon,
layers: LayersIcon,
file: FileIcon,
'file-text': FileIcon,
chats: ChatsIcon,
'message-square': ChatsIcon,
'message-circle': ChatsIcon,
'bracket-curly': BracketCurlyIcon,
}
/**
* Generic resolution against the FULL `icons-v2-generated` set.
*
* Every generated icon follows one naming convention: a file named
* `-icon.tsx` exports a component `Icon` with the
* `{ size, className, color }` signature `IconComponent`
* expects. So any backend `iconName` that names a real icon (e.g.
* `chart-pie`, `bank`, `money-bill-dollar`, `piggy-bank`) resolves
* here without a hand-maintained entry — the whole library is
* supported, not just the curated aliases above.
*
* Two candidate PascalCase spellings are tried because the generator
* is inconsistent about the `ai` token: the standalone AI glyphs
* export `…AIIcon` (`BrainAIIcon`, `CodeAIIcon`), while the Adobe
* Illustrator brand glyph keeps `AdobeAiIcon`. We try the literal
* capitalization first, then the `ai → AI` variant, and take whichever
* actually exists in the namespace (the namespace is the source of
* truth, so we never guess wrong).
*/
function resolveFromLibrary(
iconName: string,
): IconComponent | undefined {
const tokens = iconName.trim().toLowerCase().replace(/_/g, '-').split('-')
if (tokens.some((t) => t.length === 0)) return undefined
const cap = (t: string) => t.charAt(0).toUpperCase() + t.slice(1)
const candidates = [
`${tokens.map(cap).join('')}Icon`,
`${tokens.map((t) => (t === 'ai' ? 'AI' : cap(t))).join('')}Icon`,
]
const registry = IconsV2 as unknown as Record
for (const name of candidates) {
const Icon = registry[name]
if (Icon) return Icon
}
return undefined
}
/**
* THE single icon-name → component resolver for the whole app. Two variants —
* one entry point, no duplicated resolver machinery:
*
* • `'design'` (DEFAULT): the icons-v2 design set — chat chips, slash
* autocomplete, announcement bar, AI-agent icons, the admin picker.
* Order: curated `ICON_ALIASES` (brand-grey logos, tinted OpenFrame,
* semantic aliases) → generic `icons-v2-generated` by naming convention →
* `FileIcon`.
* • `'brand'`: the brand/social set (lucide + brand-colored logos) via the
* registry resolver `getIconComponent` (PascalCase-normalised) — social
* links, source-row CTAs.
*
* The two glyph SETS differ on purpose (a grey design 'slack' vs a brand-color
* social 'slack'); this resolver is the ONE place that owns both. `getIconComponent`
* / `getDynamicIcon` remain as the brand-variant + sized-brand helpers.
*/
export function resolveIcon(
iconName: string | null | undefined,
opts?: { variant?: 'design' | 'brand' },
): IconComponent {
if (opts?.variant === 'brand') {
// Brand variant — delegate to the registry resolver (same glyph as before).
return getIconComponent(iconName) as IconComponent
}
if (!iconName) return FileIcon
return ICON_ALIASES[iconName] ?? resolveFromLibrary(iconName) ?? FileIcon
}
/** One selectable icon in the admin slash-command picker. */
export interface IconOption {
/** Stored `icon_name` value — a kebab-case `icons-v2-generated` key (or a
* curated alias) that `resolveIcon` displays in the chat. */
key: string
/** Human-friendly label shown in the picker dropdown. */
label: string
}
/**
* Curated icon set offered by the admin slash-command icon picker
* (`/admin/chat-config` → Edit slash command).
*
* THE PICKER AND THE CHAT SHARE ONE ICON SOURCE: every `key` here is an
* `icons-v2-generated` name (or curated alias) that `resolveIcon`
* renders — so whatever the admin selects always displays in the chip /
* autocomplete, with no fallback-glyph drift. (The full library is far larger
* and resolvable generically; this is the curated, slash-command-relevant
* subset for a usable dropdown.) Keep keys kebab-case to match the DB
* `chat_admin_slash_commands.icon_name` convention.
*/
export const ICON_OPTIONS: ReadonlyArray = [
// Brands
{ key: 'openframe', label: 'OpenFrame' },
{ key: 'slack', label: 'Slack' },
{ key: 'hubspot', label: 'HubSpot' },
{ key: 'clickup', label: 'ClickUp' },
{ key: 'github', label: 'GitHub' },
{ key: 'linkedin', label: 'LinkedIn' },
{ key: 'twitter', label: 'Twitter (X)' },
{ key: 'twitter-x', label: 'X' },
{ key: 'facebook', label: 'Facebook' },
{ key: 'instagram', label: 'Instagram' },
{ key: 'youtube', label: 'YouTube' },
{ key: 'discord', label: 'Discord' },
{ key: 'figma', label: 'Figma' },
{ key: 'google', label: 'Google' },
{ key: 'apple', label: 'Apple' },
{ key: 'windows', label: 'Windows' },
{ key: 'android', label: 'Android' },
{ key: 'openai-logo', label: 'OpenAI' },
{ key: 'anthropic-logo', label: 'Anthropic' },
// Content & docs
{ key: 'newspaper', label: 'Newspaper' },
{ key: 'book', label: 'Book' },
{ key: 'file', label: 'File' },
{ key: 'file-text', label: 'Document' },
{ key: 'file-code', label: 'Code file' },
{ key: 'file-chart-bar', label: 'Report' },
{ key: 'file-dollar', label: 'Invoice file' },
{ key: 'folder', label: 'Folder' },
{ key: 'folder-open', label: 'Folder open' },
{ key: 'clipboard', label: 'Clipboard' },
{ key: 'clipboard-list', label: 'Checklist' },
{ key: 'clipboard-check', label: 'Clipboard check' },
{ key: 'memo', label: 'Memo' },
{ key: 'paperclip', label: 'Attachment' },
{ key: 'copy-01', label: 'Copy' },
{ key: 'files', label: 'Files' },
{ key: 'box-archive', label: 'Box' },
{ key: 'parcel-01', label: 'Parcel' },
// Media
{ key: 'video', label: 'Video' },
{ key: 'headphones', label: 'Podcast / audio' },
{ key: 'podcast', label: 'Podcast' },
{ key: 'mic', label: 'Microphone' },
{ key: 'music-note-01', label: 'Music' },
{ key: 'camera', label: 'Camera' },
{ key: 'image', label: 'Image' },
{ key: 'film-hr', label: 'Film' },
{ key: 'play', label: 'Play' },
// Date & time
{ key: 'calendar', label: 'Calendar' },
{ key: 'calendar-check', label: 'Calendar check' },
{ key: 'clock', label: 'Clock' },
{ key: 'alarm', label: 'Alarm' },
{ key: 'stopwatch', label: 'Stopwatch' },
{ key: 'timer', label: 'Timer' },
{ key: 'hourglass', label: 'Hourglass' },
// Places & business
{ key: 'briefcase', label: 'Briefcase' },
{ key: 'graduation-cap', label: 'Education' },
{ key: 'building', label: 'Building' },
{ key: 'buildings', label: 'Buildings' },
{ key: 'hospital', label: 'Hospital' },
{ key: 'school', label: 'School' },
{ key: 'warehouse', label: 'Warehouse' },
{ key: 'shop', label: 'Shop' },
// Communication
{ key: 'search', label: 'Search' },
{ key: 'chats', label: 'Chat' },
{ key: 'message-text', label: 'Message' },
{ key: 'messages', label: 'Messages' },
{ key: 'email', label: 'Email' },
{ key: 'inbox', label: 'Inbox' },
{ key: 'send-01', label: 'Send' },
{ key: 'at-sign', label: 'Mention' },
{ key: 'bell', label: 'Notifications' },
{ key: 'megaphone-01', label: 'Announcement' },
// Charts & analytics
{ key: 'chart-pie', label: 'Pie chart' },
{ key: 'chart-donut', label: 'Donut chart' },
{ key: 'chart-bar-01-ver', label: 'Bar chart' },
{ key: 'chart-line-up', label: 'Line chart' },
{ key: 'trend-up', label: 'Trend up' },
{ key: 'trend-down', label: 'Trend down' },
{ key: 'presentation', label: 'Presentation' },
{ key: 'analysis', label: 'Analysis' },
{ key: 'activity', label: 'Activity' },
{ key: 'network', label: 'Network' },
// Finance
{ key: 'bank', label: 'Bank' },
{ key: 'money-bill-dollar', label: 'Money' },
{ key: 'money-bill', label: 'Bill' },
{ key: 'coins', label: 'Coins' },
{ key: 'coin', label: 'Coin' },
{ key: 'coins-exchange-currency', label: 'Currency exchange' },
{ key: 'piggy-bank', label: 'Savings' },
{ key: 'wallet-01', label: 'Wallet' },
{ key: 'credit-card', label: 'Credit card' },
{ key: 'hand-coin', label: 'Hand coin' },
{ key: 'dollar', label: 'Dollar' },
{ key: 'dollar-circle', label: 'Dollar circle' },
{ key: 'euro', label: 'Euro' },
{ key: 'bitcoin', label: 'Bitcoin' },
{ key: 'percent', label: 'Percent' },
{ key: 'receipt-01', label: 'Receipt' },
{ key: 'badge-dollar', label: 'Price badge' },
{ key: 'sack', label: 'Money sack' },
// Security
{ key: 'shield', label: 'Shield' },
{ key: 'shield-check', label: 'Shield check' },
{ key: 'lock', label: 'Lock' },
{ key: 'unlock', label: 'Unlock' },
{ key: 'key', label: 'Key' },
{ key: 'fingerprint', label: 'Fingerprint' },
{ key: 'scan', label: 'Scan' },
// People
{ key: 'user', label: 'User' },
{ key: 'users', label: 'Users' },
{ key: 'users-group', label: 'User group' },
{ key: 'user-check', label: 'User check' },
{ key: 'id-card', label: 'ID card' },
{ key: 'avatar-circle', label: 'Avatar' },
// Devices & infra
{ key: 'laptop', label: 'Laptop' },
{ key: 'monitor', label: 'Monitor' },
{ key: 'mobile-phone', label: 'Phone' },
{ key: 'hard-drive', label: 'Hard drive' },
{ key: 'hard-drives', label: 'Storage' },
{ key: 'cloud', label: 'Cloud' },
{ key: 'wifi', label: 'Wi-Fi' },
{ key: 'printer', label: 'Printer' },
{ key: 'keyboard', label: 'Keyboard' },
{ key: 'microchip', label: 'Chip' },
{ key: 'microchip-ai', label: 'AI chip' },
{ key: 'power', label: 'Power' },
// Code & dev
{ key: 'bracket-curly', label: 'Code' },
{ key: 'terminal', label: 'Terminal' },
{ key: 'code', label: 'Code block' },
{ key: 'bug', label: 'Bug' },
{ key: 'package', label: 'Package' },
{ key: 'database', label: 'Database' },
{ key: 'browser', label: 'Browser' },
{ key: 'puzzle-01', label: 'Plugin' },
{ key: 'coding-branch', label: 'Branch' },
{ key: 'coding-commit', label: 'Commit' },
{ key: 'coding-pull-request', label: 'Pull request' },
{ key: 'robot', label: 'Bot' },
{ key: 'blockchain', label: 'Blockchain' },
// General & status
{ key: 'layers', label: 'Layers' },
{ key: 'settings-01', label: 'Settings' },
{ key: 'home', label: 'Home' },
{ key: 'eye', label: 'View' },
{ key: 'link-01', label: 'Link' },
{ key: 'download-01', label: 'Download' },
{ key: 'upload-01', label: 'Upload' },
{ key: 'trash', label: 'Delete' },
{ key: 'share', label: 'Share' },
{ key: 'life-buoy', label: 'Support' },
{ key: 'rocket', label: 'Rocket' },
{ key: 'compass', label: 'Compass' },
{ key: 'star', label: 'Star' },
{ key: 'crown', label: 'Crown' },
{ key: 'gift', label: 'Gift' },
{ key: 'ticket', label: 'Ticket' },
{ key: 'tag', label: 'Tag' },
{ key: 'cart', label: 'Cart' },
{ key: 'thumbs-up', label: 'Thumbs up' },
{ key: 'flag-01', label: 'Flag' },
{ key: 'bookmark', label: 'Bookmark' },
{ key: 'pin', label: 'Pin' },
{ key: 'check-circle', label: 'Check' },
{ key: 'check-square', label: 'Checkbox' },
{ key: 'info-circle', label: 'Info' },
{ key: 'info', label: 'Info alt' },
{ key: 'question-circle', label: 'Help' },
{ key: 'alert-triangle', label: 'Warning' },
{ key: 'hash', label: 'Hashtag' },
{ key: 'pencil', label: 'Edit' },
{ key: 'lightning', label: 'Lightning' },
{ key: 'globe-01', label: 'Globe' },
{ key: 'scale-balanced', label: 'Balance' },
{ key: 'certified-01', label: 'Certified' },
]