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 ( +
+
+
+ + {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); + }, +};