'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { Drawer as VaulDrawer } from 'vaul'; import { Clock, History, Search, X } from 'lucide-react'; import { apiFetch } from '@/lib/api/client'; import { cn } from '@/lib/utils'; import { useSearch, type BucketType, type SearchResults } from '@/hooks/use-search'; import { useUIStore } from '@/stores/ui-store'; import { buildFlatRows, type FlatRow } from './command-search'; import { HighlightMatch } from './highlight-match'; // Match the desktop bucket order — feels consistent when reps switch contexts. const BUCKETS: { type: BucketType; label: string }[] = [ { type: 'clients', label: 'Clients' }, { type: 'yachts', label: 'Yachts' }, { type: 'companies', label: 'Companies' }, { type: 'interests', label: 'Interests' }, { type: 'berths', label: 'Berths' }, { type: 'documents', label: 'Documents' }, { type: 'invoices', label: 'Invoices' }, { type: 'reminders', label: 'Reminders' }, ]; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const INVOICE_RE = /^INV-\d{6}-\d+$/i; function looksLikePastedId(input: string): boolean { const trimmed = input.trim(); return UUID_RE.test(trimmed) || INVOICE_RE.test(trimmed); } const BADGE_TONE: Record<'neutral' | 'warning' | 'success' | 'danger', string> = { neutral: 'bg-muted text-muted-foreground', warning: 'bg-amber-100 text-amber-900', success: 'bg-emerald-100 text-emerald-900', danger: 'bg-red-100 text-red-900', }; interface MobileSearchOverlayProps { open: boolean; onOpenChange: (open: boolean) => void; } export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayProps) { const [query, setQuery] = useState(''); const [activeBucket, setActiveBucket] = useState('all'); // Tracks the visible-above-keyboard height. iOS Safari ignores // keyboard area in `dvh`, so we use the visualViewport API directly: // visualViewport.height is the actual visible area in CSS pixels, // updates in real time as the keyboard rises/falls. const [visibleHeight, setVisibleHeight] = useState(null); const router = useRouter(); const queryClient = useQueryClient(); const portSlug = useUIStore((s) => s.currentPortSlug); const inputRef = useRef(null); // The overlay is mounted once at the layout root, so the recently- // viewed query won't refetch via the usual mount path. Bump it every // time the drawer opens — the user is about to look at it, and the // staleTime cache may have missed an entity view that happened in a // route that doesn't render . useEffect(() => { if (!open) return; queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] }); queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] }); }, [open, queryClient]); useEffect(() => { if (!open) { setVisibleHeight(null); return; } const vv = window.visualViewport; if (!vv) return; const update = () => setVisibleHeight(vv.height); update(); vv.addEventListener('resize', update); vv.addEventListener('scroll', update); return () => { vv.removeEventListener('resize', update); vv.removeEventListener('scroll', update); }; }, [open]); const { results, isFetching, recentSearches, recentlyViewed } = useSearch(query, { type: activeBucket === 'all' ? undefined : activeBucket, limit: activeBucket === 'all' ? 5 : 25, }); // Persist counts from the last "all" query so chip counts stay visible // when the user narrows to a single bucket. Narrowed queries only // return counts for the active bucket, which would otherwise wipe the // counts off every other chip the moment the user taps one. const lastAllTotalsRef = useRef(null); useEffect(() => { if (activeBucket === 'all' && results?.totals) { lastAllTotalsRef.current = results.totals; } }, [activeBucket, results]); const chipTotals: SearchResults['totals'] | undefined = activeBucket === 'all' ? results?.totals : (lastAllTotalsRef.current ?? results?.totals); // Auto-focus is delegated to Vaul's `autoFocus` + the input's // `autoFocus` attribute (synchronous in-gesture, which iOS Safari // requires before it'll pop the keyboard on programmatic focus). // A useEffect setTimeout was the previous approach but broke the // user-gesture chain — input was focused, keyboard stayed hidden. // Body scroll lock is delegated to Vaul (modal=true + noBodyStyles=false // defaults). Manual position:fixed locking caused a visible scroll-then- // jump on iOS Safari because the body briefly snaps to scrollY=0 after // being taken out of flow, before the negative-top compensation paints. // Vaul handles the lock natively via overflow:hidden which doesn't // remove the body from flow. The trick to avoid Vaul's iOS scroll-lock // race is `repositionInputs={false}` on the Drawer.Root (set below). // Reset query when the drawer closes. Without this, reopening the // overlay would flash stale results before the empty state renders. useEffect(() => { if (!open) { setQuery(''); setActiveBucket('all'); } }, [open]); const close = useCallback(() => { onOpenChange(false); inputRef.current?.blur(); }, [onOpenChange]); const navigate = useCallback( (path: string) => { close(); // eslint-disable-next-line @typescript-eslint/no-explicit-any router.push(path as any); }, [close, router], ); // Paste a UUID or invoice number → jump straight to the entity. const onPaste = useCallback( async (e: React.ClipboardEvent) => { const pasted = e.clipboardData.getData('text').trim(); if (!looksLikePastedId(pasted)) return; try { const res = await apiFetch<{ found: boolean; href: string | null }>( `/api/v1/search/resolve-id?id=${encodeURIComponent(pasted)}`, ); if (res.found && res.href) { e.preventDefault(); navigate(res.href); } } catch { // Best-effort — fall through to text search. } }, [navigate], ); const rows = useMemo( () => buildFlatRows({ query, results, recentlyViewed, recentSearches, activeBucket, portSlug, }), [query, results, recentlyViewed, recentSearches, activeBucket, portSlug], ); const showingEmptyHints = query.length < 2; const noResults = !showingEmptyHints && rows.length === 0 && !isFetching; return ( {/* Visually-hidden title for screen readers. Radix Dialog (which Vaul wraps) requires a DialogTitle in the accessibility tree; without this, the console throws an a11y violation. */} Search {/* Drag handle — Vaul reads this as a swipe target. Centered grip + a small label below feels iOS-native. */}
{/* Sticky header: input + Cancel. The Cancel slides in from the right when the input has focus, otherwise it sits flat. */}
{/* Bucket chips: horizontally scrollable so all buckets fit no matter the phone width. "All" is sticky-left so it's always one tap away when the user is deep in a bucket. */}
setActiveBucket('all')} /> {BUCKETS.map((b) => { const count = chipTotals?.[b.type] ?? 0; // Hide chips with zero matches in the last "all" snapshot, // unless this is the currently active chip. Always show all // before a query has run (chipTotals undefined → count 0 // and active 'all' means none get hidden). if (query.length >= 2 && count === 0 && activeBucket !== b.type) { return null; } return ( 0 ? count : undefined} active={activeBucket === b.type} onClick={() => setActiveBucket(b.type)} /> ); })}
{/* Results scroll region. overscroll-contain prevents the body from rubber-banding when the user scrolls past the bottom. */}
{showingEmptyHints && rows.length === 0 ? ( ) : showingEmptyHints ? ( ) : noResults ? ( ) : ( )}
); } function BucketChip({ label, count, active, onClick, }: { label: string; count?: number; active: boolean; onClick: () => void; }) { return ( ); } function EmptyHint() { return (

Search clients, yachts, interests, berths, invoices, documents — paste a UUID or invoice number to jump directly.

); } function NoResults({ query }: { query: string }) { return (

No matches for “{query}”

Try a different spelling, or switch buckets above.

); } function RowList({ rows, query, onSelect, variant, }: { rows: FlatRow[]; query: string; onSelect: (href: string) => void; variant: 'empty' | 'results'; }) { // Split rows by section header — "Recently viewed", "Recent searches", // "Results". Headers live inside the row list so they scroll with their // content (instead of sticky-positioning, which adds visual noise). const recentViews = rows.filter((r) => r.kind === 'recent-view'); const recentTerms = rows.filter((r) => r.kind === 'recent-term'); const results = rows.filter((r) => r.kind === 'result' || r.kind === 'other-port'); return (
{variant === 'empty' && recentViews.length > 0 ? (
} label="Recently viewed"> {recentViews.map((row) => row.kind === 'recent-view' ? ( onSelect(row.href)} label={row.item.label} sub={row.item.sub} /> ) : null, )}
) : null} {variant === 'empty' && recentTerms.length > 0 ? (
} label="Recent searches">
{recentTerms.map((row) => row.kind === 'recent-term' ? ( ) : null, )}
) : null} {variant === 'results' && results.length > 0 ? renderResultRows(results, query, onSelect) : null}
); } /** * Walk the flat result rows, inserting a small section header above the * first row of each bucket so reps know exactly what kind of entity * each result points to ("CLIENTS", "INTERESTS", "BERTHS", …). Bucket * order follows `buildFlatRows`'s ordering — most-likely matches first. */ function renderResultRows( rows: FlatRow[], query: string, onSelect: (path: string) => void, ): React.ReactNode[] { const nodes: React.ReactNode[] = []; let lastBucket: BucketType | null = null; rows.forEach((row, i) => { if (row.kind === 'result' && row.bucket !== lastBucket) { nodes.push(
{BUCKET_LABELS[row.bucket] ?? row.bucket}
, ); lastBucket = row.bucket; } else if (row.kind === 'other-port' && lastBucket !== null) { // Reset bucket tracker so re-grouping works on subsequent results. lastBucket = null; } if (row.kind === 'result') { const Icon = row.icon; const subContent = ( <> {row.sub ? : null} {row.relatedVia ? ( via {row.relatedVia.label} ) : null} ); nodes.push( onSelect(row.href)} label={} sub={row.sub || row.relatedVia ? subContent : null} icon={} badges={row.badges} />, ); } else if (row.kind === 'other-port') { nodes.push( onSelect(row.href)} label={row.item.label} sub={`${row.item.portName} · other port`} />, ); } }); return nodes; } /** Human-readable bucket labels for the section-header rows. */ const BUCKET_LABELS: Record = { clients: 'Clients', residentialClients: 'Residential clients', yachts: 'Yachts', companies: 'Companies', interests: 'Interests', residentialInterests: 'Residential interests', berths: 'Berths', invoices: 'Invoices', expenses: 'Expenses', documents: 'Documents', files: 'Files', reminders: 'Reminders', brochures: 'Brochures', tags: 'Tags', navigation: 'Settings & navigation', notes: 'Notes', }; function Section({ icon, label, children, }: { icon: React.ReactNode; label: string; children: React.ReactNode; }) { return (
{icon} {label}
{children}
); } function Row({ onSelect, label, sub, icon, badges, }: { onSelect: () => void; label: React.ReactNode; sub?: React.ReactNode; icon?: React.ReactNode; badges?: { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' }[]; }) { return ( ); }