feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones
Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
} from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Anchor,
|
||||
Bell,
|
||||
@@ -100,6 +101,7 @@ export function CommandSearch() {
|
||||
const [focusIndex, setFocusIndex] = useState<number>(-1);
|
||||
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -113,8 +115,32 @@ export function CommandSearch() {
|
||||
limit: activeBucket === 'all' ? 5 : 15,
|
||||
});
|
||||
|
||||
// Persist the totals from the last "all" query so the filter chips stay
|
||||
// populated when the user narrows to a single bucket. Without this, the
|
||||
// narrowed query only returns counts for the active bucket and every
|
||||
// other chip would vanish — making it impossible to swap between
|
||||
// filters without clearing back to "All" first.
|
||||
const lastAllTotalsRef = useRef<SearchResults['totals'] | null>(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);
|
||||
|
||||
const showDropdown = focused;
|
||||
|
||||
// CommandSearch lives in the header and persists across navigations,
|
||||
// so its React Query cache never sees a remount. Invalidate the
|
||||
// recently-viewed + recent-terms queries whenever the dropdown opens
|
||||
// so the user sees fresh data after navigating around the app.
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return;
|
||||
queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] });
|
||||
}, [showDropdown, queryClient]);
|
||||
|
||||
// Cmd/Ctrl+K focuses the input from anywhere on the page.
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: globalThis.KeyboardEvent) {
|
||||
@@ -287,7 +313,7 @@ export function CommandSearch() {
|
||||
>
|
||||
{/* Filter chip row — always visible while the dropdown is open. */}
|
||||
<FilterChipRow
|
||||
results={results}
|
||||
totals={chipTotals}
|
||||
active={activeBucket}
|
||||
onChange={setActiveBucket}
|
||||
disabled={query.length < 2}
|
||||
@@ -337,18 +363,19 @@ export function CommandSearch() {
|
||||
// ─── Filter chips ────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterChipRow({
|
||||
results,
|
||||
totals,
|
||||
active,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
results: SearchResults | undefined;
|
||||
/** Counts from the last "all" query, persisted so chips stay visible
|
||||
* when the user narrows to a single bucket. Falls back to the current
|
||||
* results.totals when no "all" snapshot exists yet. */
|
||||
totals: SearchResults['totals'] | undefined;
|
||||
active: BucketType | 'all';
|
||||
onChange: (b: BucketType | 'all') => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
// Show a chip for every bucket so the user can browse the search
|
||||
// surface even with no query; counts only render when results exist.
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
@@ -364,10 +391,10 @@ function FilterChipRow({
|
||||
All
|
||||
</ChipButton>
|
||||
{BUCKETS.map((b) => {
|
||||
const count = results?.totals?.[b.type] ?? 0;
|
||||
// Hide chips for buckets the current user can't see (count === 0
|
||||
// when the bucket query was permission-skipped) — but only after
|
||||
// a query has run, otherwise we'd hide every chip on first paint.
|
||||
const count = totals?.[b.type] ?? 0;
|
||||
// Hide chips for buckets with zero matches in the last "all"
|
||||
// snapshot — keeps the row tight and avoids dead-end clicks.
|
||||
// Always show the active chip + every chip before a query has run.
|
||||
if (!disabled && count === 0 && active !== b.type) return null;
|
||||
return (
|
||||
<ChipButton
|
||||
@@ -701,6 +728,11 @@ function ResultRow({
|
||||
<HighlightMatch text={row.sub} query={query} />
|
||||
</div>
|
||||
)}
|
||||
{row.relatedVia && (
|
||||
<div className="text-[11px] italic text-muted-foreground/80 truncate mt-0.5">
|
||||
via {row.relatedVia.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -759,9 +791,9 @@ function BucketSection({
|
||||
|
||||
// ─── Flat-row construction (drives keyboard nav + ARIA) ──────────────────────
|
||||
|
||||
type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
|
||||
export type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
|
||||
|
||||
type FlatRow =
|
||||
export type FlatRow =
|
||||
| {
|
||||
kind: 'recent-view';
|
||||
key: string;
|
||||
@@ -783,6 +815,9 @@ type FlatRow =
|
||||
sub: string | null;
|
||||
href: string;
|
||||
badges?: ResultBadge[];
|
||||
/** Provenance hint when the row was surfaced via graph expansion.
|
||||
* Rendered as a subtle "via Berth A10" line below the sub. */
|
||||
relatedVia?: { type: string; label: string } | null;
|
||||
}
|
||||
| {
|
||||
kind: 'other-port';
|
||||
@@ -791,7 +826,7 @@ type FlatRow =
|
||||
href: string;
|
||||
};
|
||||
|
||||
interface BuildFlatRowsArgs {
|
||||
export interface BuildFlatRowsArgs {
|
||||
query: string;
|
||||
results: SearchResults | undefined;
|
||||
recentlyViewed: RecentlyViewedItem[];
|
||||
@@ -800,7 +835,7 @@ interface BuildFlatRowsArgs {
|
||||
portSlug: string | null;
|
||||
}
|
||||
|
||||
function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args;
|
||||
const rows: FlatRow[] = [];
|
||||
|
||||
@@ -839,6 +874,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub: c.matchedContact ?? null,
|
||||
href: `/${portSlug}/clients/${c.id}`,
|
||||
badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined,
|
||||
relatedVia: c.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -866,6 +902,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
label: y.name,
|
||||
sub,
|
||||
href: `/${portSlug}/yachts/${y.id}`,
|
||||
relatedVia: y.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -880,6 +917,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
label: co.name,
|
||||
sub,
|
||||
href: `/${portSlug}/companies/${co.id}`,
|
||||
relatedVia: co.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -903,6 +941,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub: i.berthMooringNumber,
|
||||
href: `/${portSlug}/interests/${i.id}`,
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
relatedVia: i.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -942,6 +981,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub,
|
||||
href: `/${portSlug}/berths/${b.id}`,
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
relatedVia: b.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
607
src/components/search/mobile-search-overlay.tsx
Normal file
607
src/components/search/mobile-search-overlay.tsx
Normal file
@@ -0,0 +1,607 @@
|
||||
'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<BucketType | 'all'>('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<number | null>(null);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const inputRef = useRef<HTMLInputElement>(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 <TrackEntityView>.
|
||||
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<SearchResults['totals'] | null>(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<HTMLInputElement>) => {
|
||||
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<FlatRow[]>(
|
||||
() =>
|
||||
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 (
|
||||
<VaulDrawer.Root
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
// iOS Safari fluidity recipe sourced from:
|
||||
// - github.com/shadcn-ui/ui/issues/4321 (page reflow on open)
|
||||
// - gracefullight.dev fix-ios-safari-scroll-issue-with-vaul-drawer
|
||||
//
|
||||
// - shouldScaleBackground=false: page doesn't shrink behind us;
|
||||
// feels in-app rather than card-over-page.
|
||||
// - repositionInputs=false: don't let Vaul jiggle the viewport
|
||||
// when the input autofocuses and the keyboard appears — that
|
||||
// was the source of the "scroll then jump back" we were seeing.
|
||||
// Vaul still locks scroll via its modal=true default.
|
||||
// - autoFocus=true: Vaul focuses the input synchronously inside
|
||||
// the user-gesture frame, which is the only way iOS Safari
|
||||
// will pop the keyboard on programmatic focus. The input has
|
||||
// `autoFocus` set below so Vaul picks it as the target.
|
||||
shouldScaleBackground={false}
|
||||
repositionInputs={false}
|
||||
>
|
||||
<VaulDrawer.Portal>
|
||||
<VaulDrawer.Overlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur-sm" />
|
||||
<VaulDrawer.Content
|
||||
// Anchor by top + explicit height (not bottom: 0). iOS treats
|
||||
// `bottom: 0` on position:fixed inconsistently when the
|
||||
// keyboard is up (sometimes layout viewport, sometimes visual);
|
||||
// anchoring by top + height removes that ambiguity. Height
|
||||
// comes from visualViewport.height — the only iOS-reliable
|
||||
// source for "visible area above keyboard". 12px gap at the
|
||||
// top keeps a strip of backdrop visible.
|
||||
style={
|
||||
visibleHeight != null
|
||||
? { top: '12px', bottom: 'auto', height: `${Math.max(0, visibleHeight - 12)}px` }
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'fixed inset-x-0 z-50 flex flex-col rounded-t-2xl',
|
||||
// Fallback when visibleHeight hasn't measured yet (first
|
||||
// frame, SSR): top+bottom CSS-only sizing.
|
||||
visibleHeight == null && 'top-3 bottom-0',
|
||||
'border-t bg-background shadow-[0_-12px_40px_-12px_rgba(0,0,0,0.25)]',
|
||||
// Respect the bottom safe-area so the home indicator never
|
||||
// overlaps the scroll region.
|
||||
'pb-safe-bottom',
|
||||
)}
|
||||
>
|
||||
{/* 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. */}
|
||||
<VaulDrawer.Title className="sr-only">Search</VaulDrawer.Title>
|
||||
|
||||
{/* Drag handle — Vaul reads this as a swipe target. Centered grip
|
||||
+ a small label below feels iOS-native. */}
|
||||
<div className="flex flex-col items-center pt-2.5 pb-1.5">
|
||||
<div className="h-1.5 w-12 rounded-full bg-muted" aria-hidden />
|
||||
</div>
|
||||
|
||||
{/* Sticky header: input + Cancel. The Cancel slides in from the
|
||||
right when the input has focus, otherwise it sits flat. */}
|
||||
<div className="flex items-center gap-2 px-4 pb-3">
|
||||
<label className="relative flex h-11 flex-1 items-center rounded-xl bg-muted/70 px-3 transition-colors focus-within:bg-muted">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onPaste={onPaste}
|
||||
placeholder="Search clients, yachts, interests…"
|
||||
aria-label="Search"
|
||||
inputMode="search"
|
||||
enterKeyHint="search"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
className={cn(
|
||||
'ml-2 h-full w-full min-w-0 bg-transparent text-base outline-none',
|
||||
'placeholder:text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{query.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="ml-1 inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground active:bg-foreground/10"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="text-sm font-medium text-primary active:opacity-60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="border-b pb-3">
|
||||
<div className="flex gap-1.5 overflow-x-auto px-4 [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<BucketChip
|
||||
label="All"
|
||||
active={activeBucket === 'all'}
|
||||
onClick={() => 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 (
|
||||
<BucketChip
|
||||
key={b.type}
|
||||
label={b.label}
|
||||
count={count > 0 ? count : undefined}
|
||||
active={activeBucket === b.type}
|
||||
onClick={() => setActiveBucket(b.type)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results scroll region. overscroll-contain prevents the body
|
||||
from rubber-banding when the user scrolls past the bottom. */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-2 pb-4 pt-1">
|
||||
{showingEmptyHints && rows.length === 0 ? (
|
||||
<EmptyHint />
|
||||
) : showingEmptyHints ? (
|
||||
<RowList rows={rows} query={query} onSelect={navigate} variant="empty" />
|
||||
) : noResults ? (
|
||||
<NoResults query={query} />
|
||||
) : (
|
||||
<RowList rows={rows} query={query} onSelect={navigate} variant="results" />
|
||||
)}
|
||||
</div>
|
||||
</VaulDrawer.Content>
|
||||
</VaulDrawer.Portal>
|
||||
</VaulDrawer.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function BucketChip({
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count?: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background text-muted-foreground active:bg-accent active:text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{typeof count === 'number' && <span className="ml-1 opacity-70">({count})</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHint() {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center gap-3 px-6 pb-12 pt-28 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Search className="size-7" aria-hidden />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search clients, yachts, interests, berths, invoices, documents — paste a UUID or
|
||||
invoice number to jump directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoResults({ query }: { query: string }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 pb-12 text-center">
|
||||
<p className="text-sm font-medium text-foreground">No matches for “{query}”</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try a different spelling, or switch buckets above.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
{variant === 'empty' && recentViews.length > 0 ? (
|
||||
<Section icon={<Clock className="size-3.5" />} label="Recently viewed">
|
||||
{recentViews.map((row) =>
|
||||
row.kind === 'recent-view' ? (
|
||||
<Row
|
||||
key={row.key}
|
||||
onSelect={() => onSelect(row.href)}
|
||||
label={row.item.label}
|
||||
sub={row.item.sub}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{variant === 'empty' && recentTerms.length > 0 ? (
|
||||
<Section icon={<History className="size-3.5" />} label="Recent searches">
|
||||
<div className="flex flex-wrap gap-1.5 px-2 py-1">
|
||||
{recentTerms.map((row) =>
|
||||
row.kind === 'recent-term' ? (
|
||||
<button
|
||||
key={row.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Recent-term taps populate the input rather than
|
||||
// navigating — the rep usually wants to refine, not
|
||||
// jump straight back to the previous result.
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Search"]',
|
||||
);
|
||||
if (input) {
|
||||
input.value = row.term;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.focus();
|
||||
}
|
||||
}}
|
||||
className="rounded-full border border-border bg-muted/40 px-3 py-1 text-xs text-muted-foreground active:bg-accent active:text-accent-foreground"
|
||||
>
|
||||
{row.term}
|
||||
</button>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{variant === 'results' && results.length > 0 ? renderResultRows(results, query, onSelect) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
<div
|
||||
key={`__bucket_${row.bucket}_${i}`}
|
||||
className="px-3 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{BUCKET_LABELS[row.bucket] ?? row.bucket}
|
||||
</div>,
|
||||
);
|
||||
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 ? <HighlightMatch text={row.sub} query={query} /> : null}
|
||||
{row.relatedVia ? (
|
||||
<span className="block text-[11px] italic text-muted-foreground/80">
|
||||
via {row.relatedVia.label}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
nodes.push(
|
||||
<Row
|
||||
key={row.key}
|
||||
onSelect={() => onSelect(row.href)}
|
||||
label={<HighlightMatch text={row.label} query={query} />}
|
||||
sub={row.sub || row.relatedVia ? subContent : null}
|
||||
icon={<Icon className="size-4 text-muted-foreground" aria-hidden />}
|
||||
badges={row.badges}
|
||||
/>,
|
||||
);
|
||||
} else if (row.kind === 'other-port') {
|
||||
nodes.push(
|
||||
<Row
|
||||
key={row.key}
|
||||
onSelect={() => 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<BucketType, string> = {
|
||||
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 (
|
||||
<section>
|
||||
<div className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left',
|
||||
'min-h-[52px] active:bg-accent',
|
||||
)}
|
||||
>
|
||||
{icon ? <span className="shrink-0">{icon}</span> : null}
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium text-foreground">{label}</span>
|
||||
{sub ? <span className="truncate text-xs text-muted-foreground">{sub}</span> : null}
|
||||
</span>
|
||||
{badges?.length ? (
|
||||
<span className="flex shrink-0 gap-1">
|
||||
{badges.map((b) => (
|
||||
<span
|
||||
key={b.label}
|
||||
className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||
BADGE_TONE[b.tone],
|
||||
)}
|
||||
>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user