'use client' import * as React from 'react' import { useRouter } from 'next/navigation' import { Search, Loader2, Zap, User, Users, Building, StickyNote, Briefcase, CheckSquare, FileText, Mail, Phone, Calendar, Clock, Star, Tag, Flag, Heart, Bookmark, Package, Truck, ShoppingCart, CreditCard, DollarSign, Target, Award, Trophy, Rocket, Lightbulb, MessageSquare, Bell, Settings, Globe, MapPin, Link, Folder, Database, Activity, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { Dialog, DialogContent, DialogTitle } from '@open-mercato/ui/primitives/dialog' import { Input } from '@open-mercato/ui/primitives/input' import { Button } from '@open-mercato/ui/primitives/button' import { cn } from '@open-mercato/shared/lib/utils' import type { SearchResult, SearchResultLink, SearchStrategyId } from '@open-mercato/shared/modules/search' import { useT } from '@open-mercato/shared/lib/i18n/context' import { getCurrentOrganizationScope, subscribeOrganizationScopeChanged, } from '@open-mercato/shared/lib/frontend/organizationEvents' import { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants' import { parseSelectedOrganizationCookie } from '@open-mercato/core/modules/directory/utils/scopeCookies' import { ForbiddenError } from '@open-mercato/ui/backend/utils/api' import { resolveSearchMinTokenLength } from '@open-mercato/shared/lib/search/config' import { fetchGlobalSearchResults } from '../utils' const MIN_QUERY_LENGTH = resolveSearchMinTokenLength() /** Default strategies used when none are configured */ const DEFAULT_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens'] function normalizeLinks(links?: SearchResultLink[] | null): SearchResultLink[] { if (!Array.isArray(links)) return [] return links.filter((link) => typeof link?.href === 'string') } function pickPrimaryLink(result: SearchResult): string | null { if (result.url) return result.url const links = normalizeLinks(result.links) if (!links.length) return null const primary = links.find((link) => link.kind === 'primary') return (primary ?? links[0]).href } function hasActiveOrganizationSelection(): boolean { const fromEvent = getCurrentOrganizationScope().organizationId if (typeof fromEvent === 'string' && fromEvent.trim().length > 0) return true const cookieHeader = typeof document === 'undefined' ? null : document.cookie const cookieValue = parseSelectedOrganizationCookie(cookieHeader) if (!cookieValue) return false return !isAllOrganizationsSelection(cookieValue); } function humanizeSegment(segment: string): string { return segment .split(/[_-]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' ') } const ICON_MAP: Record = { bolt: Zap, zap: Zap, user: User, users: Users, building: Building, 'sticky-note': StickyNote, briefcase: Briefcase, 'check-square': CheckSquare, 'file-text': FileText, mail: Mail, phone: Phone, calendar: Calendar, clock: Clock, star: Star, tag: Tag, flag: Flag, heart: Heart, bookmark: Bookmark, package: Package, truck: Truck, 'shopping-cart': ShoppingCart, 'credit-card': CreditCard, 'dollar-sign': DollarSign, target: Target, award: Award, trophy: Trophy, rocket: Rocket, lightbulb: Lightbulb, 'message-square': MessageSquare, bell: Bell, settings: Settings, globe: Globe, 'map-pin': MapPin, link: Link, folder: Folder, database: Database, activity: Activity, } function resolveIcon(name?: string): LucideIcon | null { if (!name) return null return ICON_MAP[name.toLowerCase()] ?? null } function formatEntityId(entityId: string): string { if (!entityId.includes(':')) return humanizeSegment(entityId) const [module, entity] = entityId.split(':') return `${humanizeSegment(module)} · ${humanizeSegment(entity)}` } export type GlobalSearchDialogProps = { /** Whether embedding provider is configured for vector search */ embeddingConfigured: boolean /** Message to show when embedding is not configured */ missingConfigMessage: string /** Enabled strategies from tenant configuration (optional - uses defaults if not provided) */ enabledStrategies?: SearchStrategyId[] } export function GlobalSearchDialog({ embeddingConfigured, missingConfigMessage, enabledStrategies: propStrategies, }: GlobalSearchDialogProps) { const router = useRouter() const [open, setOpen] = React.useState(false) const [query, setQuery] = React.useState('') const [results, setResults] = React.useState([]) const [loading, setLoading] = React.useState(false) const [error, setError] = React.useState(null) const [selectedIndex, setSelectedIndex] = React.useState(0) const inputRef = React.useRef(null) const listRef = React.useRef(null) const abortRef = React.useRef(null) const t = useT() const [showScopeHint, setShowScopeHint] = React.useState(() => hasActiveOrganizationSelection()) React.useEffect(() => { setShowScopeHint(hasActiveOrganizationSelection()) return subscribeOrganizationScopeChanged((detail) => { setShowScopeHint(Boolean(detail.organizationId && detail.organizationId.trim().length > 0)) }) }, []) // Use configured strategies or fall back to defaults const enabledStrategies = React.useMemo(() => { if (propStrategies && propStrategies.length > 0) { return propStrategies } return DEFAULT_STRATEGIES }, [propStrategies]) const resetState = React.useCallback(() => { setQuery('') setResults([]) setError(null) setSelectedIndex(0) setLoading(false) }, []) React.useEffect(() => { if (!open) { resetState() return } const handler = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault() } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [open, resetState]) React.useEffect(() => { const shortcut = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault() setOpen((prev) => !prev) } } window.addEventListener('keydown', shortcut) return () => window.removeEventListener('keydown', shortcut) }, []) React.useEffect(() => { if (!open) return const focusTimer = setTimeout(() => inputRef.current?.focus(), 50) return () => clearTimeout(focusTimer) }, [open]) React.useEffect(() => { if (!open) return abortRef.current?.abort() if (query.trim().length < MIN_QUERY_LENGTH) { setResults([]) setError(null) setLoading(false) return } const controller = new AbortController() abortRef.current = controller setLoading(true) const handle = setTimeout(async () => { try { const data = await fetchGlobalSearchResults(query, { limit: 10, signal: controller.signal, }) setResults(data.results) setError(data.error ?? null) setSelectedIndex(0) } catch (err: unknown) { if (controller.signal.aborted) return const abortError = err as { name?: string } if (abortError?.name === 'AbortError') return if (err instanceof ForbiddenError) { setError(t('search.dialog.errors.noPermission')) } else { setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed')) } setResults([]) } finally { if (!controller.signal.aborted) setLoading(false) } }, 220) return () => { clearTimeout(handle) controller.abort() } }, [open, query, enabledStrategies, t]) const openResult = React.useCallback((result: SearchResult | undefined) => { if (!result) return const href = pickPrimaryLink(result) if (!href) return router.push(href) setOpen(false) }, [router]) const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault() openResult(results[selectedIndex]) return } if (event.key === 'ArrowDown') { event.preventDefault() setSelectedIndex((prev) => (prev + 1) % Math.max(results.length || 1, 1)) return } if (event.key === 'ArrowUp') { event.preventDefault() setSelectedIndex((prev) => { if (!results.length) return 0 return prev <= 0 ? results.length - 1 : prev - 1 }) return } if (event.key === 'Escape') { event.preventDefault() setOpen(false) return } if (event.key === 'Enter') { event.preventDefault() const target = results[selectedIndex] openResult(target) return } }, [results, selectedIndex, openResult]) React.useEffect(() => { const container = listRef.current const active = container?.querySelector('[data-active="true"]') if (!container || !active) return const { top: containerTop, bottom: containerBottom } = container.getBoundingClientRect() const { top: activeTop, bottom: activeBottom } = active.getBoundingClientRect() if (activeTop < containerTop) { container.scrollTop -= containerTop - activeTop } else if (activeBottom > containerBottom) { container.scrollTop += activeBottom - containerBottom } }, [selectedIndex]) // Check if vector search is enabled but not configured const showVectorWarning = !embeddingConfigured && enabledStrategies.includes('vector') && !error // Check if selected result has a navigable link const selectedResult = results[selectedIndex] const selectedHasLink = selectedResult ? pickPrimaryLink(selectedResult) !== null : false return ( <> {t('search.dialog.title', 'Global Search')} {t('search.dialog.instructions')}
setQuery(event.target.value)} onKeyDown={handleKeyDown} placeholder={t('search.dialog.input.placeholder')} className="border-none px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" autoFocus /> {loading && }
{error ? (

{error}

) : null} {showVectorWarning ? (

{missingConfigMessage}

) : null} {showScopeHint ? (

{t('search.scopeHint.currentOrg', 'Scoped to current organization')}

) : null}
{results.length === 0 && !loading && !error ? (
{query.trim().length < MIN_QUERY_LENGTH ? t('search.dialog.empty.hint', { count: MIN_QUERY_LENGTH }) : t('search.dialog.empty.none')}
) : null}
    {results.map((result, index) => { const presenter = result.presenter const isActive = index === selectedIndex const hasLink = pickPrimaryLink(result) !== null const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null return (
  • ) })}
{selectedResult && !selectedHasLink ? t('search.dialog.noLinkHint') : t('search.dialog.shortcuts.hint')}
) } export default GlobalSearchDialog const TypedInput = Input as React.ForwardRefExoticComponent & React.RefAttributes>