diff --git a/components/search/GlobalSearch.tsx b/components/search/GlobalSearch.tsx
new file mode 100644
index 0000000..bf822a4
--- /dev/null
+++ b/components/search/GlobalSearch.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import { Search, X } from 'lucide-react';
+import { useGlobalSearch } from '@/hooks/useGlobalSearch';
+import type { SearchResult } from '@/services/globalSearchService';
+
+interface GlobalSearchProps {
+ /** Called with the selected result — typically routes to `result.href`. */
+ onSelect?: (result: SearchResult) => void;
+}
+
+/**
+ * GlobalSearch — unified search across deliveries, drivers and transactions.
+ *
+ * Results arrive as one flat list and are rendered under a heading per category,
+ * always in the canonical order, with empty categories omitted entirely.
+ */
+export function GlobalSearch({ onSelect }: GlobalSearchProps) {
+ const { query, setQuery, groups, totalResults, isLoading, error, isEmpty, clear } =
+ useGlobalSearch();
+
+ return (
+
+
+
+ setQuery(event.target.value)}
+ placeholder="Search deliveries, drivers, transactions..."
+ aria-label="Global search"
+ className="w-full rounded-lg border border-gray-300 py-2.5 pl-10 pr-10 text-sm focus:border-transparent focus:ring-2 focus:ring-primary dark:border-gray-600 dark:bg-gray-800 dark:text-white"
+ />
+ {query && (
+
+ )}
+
+
+
+ {isLoading && (
+
+ Searching...
+
+ )}
+
+ {!isLoading && error && (
+
+ {error}
+
+ )}
+
+ {!isLoading && !error && isEmpty && (
+
+ No results found for "{query.trim()}".
+
+ )}
+
+ {!isLoading && !error && totalResults > 0 && (
+
+
+ {totalResults} result{totalResults === 1 ? '' : 's'} found
+
+ {groups.map((group) => (
+
+
+ {group.label}
+
+
+ {group.results.map((result) => (
+ -
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/components/search/__tests__/GlobalSearch.test.tsx b/components/search/__tests__/GlobalSearch.test.tsx
new file mode 100644
index 0000000..d410e31
--- /dev/null
+++ b/components/search/__tests__/GlobalSearch.test.tsx
@@ -0,0 +1,208 @@
+import React from 'react';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { GlobalSearch } from '@/components/search/GlobalSearch';
+import { globalSearchService, type SearchResult } from '@/services/globalSearchService';
+
+jest.mock('@/services/globalSearchService', () => {
+ const actual = jest.requireActual('@/services/globalSearchService');
+ return {
+ ...actual,
+ globalSearchService: { search: jest.fn() },
+ };
+});
+
+const mockSearch = globalSearchService.search as jest.Mock;
+
+const MIXED_RESULTS: SearchResult[] = [
+ { id: 't1', category: 'transactions', title: '0xabc123', subtitle: '250 XLM', href: '/transactions/t1' },
+ { id: 'd1', category: 'deliveries', title: 'TRK001', subtitle: 'In transit', href: '/deliveries/d1' },
+ { id: 'v1', category: 'drivers', title: 'Ada Obi', subtitle: 'Lagos', href: '/fleet/drivers/v1' },
+ { id: 'd2', category: 'deliveries', title: 'TRK002', subtitle: 'Pending', href: '/deliveries/d2' },
+];
+
+describe('GlobalSearch', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSearch.mockResolvedValue([]);
+ });
+
+ it('renders the search field and nothing else before a query is typed', () => {
+ render();
+
+ expect(screen.getByRole('searchbox', { name: 'Global search' })).toBeInTheDocument();
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument();
+ });
+
+ describe('categorisation', () => {
+ it('groups results under Deliveries, Drivers and Transactions headings in order', async () => {
+ const user = userEvent.setup();
+ mockSearch.mockResolvedValue(MIXED_RESULTS);
+ render();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a');
+
+ await waitFor(() => expect(screen.getAllByRole('heading')).toHaveLength(3));
+ expect(screen.getAllByRole('heading').map((heading) => heading.textContent)).toEqual([
+ 'Deliveries',
+ 'Drivers',
+ 'Transactions',
+ ]);
+ });
+
+ it('places each result under its own category', async () => {
+ const user = userEvent.setup();
+ mockSearch.mockResolvedValue(MIXED_RESULTS);
+ render();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a');
+
+ const deliveries = await screen.findByRole('region', { name: 'Deliveries' });
+ expect(within(deliveries).getAllByRole('listitem')).toHaveLength(2);
+ expect(within(deliveries).getByText('TRK001')).toBeInTheDocument();
+ expect(within(deliveries).getByText('TRK002')).toBeInTheDocument();
+
+ const drivers = screen.getByRole('region', { name: 'Drivers' });
+ expect(within(drivers).getAllByRole('listitem')).toHaveLength(1);
+ expect(within(drivers).getByText('Ada Obi')).toBeInTheDocument();
+
+ const transactions = screen.getByRole('region', { name: 'Transactions' });
+ expect(within(transactions).getByText('0xabc123')).toBeInTheDocument();
+ expect(within(transactions).queryByText('TRK001')).not.toBeInTheDocument();
+ });
+
+ it('renders the subtitle alongside each result title', async () => {
+ const user = userEvent.setup();
+ mockSearch.mockResolvedValue(MIXED_RESULTS);
+ render();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a');
+
+ expect(await screen.findByText('In transit')).toBeInTheDocument();
+ expect(screen.getByText('250 XLM')).toBeInTheDocument();
+ });
+
+ it('omits a heading for a category with no matches', async () => {
+ const user = userEvent.setup();
+ mockSearch.mockResolvedValue([MIXED_RESULTS[1]]);
+ render();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'trk');
+
+ expect(await screen.findByRole('heading', { name: 'Deliveries' })).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Drivers' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Transactions' })).not.toBeInTheDocument();
+ });
+
+ it('re-categorises when a follow-up query returns a different mix', async () => {
+ const user = userEvent.setup();
+ mockSearch.mockResolvedValueOnce(MIXED_RESULTS);
+ render();
+
+ const input = screen.getByRole('searchbox', { name: 'Global search' });
+ await user.type(input, 'a');
+ await waitFor(() => expect(screen.getAllByRole('heading')).toHaveLength(3));
+
+ mockSearch.mockResolvedValueOnce([MIXED_RESULTS[2]]);
+ await user.type(input, 'd');
+
+ await waitFor(() => expect(screen.getAllByRole('heading')).toHaveLength(1));
+ expect(screen.getByRole('heading', { name: 'Drivers' })).toBeInTheDocument();
+ expect(screen.queryByText('TRK001')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('selection', () => {
+ it('reports the selected result to the caller', async () => {
+ const user = userEvent.setup();
+ const onSelect = jest.fn();
+ mockSearch.mockResolvedValue(MIXED_RESULTS);
+ render();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a');
+ await user.click(await screen.findByRole('button', { name: /Ada Obi/ }));
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith(MIXED_RESULTS[2]);
+ });
+
+ it('does not throw when no onSelect handler is supplied', async () => {
+ const user = userEvent.setup();
+ mockSearch.mockResolvedValue(MIXED_RESULTS);
+ render();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a');
+ const button = await screen.findByRole('button', { name: /Ada Obi/ });
+
+ await expect(user.click(button)).resolves.not.toThrow();
+ });
+ });
+
+ describe('states', () => {
+ it('shows a loading message while the search is in flight', async () => {
+ const user = userEvent.setup();
+ let resolveSearch: (value: SearchResult[]) => void = () => {};
+ mockSearch.mockReturnValue(
+ new Promise((resolve) => {
+ resolveSearch = resolve;
+ }),
+ );
+ render();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a');
+
+ expect(await screen.findByText('Searching...')).toBeInTheDocument();
+
+ resolveSearch(MIXED_RESULTS);
+ await waitFor(() => expect(screen.queryByText('Searching...')).not.toBeInTheDocument());
+ });
+
+ it('shows an empty state when nothing matches', async () => {
+ const user = userEvent.setup();
+ mockSearch.mockResolvedValue([]);
+ render();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'zzz');
+
+ expect(await screen.findByText('No results found for "zzz".')).toBeInTheDocument();
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ });
+
+ it('shows an error state when the search service fails', async () => {
+ const user = userEvent.setup();
+ mockSearch.mockRejectedValue(new Error('Search service unavailable'));
+ render();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a');
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Search service unavailable');
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ });
+
+ it('clears the query and results via the clear button', async () => {
+ const user = userEvent.setup();
+ mockSearch.mockResolvedValue(MIXED_RESULTS);
+ render();
+
+ const input = screen.getByRole('searchbox', { name: 'Global search' });
+ await user.type(input, 'a');
+ await screen.findByRole('heading', { name: 'Deliveries' });
+
+ await user.click(screen.getByRole('button', { name: 'Clear search' }));
+
+ expect(input).toHaveValue('');
+ await waitFor(() => expect(screen.queryByRole('heading')).not.toBeInTheDocument());
+ });
+
+ it('does not query the service for whitespace-only input', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Global search' }), ' ');
+
+ await waitFor(() => expect(mockSearch).not.toHaveBeenCalled());
+ expect(screen.queryByText(/No results found/)).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/hooks/__tests__/useGlobalSearch.test.ts b/hooks/__tests__/useGlobalSearch.test.ts
new file mode 100644
index 0000000..d39953e
--- /dev/null
+++ b/hooks/__tests__/useGlobalSearch.test.ts
@@ -0,0 +1,220 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { useGlobalSearch } from '@/hooks/useGlobalSearch';
+import { globalSearchService, type SearchResult } from '@/services/globalSearchService';
+
+jest.mock('@/services/globalSearchService', () => {
+ const actual = jest.requireActual('@/services/globalSearchService');
+ return {
+ ...actual,
+ globalSearchService: { search: jest.fn() },
+ };
+});
+
+const mockSearch = globalSearchService.search as jest.Mock;
+
+const result = (overrides: Partial & Pick): SearchResult => ({
+ title: overrides.id,
+ href: `/${overrides.category}/${overrides.id}`,
+ ...overrides,
+});
+
+const MIXED_RESULTS: SearchResult[] = [
+ result({ id: 't1', category: 'transactions', title: '0xabc' }),
+ result({ id: 'd1', category: 'deliveries', title: 'TRK001' }),
+ result({ id: 'v1', category: 'drivers', title: 'Ada Obi' }),
+ result({ id: 'd2', category: 'deliveries', title: 'TRK002' }),
+];
+
+describe('useGlobalSearch', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSearch.mockResolvedValue([]);
+ });
+
+ it('starts idle without hitting the search service', () => {
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ expect(hook.current.query).toBe('');
+ expect(hook.current.groups).toEqual([]);
+ expect(hook.current.totalResults).toBe(0);
+ expect(hook.current.isLoading).toBe(false);
+ expect(hook.current.isEmpty).toBe(false);
+ expect(mockSearch).not.toHaveBeenCalled();
+ });
+
+ it('groups results into Deliveries, Drivers and Transactions in canonical order', async () => {
+ mockSearch.mockResolvedValue(MIXED_RESULTS);
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('a'));
+
+ await waitFor(() => expect(hook.current.groups).toHaveLength(3));
+
+ expect(hook.current.groups.map((group) => group.category)).toEqual([
+ 'deliveries',
+ 'drivers',
+ 'transactions',
+ ]);
+ expect(hook.current.groups.map((group) => group.label)).toEqual([
+ 'Deliveries',
+ 'Drivers',
+ 'Transactions',
+ ]);
+ expect(hook.current.groups[0].results.map((entry) => entry.id)).toEqual(['d1', 'd2']);
+ expect(hook.current.totalResults).toBe(4);
+ });
+
+ it('omits categories with no results', async () => {
+ mockSearch.mockResolvedValue([result({ id: 'v1', category: 'drivers', title: 'Ada Obi' })]);
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('ada'));
+
+ await waitFor(() => expect(hook.current.groups).toHaveLength(1));
+ expect(hook.current.groups[0].category).toBe('drivers');
+ });
+
+ it('exposes a loading state while the search is in flight', async () => {
+ let resolveSearch: (value: SearchResult[]) => void = () => {};
+ mockSearch.mockReturnValue(
+ new Promise((resolve) => {
+ resolveSearch = resolve;
+ }),
+ );
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('trk'));
+ await waitFor(() => expect(hook.current.isLoading).toBe(true));
+
+ await act(async () => {
+ resolveSearch([]);
+ });
+
+ expect(hook.current.isLoading).toBe(false);
+ });
+
+ it('reports an empty state only after a search has actually run', async () => {
+ mockSearch.mockResolvedValue([]);
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('nothing-matches'));
+
+ await waitFor(() => expect(hook.current.isEmpty).toBe(true));
+ expect(hook.current.groups).toEqual([]);
+ expect(hook.current.error).toBeNull();
+ });
+
+ it('does not search for a whitespace-only query', async () => {
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery(' '));
+
+ await waitFor(() => expect(hook.current.isLoading).toBe(false));
+ expect(mockSearch).not.toHaveBeenCalled();
+ expect(hook.current.isEmpty).toBe(false);
+ });
+
+ it('surfaces a service failure and drops stale results', async () => {
+ mockSearch.mockResolvedValueOnce(MIXED_RESULTS);
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('a'));
+ await waitFor(() => expect(hook.current.totalResults).toBe(4));
+
+ mockSearch.mockRejectedValueOnce(new Error('Search service unavailable'));
+ act(() => hook.current.setQuery('ab'));
+
+ await waitFor(() => expect(hook.current.error).toBe('Search service unavailable'));
+ expect(hook.current.groups).toEqual([]);
+ expect(hook.current.isEmpty).toBe(false);
+ });
+
+ it('falls back to a generic message for a non-Error rejection', async () => {
+ mockSearch.mockRejectedValue('boom');
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('a'));
+
+ await waitFor(() => expect(hook.current.error).toBe('Unable to complete the search'));
+ });
+
+ it('clears the error once a later query succeeds', async () => {
+ mockSearch.mockRejectedValueOnce(new Error('Search service unavailable'));
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('a'));
+ await waitFor(() => expect(hook.current.error).toBe('Search service unavailable'));
+
+ mockSearch.mockResolvedValueOnce([result({ id: 'd1', category: 'deliveries', title: 'TRK001' })]);
+ act(() => hook.current.setQuery('trk'));
+
+ await waitFor(() => expect(hook.current.totalResults).toBe(1));
+ expect(hook.current.error).toBeNull();
+ });
+
+ it('aborts the previous request when the query changes', async () => {
+ mockSearch.mockResolvedValue([]);
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('a'));
+ await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1));
+
+ const firstSignal = mockSearch.mock.calls[0][1] as AbortSignal;
+ act(() => hook.current.setQuery('ab'));
+
+ await waitFor(() => expect(firstSignal.aborted).toBe(true));
+ expect(mockSearch).toHaveBeenLastCalledWith('ab', expect.any(AbortSignal));
+ });
+
+ it('ignores a stale response that resolves after a newer query', async () => {
+ let resolveFirst: (value: SearchResult[]) => void = () => {};
+ mockSearch.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ );
+ mockSearch.mockResolvedValueOnce([result({ id: 'd2', category: 'deliveries', title: 'TRK002' })]);
+
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('a'));
+ act(() => hook.current.setQuery('ab'));
+
+ await waitFor(() => expect(hook.current.totalResults).toBe(1));
+
+ await act(async () => {
+ resolveFirst([result({ id: 'd1', category: 'deliveries', title: 'STALE' })]);
+ });
+
+ expect(hook.current.results.map((entry) => entry.id)).toEqual(['d2']);
+ });
+
+ it('resets everything when the query is emptied', async () => {
+ mockSearch.mockResolvedValue(MIXED_RESULTS);
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('a'));
+ await waitFor(() => expect(hook.current.totalResults).toBe(4));
+
+ act(() => hook.current.setQuery(''));
+
+ await waitFor(() => expect(hook.current.totalResults).toBe(0));
+ expect(hook.current.groups).toEqual([]);
+ expect(hook.current.isEmpty).toBe(false);
+ });
+
+ it('clears the query and results on demand', async () => {
+ mockSearch.mockResolvedValue(MIXED_RESULTS);
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('a'));
+ await waitFor(() => expect(hook.current.totalResults).toBe(4));
+
+ act(() => hook.current.clear());
+
+ expect(hook.current.query).toBe('');
+ expect(hook.current.groups).toEqual([]);
+ expect(hook.current.isLoading).toBe(false);
+ expect(hook.current.error).toBeNull();
+ });
+});
diff --git a/hooks/useGlobalSearch.ts b/hooks/useGlobalSearch.ts
new file mode 100644
index 0000000..f984793
--- /dev/null
+++ b/hooks/useGlobalSearch.ts
@@ -0,0 +1,114 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ globalSearchService,
+ SEARCH_CATEGORIES,
+ SEARCH_CATEGORY_LABELS,
+ type SearchCategory,
+ type SearchResult,
+} from '@/services/globalSearchService';
+
+export interface SearchResultGroup {
+ category: SearchCategory;
+ label: string;
+ results: SearchResult[];
+}
+
+export interface UseGlobalSearchResult {
+ query: string;
+ setQuery: (query: string) => void;
+ /** Non-empty categories, always in the canonical Deliveries/Drivers/Transactions order. */
+ groups: SearchResultGroup[];
+ /** Flat result list, useful for keyboard navigation and counters. */
+ results: SearchResult[];
+ totalResults: number;
+ isLoading: boolean;
+ error: string | null;
+ /** True once a search has run for a non-empty query and returned nothing. */
+ isEmpty: boolean;
+ clear: () => void;
+}
+
+/** The outcome of the most recently completed search, tagged with its query. */
+interface SearchSnapshot {
+ query: string;
+ results: SearchResult[];
+ error: string | null;
+}
+
+const EMPTY_SNAPSHOT: SearchSnapshot = { query: '', results: [], error: null };
+
+/**
+ * useGlobalSearch — runs the unified search and groups the results by category.
+ *
+ * The snapshot is tagged with the query that produced it, so every derived value
+ * — loading, results, grouping — follows from whether that tag still matches the
+ * current query. Results from a superseded query can therefore never be shown,
+ * and in-flight requests are aborted as soon as the query moves on.
+ */
+export function useGlobalSearch(): UseGlobalSearchResult {
+ const [query, setQuery] = useState('');
+ const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT);
+
+ const trimmedQuery = query.trim();
+
+ useEffect(() => {
+ if (!trimmedQuery) return;
+
+ const controller = new AbortController();
+
+ globalSearchService
+ .search(trimmedQuery, controller.signal)
+ .then((results) => {
+ if (controller.signal.aborted) return;
+ setSnapshot({ query: trimmedQuery, results, error: null });
+ })
+ .catch((searchError: unknown) => {
+ if (controller.signal.aborted) return;
+ setSnapshot({
+ query: trimmedQuery,
+ results: [],
+ error:
+ searchError instanceof Error ? searchError.message : 'Unable to complete the search',
+ });
+ });
+
+ return () => controller.abort();
+ }, [trimmedQuery]);
+
+ // The snapshot only counts while it still describes the query on screen.
+ const isSettled = trimmedQuery !== '' && snapshot.query === trimmedQuery;
+ const results = useMemo(
+ () => (isSettled ? snapshot.results : []),
+ [isSettled, snapshot.results],
+ );
+ const error = isSettled ? snapshot.error : null;
+
+ const groups = useMemo(
+ () =>
+ SEARCH_CATEGORIES.map((category) => ({
+ category,
+ label: SEARCH_CATEGORY_LABELS[category],
+ results: results.filter((result) => result.category === category),
+ })).filter((group) => group.results.length > 0),
+ [results],
+ );
+
+ const clear = useCallback(() => {
+ setQuery('');
+ setSnapshot(EMPTY_SNAPSHOT);
+ }, []);
+
+ return {
+ query,
+ setQuery,
+ groups,
+ results,
+ totalResults: results.length,
+ isLoading: trimmedQuery !== '' && !isSettled,
+ error,
+ isEmpty: isSettled && error === null && results.length === 0,
+ clear,
+ };
+}
diff --git a/services/__tests__/globalSearchService.test.ts b/services/__tests__/globalSearchService.test.ts
new file mode 100644
index 0000000..d65f14f
--- /dev/null
+++ b/services/__tests__/globalSearchService.test.ts
@@ -0,0 +1,144 @@
+import api from '@/lib/api';
+import { globalSearchService, SEARCH_CATEGORIES } from '@/services/globalSearchService';
+
+jest.mock('@/lib/api', () => ({
+ __esModule: true,
+ default: { get: jest.fn() },
+}));
+
+const mockGet = api.get as jest.Mock;
+
+describe('globalSearchService.search', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('exposes the three unified search categories in canonical order', () => {
+ expect(SEARCH_CATEGORIES).toEqual(['deliveries', 'drivers', 'transactions']);
+ });
+
+ it('requests the search endpoint with the trimmed query', async () => {
+ mockGet.mockResolvedValue({ data: { results: [] } });
+
+ await globalSearchService.search(' TRK001 ');
+
+ expect(mockGet).toHaveBeenCalledWith('/search', {
+ params: { q: 'TRK001' },
+ signal: undefined,
+ });
+ });
+
+ it('short-circuits an empty query without calling the API', async () => {
+ await expect(globalSearchService.search(' ')).resolves.toEqual([]);
+
+ expect(mockGet).not.toHaveBeenCalled();
+ });
+
+ it('normalises results from a wrapped response', async () => {
+ mockGet.mockResolvedValue({
+ data: {
+ results: [
+ { id: 'd1', category: 'deliveries', title: 'TRK001', subtitle: 'In transit' },
+ { id: 'v1', category: 'drivers', title: 'Ada Obi' },
+ ],
+ },
+ });
+
+ await expect(globalSearchService.search('a')).resolves.toEqual([
+ {
+ id: 'd1',
+ category: 'deliveries',
+ title: 'TRK001',
+ subtitle: 'In transit',
+ href: '/deliveries/d1',
+ },
+ {
+ id: 'v1',
+ category: 'drivers',
+ title: 'Ada Obi',
+ subtitle: undefined,
+ href: '/fleet/drivers/v1',
+ },
+ ]);
+ });
+
+ it('accepts a bare array response', async () => {
+ mockGet.mockResolvedValue({
+ data: [{ id: 't1', category: 'transactions', title: '0xabc' }],
+ });
+
+ const results = await globalSearchService.search('0x');
+
+ expect(results).toHaveLength(1);
+ expect(results[0].href).toBe('/transactions/t1');
+ });
+
+ it('keeps an explicit href supplied by the API', async () => {
+ mockGet.mockResolvedValue({
+ data: { results: [{ id: 'd1', category: 'deliveries', title: 'TRK001', href: '/custom/d1' }] },
+ });
+
+ const [result] = await globalSearchService.search('trk');
+
+ expect(result.href).toBe('/custom/d1');
+ });
+
+ it('falls back to the id when a record carries no title', async () => {
+ mockGet.mockResolvedValue({
+ data: { results: [{ id: 'd1', category: 'deliveries' }] },
+ });
+
+ const [result] = await globalSearchService.search('d');
+
+ expect(result.title).toBe('d1');
+ });
+
+ it('drops malformed records instead of rendering broken rows', async () => {
+ mockGet.mockResolvedValue({
+ data: {
+ results: [
+ null,
+ 'not-an-object',
+ { category: 'deliveries', title: 'missing id' },
+ { id: 'x1', category: 'unknown-category', title: 'bad category' },
+ { id: 'd1', category: 'deliveries', title: 'TRK001' },
+ ],
+ },
+ });
+
+ const results = await globalSearchService.search('a');
+
+ expect(results).toHaveLength(1);
+ expect(results[0].id).toBe('d1');
+ });
+
+ it('returns an empty list when the payload has no results field', async () => {
+ mockGet.mockResolvedValue({ data: {} });
+
+ await expect(globalSearchService.search('a')).resolves.toEqual([]);
+ });
+
+ it('returns an empty list when results is not an array', async () => {
+ mockGet.mockResolvedValue({ data: { results: 'nope' } });
+
+ await expect(globalSearchService.search('a')).resolves.toEqual([]);
+ });
+
+ it('propagates transport errors to the caller', async () => {
+ mockGet.mockRejectedValue(new Error('Network Error'));
+
+ await expect(globalSearchService.search('a')).rejects.toThrow('Network Error');
+ });
+
+ it('forwards an abort signal to the transport', async () => {
+ mockGet.mockResolvedValue({ data: { results: [] } });
+ const controller = new AbortController();
+
+ await globalSearchService.search('a', controller.signal);
+
+ expect(mockGet).toHaveBeenCalledWith('/search', {
+ params: { q: 'a' },
+ signal: controller.signal,
+ });
+ });
+});
diff --git a/services/globalSearchService.ts b/services/globalSearchService.ts
new file mode 100644
index 0000000..8e3c241
--- /dev/null
+++ b/services/globalSearchService.ts
@@ -0,0 +1,87 @@
+import api from '@/lib/api';
+
+/** The categories the unified search groups its results into. */
+export type SearchCategory = 'deliveries' | 'drivers' | 'transactions';
+
+export const SEARCH_CATEGORIES: SearchCategory[] = ['deliveries', 'drivers', 'transactions'];
+
+export const SEARCH_CATEGORY_LABELS: Record = {
+ deliveries: 'Deliveries',
+ drivers: 'Drivers',
+ transactions: 'Transactions',
+};
+
+export interface SearchResult {
+ id: string;
+ category: SearchCategory;
+ /** Primary line — a tracking number, a driver name, a transaction hash. */
+ title: string;
+ /** Secondary line — status, region, amount. */
+ subtitle?: string;
+ /** In-app route the result navigates to when selected. */
+ href: string;
+}
+
+interface RawSearchResponse {
+ results?: unknown;
+}
+
+const ROUTE_BY_CATEGORY: Record = {
+ deliveries: '/deliveries',
+ drivers: '/fleet/drivers',
+ transactions: '/transactions',
+};
+
+function isSearchCategory(value: unknown): value is SearchCategory {
+ return typeof value === 'string' && (SEARCH_CATEGORIES as string[]).includes(value);
+}
+
+/**
+ * Normalises one raw record from the search endpoint.
+ * Records without an id or an unrecognised category are dropped rather than
+ * rendered as a broken row.
+ */
+function toSearchResult(raw: unknown): SearchResult | null {
+ if (typeof raw !== 'object' || raw === null) return null;
+ const record = raw as Record;
+
+ const id = typeof record.id === 'string' ? record.id : null;
+ if (!id) return null;
+ if (!isSearchCategory(record.category)) return null;
+
+ const category = record.category;
+
+ return {
+ id,
+ category,
+ title: typeof record.title === 'string' && record.title ? record.title : id,
+ subtitle: typeof record.subtitle === 'string' ? record.subtitle : undefined,
+ href:
+ typeof record.href === 'string' && record.href
+ ? record.href
+ : `${ROUTE_BY_CATEGORY[category]}/${id}`,
+ };
+}
+
+/**
+ * globalSearchService — talks to the unified search endpoint that spans
+ * deliveries, drivers and transactions.
+ */
+export const globalSearchService = {
+ async search(query: string, signal?: AbortSignal): Promise {
+ const trimmed = query.trim();
+ if (!trimmed) return [];
+
+ const { data } = await api.get('/search', {
+ params: { q: trimmed },
+ signal,
+ });
+
+ const rawResults = Array.isArray(data) ? data : ((data as RawSearchResponse)?.results ?? []);
+ if (!Array.isArray(rawResults)) return [];
+
+ return rawResults
+ .map(toSearchResult)
+ .filter((result): result is SearchResult => result !== null);
+ },
+};