diff --git a/__tests__/e2e/global-search-debounce.test.tsx b/__tests__/e2e/global-search-debounce.test.tsx
new file mode 100644
index 0000000..6e70ed3
--- /dev/null
+++ b/__tests__/e2e/global-search-debounce.test.tsx
@@ -0,0 +1,235 @@
+import React from 'react';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { GlobalSearch } from '@/components/search/GlobalSearch';
+import { GLOBAL_SEARCH_DEBOUNCE_MS } from '@/hooks/useGlobalSearch';
+import api from '@/lib/api';
+
+/**
+ * E2E: Global Unified Search and Debounce Logic
+ *
+ * Exercises the full search stack (component → hook → service) with the
+ * HTTP client, wallet, and WebSocket layers mocked so the suite stays
+ * deterministic.
+ */
+
+jest.mock('@/lib/api', () => ({
+ __esModule: true,
+ default: { get: jest.fn() },
+}));
+
+jest.mock('@/hooks/useWallet', () => ({
+ useWallet: () => ({
+ connect: jest.fn().mockResolvedValue(true),
+ address: 'GABCD...MOCK_WALLET_ADDRESS',
+ isConnected: true,
+ signTransaction: jest.fn().mockResolvedValue('mock_signed_xdr'),
+ }),
+}));
+
+jest.mock('socket.io-client', () => ({
+ io: jest.fn(() => ({
+ on: jest.fn(),
+ off: jest.fn(),
+ emit: jest.fn(),
+ disconnect: jest.fn(),
+ })),
+}));
+
+const mockGet = api.get as jest.Mock;
+
+const MIXED_RESULTS = [
+ { id: 't1', category: 'transactions', title: '0xabc123', subtitle: '250 XLM' },
+ { id: 'd1', category: 'deliveries', title: 'TRK001', subtitle: 'In transit' },
+ { id: 'v1', category: 'drivers', title: 'Ada Obi', subtitle: 'Lagos' },
+ { id: 'd2', category: 'deliveries', title: 'TRK002', subtitle: 'Pending' },
+];
+
+function delay(ms: number) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+function typeIntoSearch(value: string) {
+ fireEvent.change(screen.getByRole('searchbox', { name: 'Global search' }), {
+ target: { value },
+ });
+}
+
+describe('E2E: Global Unified Search and Debounce Logic', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGet.mockResolvedValue({ data: { results: [] } });
+ });
+
+ it('renders the global search bar without querying the API on idle', () => {
+ render();
+
+ expect(screen.getByRole('searchbox', { name: 'Global search' })).toBeInTheDocument();
+ expect(mockGet).not.toHaveBeenCalled();
+ expect(screen.queryByText('Searching...')).not.toBeInTheDocument();
+ });
+
+ it('does not fire the API while the user is still typing', () => {
+ render();
+
+ typeIntoSearch('lagos');
+
+ expect(mockGet).not.toHaveBeenCalled();
+ expect(screen.getByText('Searching...')).toBeInTheDocument();
+ });
+
+ it('fires a single API call with the final query after the debounce window', async () => {
+ mockGet.mockResolvedValue({ data: { results: MIXED_RESULTS } });
+ render();
+
+ typeIntoSearch('lagos');
+ expect(mockGet).not.toHaveBeenCalled();
+
+ await waitFor(
+ () => {
+ expect(mockGet).toHaveBeenCalledTimes(1);
+ },
+ { timeout: GLOBAL_SEARCH_DEBOUNCE_MS + 500 },
+ );
+ expect(mockGet).toHaveBeenCalledWith('/search', {
+ params: { q: 'lagos' },
+ signal: expect.any(AbortSignal),
+ });
+ });
+
+ it('resets the debounce timer on each keystroke so intermediate queries never hit the API', async () => {
+ mockGet.mockResolvedValue({ data: { results: MIXED_RESULTS } });
+ render();
+
+ typeIntoSearch('l');
+ await act(async () => {
+ await delay(80);
+ });
+ typeIntoSearch('la');
+ await act(async () => {
+ await delay(80);
+ });
+ typeIntoSearch('lag');
+
+ expect(mockGet).not.toHaveBeenCalled();
+
+ await waitFor(
+ () => {
+ expect(mockGet).toHaveBeenCalledTimes(1);
+ },
+ { timeout: GLOBAL_SEARCH_DEBOUNCE_MS + 500 },
+ );
+ expect(mockGet).toHaveBeenCalledWith('/search', {
+ params: { q: 'lag' },
+ signal: expect.any(AbortSignal),
+ });
+ });
+
+ it('shows grouped results after a successful debounced search', async () => {
+ mockGet.mockResolvedValue({ data: { results: MIXED_RESULTS } });
+ render();
+
+ typeIntoSearch('a');
+
+ expect(await screen.findByRole('heading', { name: 'Deliveries' })).toBeInTheDocument();
+ expect(screen.getAllByRole('heading').map((heading) => heading.textContent)).toEqual([
+ 'Deliveries',
+ 'Drivers',
+ 'Transactions',
+ ]);
+
+ const deliveries = screen.getByRole('region', { name: 'Deliveries' });
+ expect(within(deliveries).getByText('TRK001')).toBeInTheDocument();
+ expect(within(deliveries).getByText('TRK002')).toBeInTheDocument();
+ expect(screen.getByText('Ada Obi')).toBeInTheDocument();
+ expect(screen.getByText('0xabc123')).toBeInTheDocument();
+ });
+
+ it('shows the empty state when the API returns no matches', async () => {
+ mockGet.mockResolvedValue({ data: { results: [] } });
+ render();
+
+ typeIntoSearch('zzz');
+
+ expect(await screen.findByText('No results found for "zzz".')).toBeInTheDocument();
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ });
+
+ it('shows an error when the search API fails after debounce', async () => {
+ mockGet.mockRejectedValue(new Error('Search service unavailable'));
+ render();
+
+ typeIntoSearch('trk');
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Search service unavailable');
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ });
+
+ it('does not query the API for whitespace-only input', async () => {
+ render();
+
+ typeIntoSearch(' ');
+ await act(async () => {
+ await delay(GLOBAL_SEARCH_DEBOUNCE_MS + 50);
+ });
+
+ expect(mockGet).not.toHaveBeenCalled();
+ expect(screen.queryByText(/No results found/)).not.toBeInTheDocument();
+ });
+
+ it('reports the selected result to the caller', async () => {
+ const onSelect = jest.fn();
+ mockGet.mockResolvedValue({ data: { results: MIXED_RESULTS } });
+ render();
+
+ typeIntoSearch('ada');
+ fireEvent.click(await screen.findByRole('button', { name: /Ada Obi/ }));
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'v1',
+ category: 'drivers',
+ title: 'Ada Obi',
+ href: '/fleet/drivers/v1',
+ }),
+ );
+ });
+
+ it('clears the query and cancels a pending debounce so the API is never called', async () => {
+ render();
+ const input = screen.getByRole('searchbox', { name: 'Global search' });
+
+ typeIntoSearch('lagos');
+ fireEvent.click(screen.getByRole('button', { name: 'Clear search' }));
+ await act(async () => {
+ await delay(GLOBAL_SEARCH_DEBOUNCE_MS + 50);
+ });
+
+ expect(input).toHaveValue('');
+ expect(mockGet).not.toHaveBeenCalled();
+ expect(screen.queryByText('Searching...')).not.toBeInTheDocument();
+ });
+
+ it('issues a follow-up request with the latest query after a second debounce', async () => {
+ mockGet.mockResolvedValueOnce({ data: { results: MIXED_RESULTS } });
+ mockGet.mockResolvedValueOnce({
+ data: { results: [MIXED_RESULTS[2]] },
+ });
+ render();
+
+ typeIntoSearch('a');
+ expect(await screen.findByRole('heading', { name: 'Deliveries' })).toBeInTheDocument();
+ expect(mockGet).toHaveBeenCalledTimes(1);
+
+ typeIntoSearch('ada');
+ await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2));
+ expect(mockGet).toHaveBeenLastCalledWith('/search', {
+ params: { q: 'ada' },
+ signal: expect.any(AbortSignal),
+ });
+ await waitFor(() => expect(screen.queryByText('TRK001')).not.toBeInTheDocument());
+ expect(screen.getByText('Ada Obi')).toBeInTheDocument();
+ });
+});
diff --git a/app/api/federation/resolve/route.ts b/app/api/federation/resolve/route.ts
index 3e35b6c..ae30627 100644
--- a/app/api/federation/resolve/route.ts
+++ b/app/api/federation/resolve/route.ts
@@ -25,7 +25,7 @@ export async function GET(req: Request) {
const m = toml.match(/FEDERATION_SERVER\s*=\s*"(.*?)"/i);
if (m && m[1]) federationServer = m[1];
}
- } catch (e) {
+ } catch {
// ignore discovery errors and fallback
}
diff --git a/app/page.tsx b/app/page.tsx
index af2d3e9..a9a53ed 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -2,7 +2,7 @@ import { WorkflowCards } from '@/components/deliveries/WorkflowCards';
import { ValueProps } from '@/components/landing/ValueProps';
import { CallToAction } from '@/components/landing/CallToAction';
import { TrustBar } from '@/components/landing/TrustBar';
-import { PricingCards } from '@/components/pricing/PricingCards';
+import { KineticExplorer } from '@/components/landing/KineticExplorer';
export default function Home() {
return (
diff --git a/components/mobile/MobileFooter.tsx b/components/mobile/MobileFooter.tsx
index 303e290..1ba6230 100644
--- a/components/mobile/MobileFooter.tsx
+++ b/components/mobile/MobileFooter.tsx
@@ -1,49 +1,45 @@
-`use client
+'use client';
-import React from react
-import Link from next/link
+import React from 'react';
+import Link from 'next/link';
-// Navigation links data
const footerLinks = [
- { label: Home, href: / },
- { label: About, href: /about },
- { label: Services, href: /services },
- { label: Support, href: /support },
- { label: Privacy Policy, href: /privacy },
- { label: Terms of Service, href: /terms },
- { label: Contact, href: /contact },
- { label: FAQs, href: /faq },
-]
+ { label: 'Home', href: '/' },
+ { label: 'About', href: '/about' },
+ { label: 'Services', href: '/services' },
+ { label: 'Support', href: '/support' },
+ { label: 'Privacy Policy', href: '/privacy' },
+ { label: 'Terms of Service', href: '/terms' },
+ { label: 'Contact', href: '/contact' },
+ { label: 'FAQs', href: '/faq' },
+];
-// Social icons data
const socialLinks = [
- { label: Twitter, href: https://twitter.com/swiftchain, icon: 🐦 },
- { label: GitHub, href: https://github.com/swiftchain, icon: 🐙 },
- { label: LinkedIn, href: https://linkedin.com/company/swiftchain, icon: 🔗 },
- { label: Discord, href: https://discord.gg/swiftchain, icon: 💬 },
-]
+ { label: 'Twitter', href: 'https://twitter.com/swiftchain', icon: '🐦' },
+ { label: 'GitHub', href: 'https://github.com/swiftchain', icon: '🐙' },
+ { label: 'LinkedIn', href: 'https://linkedin.com/company/swiftchain', icon: '🔗' },
+ { label: 'Discord', href: 'https://discord.gg/swiftchain', icon: '💬' },
+];
export function MobileFooter() {
- const currentYear = new Date().getFullYear()
+ const currentYear = new Date().getFullYear();
return (
- )
+ );
}
-export default MobileFooter
-
+export default MobileFooter;
diff --git a/components/search/GlobalSearch.tsx b/components/search/GlobalSearch.tsx
new file mode 100644
index 0000000..ee37630
--- /dev/null
+++ b/components/search/GlobalSearch.tsx
@@ -0,0 +1,123 @@
+'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 — site-wide search across deliveries, drivers and transactions.
+ *
+ * The input updates immediately; the hook debounces the query so the API is
+ * only called after the user pauses typing.
+ */
+export function GlobalSearch({ onSelect }: GlobalSearchProps) {
+ const {
+ query,
+ setQuery,
+ groups,
+ totalResults,
+ isLoading,
+ isDebouncing,
+ error,
+ isEmpty,
+ clear,
+ } = useGlobalSearch();
+
+ return (
+
+
+
+ setQuery(event.target.value)}
+ placeholder="Search deliveries, drivers, transactions..."
+ aria-label="Global search"
+ aria-busy={isLoading}
+ autoComplete="off"
+ className="w-full rounded-2xl border border-slate-200 bg-slate-50 py-3 pl-10 pr-10 text-sm outline-none transition focus:border-sky-500 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:focus:border-sky-400"
+ />
+ {query ? (
+
+ ) : null}
+
+
+ {query.trim() ? (
+
+ {isDebouncing || isLoading ? (
+
+ Searching...
+
+ ) : null}
+
+ {!isLoading && error ? (
+
+ {error}
+
+ ) : null}
+
+ {!isLoading && !error && isEmpty ? (
+
+ No results found for "{query.trim()}".
+
+ ) : null}
+
+ {!isLoading && !error && totalResults > 0 ? (
+
+
+ {totalResults} result{totalResults === 1 ? '' : 's'} found
+
+ {groups.map((group) => (
+
+
+ {group.label}
+
+
+ {group.results.map((result) => (
+ -
+
+
+ ))}
+
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+export default GlobalSearch;
diff --git a/hooks/__tests__/useGlobalSearch.test.ts b/hooks/__tests__/useGlobalSearch.test.ts
new file mode 100644
index 0000000..ca6deb3
--- /dev/null
+++ b/hooks/__tests__/useGlobalSearch.test.ts
@@ -0,0 +1,294 @@
+import { act, renderHook } from '@testing-library/react';
+import {
+ GLOBAL_SEARCH_DEBOUNCE_MS,
+ 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' }),
+];
+
+function delay(ms: number) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+async function settleSearch() {
+ await act(async () => {
+ await delay(GLOBAL_SEARCH_DEBOUNCE_MS + 20);
+ });
+}
+
+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.isDebouncing).toBe(false);
+ expect(hook.current.isEmpty).toBe(false);
+ expect(mockSearch).not.toHaveBeenCalled();
+ });
+
+ it('does not call the API until the debounce window elapses', async () => {
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('lagos'));
+ expect(hook.current.isDebouncing).toBe(true);
+ expect(mockSearch).not.toHaveBeenCalled();
+
+ await act(async () => {
+ await delay(GLOBAL_SEARCH_DEBOUNCE_MS - 50);
+ });
+ expect(mockSearch).not.toHaveBeenCalled();
+
+ await settleSearch();
+ expect(mockSearch).toHaveBeenCalledTimes(1);
+ expect(mockSearch).toHaveBeenCalledWith('lagos', expect.any(AbortSignal));
+ });
+
+ it('collapses rapid query updates into a single API call with the final value', async () => {
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('l'));
+ await act(async () => {
+ await delay(80);
+ });
+ act(() => hook.current.setQuery('la'));
+ await act(async () => {
+ await delay(80);
+ });
+ act(() => hook.current.setQuery('lagos'));
+
+ expect(mockSearch).not.toHaveBeenCalled();
+
+ await settleSearch();
+ expect(mockSearch).toHaveBeenCalledTimes(1);
+ expect(mockSearch).toHaveBeenCalledWith('lagos', expect.any(AbortSignal));
+ });
+
+ it('does not search for a whitespace-only query', async () => {
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery(' '));
+ await settleSearch();
+
+ expect(mockSearch).not.toHaveBeenCalled();
+ expect(hook.current.isEmpty).toBe(false);
+ expect(hook.current.isDebouncing).toBe(false);
+ });
+
+ 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 settleSearch();
+
+ 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 settleSearch();
+
+ 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 settleSearch();
+ 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 settleSearch();
+
+ expect(hook.current.isEmpty).toBe(true);
+ expect(hook.current.groups).toEqual([]);
+ expect(hook.current.error).toBeNull();
+ });
+
+ 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 settleSearch();
+ expect(hook.current.totalResults).toBe(4);
+
+ mockSearch.mockRejectedValueOnce(new Error('Search service unavailable'));
+ act(() => hook.current.setQuery('ab'));
+ await settleSearch();
+
+ 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 settleSearch();
+
+ 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 settleSearch();
+ expect(hook.current.error).toBe('Search service unavailable');
+
+ mockSearch.mockResolvedValueOnce([
+ result({ id: 'd1', category: 'deliveries', title: 'TRK001' }),
+ ]);
+ act(() => hook.current.setQuery('trk'));
+ await settleSearch();
+
+ expect(hook.current.totalResults).toBe(1);
+ expect(hook.current.error).toBeNull();
+ });
+
+ it('aborts the previous request when a newer debounced query is ready', async () => {
+ mockSearch.mockResolvedValue([]);
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('a'));
+ await settleSearch();
+ expect(mockSearch).toHaveBeenCalledTimes(1);
+
+ const firstSignal = mockSearch.mock.calls[0][1] as AbortSignal;
+ act(() => hook.current.setQuery('ab'));
+ await settleSearch();
+
+ 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'));
+ await settleSearch();
+ act(() => hook.current.setQuery('ab'));
+ await settleSearch();
+
+ 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 settleSearch();
+ expect(hook.current.totalResults).toBe(4);
+
+ act(() => hook.current.setQuery(''));
+ await settleSearch();
+
+ 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, cancelling a pending debounce', async () => {
+ mockSearch.mockResolvedValue(MIXED_RESULTS);
+ const { result: hook } = renderHook(() => useGlobalSearch());
+
+ act(() => hook.current.setQuery('lagos'));
+ act(() => hook.current.clear());
+ await settleSearch();
+
+ expect(hook.current.query).toBe('');
+ expect(hook.current.groups).toEqual([]);
+ expect(hook.current.isLoading).toBe(false);
+ expect(hook.current.error).toBeNull();
+ expect(mockSearch).not.toHaveBeenCalled();
+ });
+});
diff --git a/hooks/useDriverReputation.ts b/hooks/useDriverReputation.ts
index e70e2d1..ca97fdb 100644
--- a/hooks/useDriverReputation.ts
+++ b/hooks/useDriverReputation.ts
@@ -10,6 +10,18 @@ export interface UseDriverReputationResult {
error: string | null;
}
+interface ReputationSnapshot {
+ driverId: string;
+ onChainScore: number | null;
+ error: string | null;
+}
+
+const EMPTY_SNAPSHOT: ReputationSnapshot = {
+ driverId: '',
+ onChainScore: null,
+ error: null,
+};
+
/**
* useDriverReputation — fetches a driver's on-chain reputation token score.
*
@@ -18,28 +30,21 @@ export interface UseDriverReputationResult {
* state on an unmounted component.
*/
export function useDriverReputation(driverId: string): UseDriverReputationResult {
- const [onChainScore, setOnChainScore] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
+ const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT);
useEffect(() => {
if (!driverId) {
- setOnChainScore(null);
- setIsLoading(false);
return;
}
const controller = new AbortController();
let cancelled = false;
- setIsLoading(true);
-
reputationService
.getDriverReputation(driverId, controller.signal)
.then((data) => {
if (cancelled) return;
- setOnChainScore(data.onChainScore);
- setError(null);
+ setSnapshot({ driverId, onChainScore: data.onChainScore, error: null });
})
.catch((err: unknown) => {
if (cancelled) return;
@@ -48,10 +53,7 @@ export function useDriverReputation(driverId: string): UseDriverReputationResult
err instanceof Error && err.message
? err.message
: 'Failed to load on-chain reputation';
- setError(message);
- })
- .finally(() => {
- if (!cancelled) setIsLoading(false);
+ setSnapshot({ driverId, onChainScore: null, error: message });
});
return () => {
@@ -60,5 +62,15 @@ export function useDriverReputation(driverId: string): UseDriverReputationResult
};
}, [driverId]);
- return { onChainScore, isLoading, error };
+ if (!driverId) {
+ return { onChainScore: null, isLoading: false, error: null };
+ }
+
+ const isSettled = snapshot.driverId === driverId;
+
+ return {
+ onChainScore: isSettled ? snapshot.onChainScore : null,
+ isLoading: !isSettled,
+ error: isSettled ? snapshot.error : null,
+ };
}
diff --git a/hooks/useGlobalSearch.ts b/hooks/useGlobalSearch.ts
new file mode 100644
index 0000000..9ba4b4b
--- /dev/null
+++ b/hooks/useGlobalSearch.ts
@@ -0,0 +1,158 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ globalSearchService,
+ SEARCH_CATEGORIES,
+ SEARCH_CATEGORY_LABELS,
+ type SearchCategory,
+ type SearchResult,
+} from '@/services/globalSearchService';
+
+/** Delay after the last keystroke before the unified search API is called. */
+export const GLOBAL_SEARCH_DEBOUNCE_MS = 300;
+
+export interface SearchResultGroup {
+ category: SearchCategory;
+ label: string;
+ results: SearchResult[];
+}
+
+export interface UseGlobalSearchResult {
+ query: string;
+ setQuery: (_query: string) => void;
+ /** Non-empty categories, always in Deliveries / Drivers / Transactions order. */
+ groups: SearchResultGroup[];
+ results: SearchResult[];
+ totalResults: number;
+ isLoading: boolean;
+ /** True while keystrokes are still settling and no API call has been made yet. */
+ isDebouncing: boolean;
+ error: string | null;
+ /** True once a search has run for a non-empty query and returned nothing. */
+ isEmpty: boolean;
+ clear: () => void;
+}
+
+interface SearchSnapshot {
+ query: string;
+ results: SearchResult[];
+ error: string | null;
+}
+
+const EMPTY_SNAPSHOT: SearchSnapshot = { query: '', results: [], error: null };
+
+function isAbortError(error: unknown): boolean {
+ if (error instanceof DOMException && error.name === 'AbortError') return true;
+ if (error instanceof Error && error.name === 'AbortError') return true;
+ if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ (error as { code?: string }).code === 'ERR_CANCELED'
+ ) {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * useGlobalSearch — debounces the query, then searches across deliveries,
+ * drivers and transactions.
+ *
+ * Rapid keystrokes reset the timer so the API is only called once the user
+ * pauses. In-flight requests are aborted as soon as a newer debounced query
+ * is ready, so a slow response can never overwrite a newer one.
+ */
+export function useGlobalSearch(): UseGlobalSearchResult {
+ const [query, setQueryState] = useState('');
+ const [debouncedQuery, setDebouncedQuery] = useState('');
+ const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT);
+
+ const setQuery = useCallback((next: string) => {
+ setQueryState(next);
+ if (next.trim() === '') {
+ setDebouncedQuery('');
+ setSnapshot(EMPTY_SNAPSHOT);
+ }
+ }, []);
+
+ const trimmedQuery = query.trim();
+
+ useEffect(() => {
+ if (!trimmedQuery) {
+ return;
+ }
+
+ const timer = window.setTimeout(() => {
+ setDebouncedQuery(trimmedQuery);
+ }, GLOBAL_SEARCH_DEBOUNCE_MS);
+
+ return () => window.clearTimeout(timer);
+ }, [trimmedQuery]);
+
+ useEffect(() => {
+ if (!debouncedQuery) {
+ return;
+ }
+
+ const controller = new AbortController();
+
+ globalSearchService
+ .search(debouncedQuery, controller.signal)
+ .then((results) => {
+ if (controller.signal.aborted) return;
+ setSnapshot({ query: debouncedQuery, results, error: null });
+ })
+ .catch((searchError: unknown) => {
+ if (controller.signal.aborted || isAbortError(searchError)) return;
+ setSnapshot({
+ query: debouncedQuery,
+ results: [],
+ error:
+ searchError instanceof Error
+ ? searchError.message
+ : 'Unable to complete the search',
+ });
+ });
+
+ return () => controller.abort();
+ }, [debouncedQuery]);
+
+ const isDebouncing = trimmedQuery !== '' && trimmedQuery !== debouncedQuery;
+ 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(() => {
+ setQueryState('');
+ setDebouncedQuery('');
+ setSnapshot(EMPTY_SNAPSHOT);
+ }, []);
+
+ return {
+ query,
+ setQuery,
+ groups,
+ results,
+ totalResults: results.length,
+ isLoading: trimmedQuery !== '' && !isSettled,
+ isDebouncing,
+ error,
+ isEmpty: isSettled && error === null && results.length === 0,
+ clear,
+ };
+}
diff --git a/hooks/useTheme.ts b/hooks/useTheme.ts
index aef9d28..9af4f03 100644
--- a/hooks/useTheme.ts
+++ b/hooks/useTheme.ts
@@ -1,11 +1,23 @@
// hooks/useTheme.ts
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import { ThemeService, Theme } from '@/services/themeService';
export const useTheme = (userId?: string) => {
const [theme, setThemeState] = useState('system');
+ const updateTheme = useCallback(
+ (newTheme: Theme) => {
+ setThemeState(newTheme);
+ ThemeService.setThemeToStorage(newTheme);
+
+ if (userId) {
+ ThemeService.syncThemeToAPI(userId, newTheme).catch(console.error);
+ }
+ },
+ [userId],
+ );
+
useEffect(() => {
const initializeTheme = async () => {
// 1. Sync from local storage immediately to match the blocking script
@@ -27,7 +39,7 @@ export const useTheme = (userId?: string) => {
}
};
initializeTheme();
- }, [userId]);
+ }, [userId, updateTheme]);
// Apply theme to DOM when state changes
useEffect(() => {
@@ -60,14 +72,5 @@ export const useTheme = (userId?: string) => {
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
- const updateTheme = (newTheme: Theme) => {
- setThemeState(newTheme);
- ThemeService.setThemeToStorage(newTheme);
-
- if (userId) {
- ThemeService.syncThemeToAPI(userId, newTheme).catch(console.error);
- }
- };
-
return { theme, setTheme: updateTheme };
};
\ No newline at end of file
diff --git a/services/__tests__/globalSearchService.test.ts b/services/__tests__/globalSearchService.test.ts
new file mode 100644
index 0000000..e1c2458
--- /dev/null
+++ b/services/__tests__/globalSearchService.test.ts
@@ -0,0 +1,146 @@
+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..efff71f
--- /dev/null
+++ b/services/globalSearchService.ts
@@ -0,0 +1,92 @@
+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 with an unrecognised category are dropped.
+ */
+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);
+ },
+};