'use client' import * as React from 'react' import { useRouter } from 'next/navigation' import type { ColumnDef } from '@tanstack/react-table' import * as LucideIcons from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { DataTable } from '@open-mercato/ui/backend/DataTable' import { RowActions } from '@open-mercato/ui/backend/RowActions' import type { SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search' import { cn } from '@open-mercato/shared/lib/utils' import { flash } from '@open-mercato/ui/backend/FlashMessages' 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 { resolveSearchMinTokenLength } from '@open-mercato/shared/lib/search/config' import { fetchHybridSearchResults } from '../utils' type Row = { entityId: string recordId: string source: string score: number | null url: string | null presenter: SearchResult['presenter'] | null links: SearchResult['links'] | null metadata: Record | null } const MIN_QUERY_LENGTH = resolveSearchMinTokenLength() const ALL_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens'] type Translator = ( key: string, fallbackOrParams?: string | Record, params?: Record ) => string 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 createColumns(t: Translator): ColumnDef[] { return [ { id: 'title', header: () => t('search.table.columns.result', 'Result'), cell: ({ row }) => { const item = row.original const title = resolveRowTitle(item) const iconName = item.presenter?.icon const Icon = iconName ? resolveIcon(iconName) : null const typeLabel = formatEntityId(item.entityId) const snapshot = item.presenter?.subtitle ?? extractSnapshot(item.metadata) const links = normalizeLinks(item.links) return (
{Icon ? : null}
{title} {typeLabel}
{snapshot ? ( {snapshot} ) : null} {links.length ? (
{links.map((link) => ( {link.label ?? link.href} ))}
) : null}
) }, meta: { priority: 1 }, }, { id: 'source', header: () => t('search.table.columns.source', 'Source'), cell: ({ row }) => { const source = row.original.source const colorClass = getStrategyColorClass(source) return ( {source} ) }, meta: { priority: 2 }, }, { id: 'score', header: () => t('search.table.columns.score', 'Score'), cell: ({ row }) => {row.original.score != null ? row.original.score.toFixed(2) : '—'}, meta: { priority: 2 }, }, ] } function getStrategyColorClass(strategy: string): string { switch (strategy) { case 'fulltext': return 'bg-status-info-bg text-status-info-text' case 'vector': return 'bg-brand-violet/10 text-brand-violet' case 'tokens': return 'bg-status-success-bg text-status-success-text' default: return 'bg-status-neutral-bg text-status-neutral-text' } } function normalizeLinks(links?: Row['links']): { href: string; label?: string; kind?: string }[] { if (!Array.isArray(links)) return [] return links.filter((link) => typeof link?.href === 'string') as Array<{ href: string; label?: string; kind?: string }> } function toPascalCase(input: string): string { return input .split(/[-_ ]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join('') } function resolveIcon(name?: string): LucideIcon | null { if (!name) return null const key = toPascalCase(name) const candidate = (LucideIcons as Record)[key] if (typeof candidate === 'function') { return candidate as LucideIcon } return null } function humanizeSegment(segment: string): string { return segment .split(/[_-]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' ') } function formatEntityId(entityId: string): string { if (!entityId.includes(':')) return humanizeSegment(entityId) const [module, entity] = entityId.split(':') const moduleLabel = humanizeSegment(module) const entityLabel = humanizeSegment(entity) return `${moduleLabel} · ${entityLabel}` } function resolveRowTitle(row: Row): string { const presenterTitle = row.presenter?.title if (typeof presenterTitle === 'string') { const trimmed = presenterTitle.trim() if (trimmed.length) return trimmed } return row.recordId } function extractSnapshot(metadata: Record | null): string | null { if (!metadata) return null const candidateKeys = ['snapshot', 'summary', 'description', 'body', 'content', 'note'] for (const key of candidateKeys) { const value = metadata[key] if (typeof value === 'string') { const trimmed = value.trim() if (trimmed.length) return trimmed } } return null } function pickPrimaryLink(row: Row): string | null { if (row.url) return row.url const links = normalizeLinks(row.links) if (!links.length) return null const primary = links.find((link) => link.kind === 'primary') return (primary ?? links[0]).href } function normalizeErrorMessage(input: unknown, fallback?: string): string | null { const fallbackMessage = typeof fallback === 'string' && fallback.trim().length ? fallback.trim() : null let message: string | null = null if (typeof input === 'string') { message = input } else if (input instanceof Error && typeof input.message === 'string') { message = input.message } if (message) { const trimmed = message.trim() if (trimmed.length) { const sanitized = trimmed.replace(/^\[[^\]]+\]\s*/, '').trim() if (sanitized.length) return sanitized } } return fallbackMessage } type HybridSearchTableProps = { /** Show strategy selector checkboxes (default: false - hidden from regular users) */ showStrategySelector?: boolean /** Show source column in results (default: false - hidden from regular users) */ showSourceColumn?: boolean } export function HybridSearchTable({ showStrategySelector = false, showSourceColumn = false, }: HybridSearchTableProps = {}) { const router = useRouter() const t = useT() const [showScopeHint, setShowScopeHint] = React.useState(() => hasActiveOrganizationSelection()) const [searchValue, setSearchValue] = React.useState('') const [rows, setRows] = React.useState([]) const [page, setPage] = React.useState(1) const [loading, setLoading] = React.useState(false) const [error, setError] = React.useState(null) const [timing, setTiming] = React.useState(null) const [strategiesUsed, setStrategiesUsed] = React.useState([]) const [enabledStrategies, setEnabledStrategies] = React.useState>( new Set(ALL_STRATEGIES) ) const debounceRef = React.useRef(null) const abortRef = React.useRef(null) const columns = React.useMemo(() => { const allColumns = createColumns(t) if (!showSourceColumn) { return allColumns.filter((col) => col.id !== 'source') } return allColumns }, [t, showSourceColumn]) React.useEffect(() => { setShowScopeHint(hasActiveOrganizationSelection()) return subscribeOrganizationScopeChanged((detail) => { setShowScopeHint(Boolean(detail.organizationId && detail.organizationId.trim().length > 0)) }) }, []) const toggleStrategy = React.useCallback((strategy: SearchStrategyId) => { setEnabledStrategies((prev) => { const next = new Set(prev) if (next.has(strategy)) { next.delete(strategy) } else { next.add(strategy) } return next }) }, []) const openRow = React.useCallback( (row: Row) => { const href = pickPrimaryLink(row) if (!href) return router.push(href) }, [router] ) React.useEffect(() => { const trimmed = searchValue.trim() abortRef.current?.abort() if (debounceRef.current) { window.clearTimeout(debounceRef.current) debounceRef.current = null } if (trimmed.length < MIN_QUERY_LENGTH) { setRows([]) setTiming(null) setStrategiesUsed([]) setError(null) setLoading(false) return } if (enabledStrategies.size === 0) { setRows([]) setTiming(null) setStrategiesUsed([]) setError(t('search.table.errors.noSources', 'Select at least one search source')) setLoading(false) return } const controller = new AbortController() abortRef.current = controller setLoading(true) debounceRef.current = window.setTimeout(async () => { try { const data = await fetchHybridSearchResults(trimmed, { limit: 50, strategies: Array.from(enabledStrategies), signal: controller.signal, }) const mapped = data.results.map((item) => ({ entityId: item.entityId, recordId: item.recordId, source: item.source, score: typeof item.score === 'number' ? item.score : null, url: item.url ?? null, presenter: item.presenter ?? null, links: item.links ?? null, metadata: (item.metadata as Record | null) ?? null, })) setRows(mapped) setTiming(data.timing) setStrategiesUsed(data.strategiesUsed) const message = data.error ? normalizeErrorMessage(data.error, t('search.table.errors.searchFailed', 'Search failed')) : null setError(message ?? null) setPage(1) } catch (err: unknown) { if (controller.signal.aborted) return if ((err as { name?: string })?.name === 'AbortError') return setError(normalizeErrorMessage(err, t('search.table.errors.searchFailed', 'Search failed'))) setRows([]) setTiming(null) setStrategiesUsed([]) } finally { if (!controller.signal.aborted) setLoading(false) } }, 250) return () => { controller.abort() if (debounceRef.current) window.clearTimeout(debounceRef.current) } }, [searchValue, enabledStrategies, t]) React.useEffect(() => { if (!error) return flash(error, 'error') }, [error]) return (
{/* Source Selector - only shown when showStrategySelector is true */} {showStrategySelector && (
{t('search.table.sources', 'Sources:')} {ALL_STRATEGIES.map((strategy) => ( ))}
)} {/* Stats Bar */} {timing !== null && rows.length > 0 && (
{rows.length} {t('search.table.stats.results', 'results')} {timing}ms {/* Only show sources when strategy selector is visible */} {showStrategySelector && strategiesUsed.length > 0 && ( {t('search.table.stats.sources', 'Sources:')} {strategiesUsed.join(', ')} )}
)} {/* Error Alert */} {error ? (
{error}
) : null} {showScopeHint ? (
{t('search.scopeHint.currentOrg', 'Scoped to current organization')}
) : null} {/* Data Table */} title={t('search.table.title', 'Search')} columns={columns} data={rows} searchValue={searchValue} onSearchChange={(value) => { setSearchValue(value) setPage(1) }} searchPlaceholder={t('search.table.searchPlaceholder', 'Search across all strategies...')} isLoading={loading} pagination={{ page, pageSize: rows.length || 1, total: rows.length, totalPages: 1, onPageChange: setPage }} onRowClick={(row) => openRow(row)} rowActions={(row) => { const primaryHref = pickPrimaryLink(row) if (!primaryHref) return null return }} embedded />
) } export default HybridSearchTable