'use client' import * as React from 'react' import { createPortal } from 'react-dom' import NextLink from 'next/link' 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 as LinkIcon, Folder, Database, Activity, X, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { cn } from '@open-mercato/shared/lib/utils' import type { SearchResult, SearchResultLink } 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 { IconButton } from '@open-mercato/ui/primitives/icon-button' import { resolveSearchMinTokenLength } from '@open-mercato/shared/lib/search/config' import { fetchGlobalSearchResults } from '../utils' const MIN_QUERY_LENGTH = resolveSearchMinTokenLength() 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: LinkIcon, 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 TopbarSearchInlineProps = { /** Whether embedding provider is configured for vector search */ embeddingConfigured: boolean /** Warning text to show when vector search is enabled but not configured */ missingConfigMessage: string } export function TopbarSearchInline({ embeddingConfigured, missingConfigMessage, }: TopbarSearchInlineProps) { const router = useRouter() const t = useT() const [query, setQuery] = React.useState('') const [results, setResults] = React.useState([]) const [loading, setLoading] = React.useState(false) const [error, setError] = React.useState(null) const [open, setOpen] = React.useState(false) const [expanded, setExpanded] = React.useState(false) const [selectedIndex, setSelectedIndex] = React.useState(0) const [showScopeHint, setShowScopeHint] = React.useState(() => hasActiveOrganizationSelection()) const [anchor, setAnchor] = React.useState<{ top: number; left: number; width: number } | null>(null) const inputRef = React.useRef(null) const popoverRef = React.useRef(null) const containerRef = React.useRef(null) const listRef = React.useRef(null) const abortRef = React.useRef(null) const expandAndFocus = React.useCallback(() => { setExpanded(true) // Wait one tick so the input is mounted/transitioned before focusing requestAnimationFrame(() => { inputRef.current?.focus() inputRef.current?.select() }) }, []) const collapseAndReset = React.useCallback(() => { setOpen(false) setExpanded(false) setQuery('') inputRef.current?.blur() }, []) React.useEffect(() => { setShowScopeHint(hasActiveOrganizationSelection()) return subscribeOrganizationScopeChanged((detail) => { setShowScopeHint(Boolean(detail.organizationId && detail.organizationId.trim().length > 0)) }) }, []) // Cmd/Ctrl+K expands + focuses the input React.useEffect(() => { const handler = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault() expandAndFocus() } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [expandAndFocus]) // Click outside closes the popover AND collapses input (unless user has typed something) React.useEffect(() => { if (!expanded) return const handler = (event: MouseEvent) => { const target = event.target as Node | null if (!target) return if (containerRef.current?.contains(target)) return if (popoverRef.current?.contains(target)) return collapseAndReset() } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [expanded, collapseAndReset]) // Fetch results on query change (debounced) React.useEffect(() => { abortRef.current?.abort() const trimmed = query.trim() if (trimmed.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() } }, [query, t]) // Auto-scroll active item into view 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]) const openResult = React.useCallback( (result: SearchResult | undefined) => { if (!result) return const href = pickPrimaryLink(result) if (!href) return router.push(href) collapseAndReset() }, [router, collapseAndReset], ) const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if (event.key === 'ArrowDown') { event.preventDefault() if (!open) setOpen(true) setSelectedIndex((prev) => (results.length ? (prev + 1) % results.length : 0)) 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() if (open) { setOpen(false) } else if (query) { setQuery('') } else { collapseAndReset() } return } if (event.key === 'Enter') { event.preventDefault() const target = results[selectedIndex] openResult(target) return } }, [open, query, results, selectedIndex, openResult, collapseAndReset], ) const showVectorWarning = !embeddingConfigured && !error const trimmed = query.trim() const tooShort = trimmed.length > 0 && trimmed.length < MIN_QUERY_LENGTH const showPopover = open && (loading || results.length > 0 || error !== null || tooShort || showVectorWarning) const showClear = query.length > 0 // The results panel is portaled to so it escapes the sticky header's // stacking context (z-sticky) — rendered inline it stays trapped at the // header's z-index and page content with its own z-index paints over it // (#3097). Anchor it under the input and keep it aligned on scroll/resize. React.useEffect(() => { if (!showPopover) return const update = () => { const el = containerRef.current if (!el) return const rect = el.getBoundingClientRect() const width = Math.max(rect.width, 320) const left = Math.min(Math.max(rect.left, 8), Math.max(window.innerWidth - width - 8, 8)) setAnchor({ top: rect.bottom + 4, left, width }) } update() window.addEventListener('resize', update) window.addEventListener('scroll', update, true) return () => { window.removeEventListener('resize', update) window.removeEventListener('scroll', update, true) } }, [showPopover]) if (!expanded) { return ( { containerRef.current = el }} className="inline-flex" > ) } return (
{ containerRef.current = el }} data-search-expanded="true" className="relative min-w-0 sm:w-[260px] md:w-[320px] max-sm:absolute max-sm:inset-x-3 max-sm:top-1/2 max-sm:-translate-y-1/2 max-sm:z-popover" >
inputRef.current?.focus()} >
{showPopover && anchor ? createPortal(
{error ? (

{error}

) : null} {showVectorWarning && !error ? (

{missingConfigMessage}

collapseAndReset()} className="mt-1 inline-flex items-center font-medium underline underline-offset-2 hover:no-underline" > {t('search.dialog.warnings.configureLink', 'Configure search settings')}
) : null} {showScopeHint ? (

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

) : null} {tooShort && !error ? (
{t('search.dialog.empty.hint', { count: MIN_QUERY_LENGTH })}
) : null} {!tooShort && results.length === 0 && !loading && !error ? (
{t('search.dialog.empty.none', 'No results')}
) : null} {results.length > 0 ? (
{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 ( ) })}
) : null}
, document.body, ) : null}
) } export default TopbarSearchInline