'use client'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { apiFetch } from '@/lib/api/client'; import { useDebounce } from '@/hooks/use-debounce'; // ─── Types - mirror SearchResults from search.service.ts ───────────────────── export type BucketType = | 'clients' | 'residentialClients' | 'yachts' | 'companies' | 'interests' | 'residentialInterests' | 'berths' | 'invoices' | 'expenses' | 'documents' | 'files' | 'reminders' | 'brochures' | 'tags' | 'navigation' | 'notes' | 'stages'; /** * Provenance hint for a result row that surfaced via graph expansion * rather than a direct match against the query. Rendered as a "via X" * subtitle by the result-row UI. */ export interface RelatedVia { type: 'berth' | 'interest' | 'client' | 'yacht' | 'company'; id: string; label: string; } export interface ClientResult { id: string; fullName: string; matchedContact: string | null; matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null; archivedAt: string | null; relatedVia?: RelatedVia | null; matchedOn?: string | null; } export interface ResidentialClientResult { id: string; fullName: string; email: string | null; phone: string | null; status: string; archivedAt: string | null; } export interface YachtResult { id: string; name: string; hullNumber: string | null; registration: string | null; archivedAt: string | null; relatedVia?: RelatedVia | null; } export interface CompanyResult { id: string; name: string; legalName: string | null; taxId: string | null; matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null; archivedAt: string | null; relatedVia?: RelatedVia | null; } export interface InterestResult { id: string; clientName: string; berthMooringNumber: string | null; pipelineStage: string; outcome: string | null; relatedVia?: RelatedVia | null; } export interface ResidentialInterestResult { id: string; clientName: string; pipelineStage: string; } export interface BerthResult { id: string; mooringNumber: string; area: string | null; status: string; linkedInterestCount: number; relatedVia?: RelatedVia | null; } export interface InvoiceResult { id: string; invoiceNumber: string; clientName: string; status: string; paymentStatus: string | null; totalAmount: string | null; currency: string; } export interface ExpenseResult { id: string; description: string | null; vendor: string | null; tripLabel: string | null; amount: string; currency: string; paymentStatus: string | null; } export interface DocumentResult { id: string; title: string; documentType: string; status: string; matchedSignerName: string | null; } export interface FileResult { id: string; filename: string; category: string | null; ownerLabel: string | null; } export interface ReminderResult { id: string; title: string; dueAt: string; priority: string; status: string; } export interface BrochureResult { id: string; label: string; isDefault: boolean; archivedAt: string | null; } export interface TagResult { id: string; name: string; color: string; totalCount: number; } export interface NavResult { id: string; href: string; label: string; category: 'settings' | 'admin' | 'dashboard'; } export interface StageSuggestionResult { /** Canonical pipeline-stage value (matches PIPELINE_STAGES). */ stage: string; /** Human label (STAGE_LABELS[stage]). */ label: string; /** Live count of non-archived interests in this stage. */ count: number; /** Slug-less href. CommandSearch prefixes the portSlug at render time. */ href: string; } export interface OtherPortResult { portId: string; portSlug: string; portName: string; type: 'client' | 'yacht' | 'company' | 'berth' | 'interest'; id: string; label: string; sub: string | null; } export interface SearchResults { clients: ClientResult[]; residentialClients: ResidentialClientResult[]; yachts: YachtResult[]; companies: CompanyResult[]; interests: InterestResult[]; residentialInterests: ResidentialInterestResult[]; berths: BerthResult[]; invoices: InvoiceResult[]; expenses: ExpenseResult[]; documents: DocumentResult[]; files: FileResult[]; reminders: ReminderResult[]; brochures: BrochureResult[]; tags: TagResult[]; navigation: NavResult[]; notes: NoteResult[]; stages: StageSuggestionResult[]; totals: Record; otherPorts?: OtherPortResult[]; } export interface NoteResult { id: string; snippet: string; source: 'client' | 'interest' | 'yacht' | 'company'; sourceId: string; sourceLabel: string; createdAt: string; } export interface RecentlyViewedItem { type: string; id: string; label: string; sub: string | null; href: string; viewedAt: number; } // ─── Hooks ──────────────────────────────────────────────────────────────────── export interface UseSearchOptions { /** When set, narrows the result set to a single bucket. */ type?: BucketType; /** Per-bucket cap. Default 5 (dropdown); use 25 for the /search page. */ limit?: number; /** Super-admin opt-in for cross-port matches. Silently ignored otherwise. */ includeOtherPorts?: boolean; /** Override the 300ms input debounce. */ debounceMs?: number; } export function useSearch(query: string, opts: UseSearchOptions = {}) { const debouncedQuery = useDebounce(query, opts.debounceMs ?? 300); const enabled = debouncedQuery.length >= 2; const params = new URLSearchParams(); params.set('q', debouncedQuery); if (opts.type) params.set('type', opts.type); if (opts.limit) params.set('limit', String(opts.limit)); if (opts.includeOtherPorts) params.set('includeOtherPorts', 'true'); const searchQuery = useQuery({ queryKey: [ 'search', debouncedQuery, opts.type ?? 'all', opts.limit ?? 5, opts.includeOtherPorts ?? false, ], queryFn: ({ signal }) => apiFetch(`/api/v1/search?${params.toString()}`, { signal }), enabled, // Keep previous results visible while the next debounced query loads // - eliminates the dropdown flicker when the user is typing fast. placeholderData: keepPreviousData, staleTime: 30_000, }); const recentSearchQuery = useQuery<{ searches: string[] }>({ queryKey: ['search', 'recent-terms'], queryFn: ({ signal }) => apiFetch<{ searches: string[] }>('/api/v1/search/recent', { signal }), staleTime: 60_000, }); const recentlyViewedQuery = useQuery<{ data: RecentlyViewedItem[] }>({ queryKey: ['search', 'recently-viewed'], queryFn: ({ signal }) => apiFetch<{ data: RecentlyViewedItem[] }>('/api/v1/search/recently-viewed', { signal }), staleTime: 30_000, }); return { results: searchQuery.data, isLoading: searchQuery.isLoading, isFetching: searchQuery.isFetching, enabled, recentSearches: recentSearchQuery.data?.searches ?? [], recentlyViewed: recentlyViewedQuery.data?.data ?? [], }; } /** * Track that the current user just opened an entity. Call once on mount * from each entity detail page (via `useTrackEntityView`); the resulting * (type, id) pair is used by the search dropdown's "Recently viewed" * section to surface the user's working set. */ export async function trackEntityView(type: string, id: string): Promise { try { await apiFetch('/api/v1/search/recently-viewed', { method: 'POST', body: { type, id }, }); } catch { // Tracking is non-critical - never bubble up to the user. } }