diff --git a/app/(dashboard)/driver/jobs/page.tsx b/app/(dashboard)/driver/jobs/page.tsx index 19840af..059eea4 100644 --- a/app/(dashboard)/driver/jobs/page.tsx +++ b/app/(dashboard)/driver/jobs/page.tsx @@ -5,6 +5,8 @@ import { Briefcase, RefreshCw } from 'lucide-react'; import { useDriverJobs } from '@/hooks/useDriverJobs'; import { JobCard } from '@/features/driver/components/JobCard'; import { AcceptJobModal } from '@/features/driver/components/AcceptJobModal'; +import { JobFilters } from '@/features/driver/components/JobFilters'; +import { useJobFilters } from '@/hooks/useJobFilters'; import { DeliveryJob } from '@/services/driverJobService'; /** @@ -17,6 +19,17 @@ export default function DriverJobBoardPage() { const { jobs, isLoading, isAccepting, error, refreshJobs, acceptJob } = useDriverJobs(); + const { + filters, + filteredJobs, + hasActiveFilters, + availableLocations, + setQuery, + setLocation, + setCargoType, + resetFilters, + } = useJobFilters(jobs); + // The job currently being confirmed — null means modal is closed. const [pendingJob, setPendingJob] = useState(null); @@ -64,6 +77,22 @@ export default function DriverJobBoardPage() { + {/* Advanced search filters */} + {!isLoading && !error && jobs.length > 0 && ( +
+ +
+ )} + {/* Stats bar */} {!isLoading && !error && (

@@ -114,10 +143,27 @@ export default function DriverJobBoardPage() { )} + {/* No results for the current filters */} + {!isLoading && !error && jobs.length > 0 && filteredJobs.length === 0 && ( +

+ +

+ No jobs match your filters +

+ +
+ )} + {/* Job grid */} - {!isLoading && !error && jobs.length > 0 && ( + {!isLoading && !error && filteredJobs.length > 0 && (
- {jobs.map((job) => ( + {filteredJobs.map((job) => ( void; + onLocationChange: (location: string) => void; + onCargoTypeChange: (cargoType: CargoType | '') => void; + onResetFilters: () => void; +} + +const CARGO_TYPE_LABELS: Record = { + general: 'General', + fragile: 'Fragile', + perishable: 'Perishable', + hazardous: 'Hazardous', + oversized: 'Oversized', + refrigerated: 'Refrigerated', +}; + +/** + * JobFilters — advanced search controls for the driver job marketplace. + * + * Presentational only: every value is owned by `useJobFilters`, so the control + * strip stays in lock-step with the filtered list rendered beside it. + */ +export function JobFilters({ + filters, + hasActiveFilters, + availableLocations, + resultCount, + onQueryChange, + onLocationChange, + onCargoTypeChange, + onResetFilters, +}: JobFiltersProps) { + return ( +
+
+
+ +
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + {hasActiveFilters && ( +
+ + {resultCount} matching job{resultCount === 1 ? '' : 's'} + + +
+ )} +
+ ); +} diff --git a/features/driver/components/__tests__/JobFilters.test.tsx b/features/driver/components/__tests__/JobFilters.test.tsx new file mode 100644 index 0000000..0373200 --- /dev/null +++ b/features/driver/components/__tests__/JobFilters.test.tsx @@ -0,0 +1,256 @@ +import React, { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { JobFilters } from '@/features/driver/components/JobFilters'; +import { useJobFilters, EMPTY_JOB_FILTERS, type JobFilterState } from '@/hooks/useJobFilters'; +import type { DeliveryJob } from '@/services/driverJobService'; + +const handlers = { + onQueryChange: jest.fn(), + onLocationChange: jest.fn(), + onCargoTypeChange: jest.fn(), + onResetFilters: jest.fn(), +}; + +function renderFilters(overrides: Partial> = {}) { + return render( + , + ); +} + +describe('JobFilters', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the keyword, location and cargo type controls', () => { + renderFilters(); + + expect(screen.getByRole('combobox', { name: 'Location' })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Cargo type' })).toBeInTheDocument(); + expect(screen.getByLabelText('Keyword')).toBeInTheDocument(); + }); + + it('lists the supplied locations alongside an "all locations" option', () => { + renderFilters(); + + const options = screen.getAllByRole('option').map((option) => option.textContent); + expect(options).toEqual( + expect.arrayContaining(['All locations', 'Abuja', 'Lagos', 'All cargo types', 'Fragile']), + ); + }); + + it('renders a human-readable label for every cargo type', () => { + renderFilters(); + + const cargoSelect = screen.getByRole('combobox', { name: 'Cargo type' }); + const labels = Array.from(cargoSelect.querySelectorAll('option')).map( + (option) => option.textContent, + ); + expect(labels).toEqual([ + 'All cargo types', + 'General', + 'Fragile', + 'Perishable', + 'Hazardous', + 'Oversized', + 'Refrigerated', + ]); + }); + + it('renders the current filter values as the selected options', () => { + const filters: JobFilterState = { query: 'crate', location: 'Lagos', cargoType: 'fragile' }; + renderFilters({ filters, hasActiveFilters: true, resultCount: 2 }); + + expect(screen.getByRole('combobox', { name: 'Location' })).toHaveValue('Lagos'); + expect(screen.getByRole('combobox', { name: 'Cargo type' })).toHaveValue('fragile'); + expect(screen.getByLabelText('Keyword')).toHaveValue('crate'); + }); + + it('renders an empty location dropdown gracefully', () => { + renderFilters({ availableLocations: [] }); + + const locationSelect = screen.getByRole('combobox', { name: 'Location' }); + expect(locationSelect.querySelectorAll('option')).toHaveLength(1); + expect(screen.getByRole('option', { name: 'All locations' })).toBeInTheDocument(); + }); + }); + + describe('interaction', () => { + it('reports a location selection', async () => { + const user = userEvent.setup(); + renderFilters(); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Location' }), 'Lagos'); + + expect(handlers.onLocationChange).toHaveBeenCalledWith('Lagos'); + }); + + it('reports a cargo type selection', async () => { + const user = userEvent.setup(); + renderFilters(); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Cargo type' }), 'refrigerated'); + + expect(handlers.onCargoTypeChange).toHaveBeenCalledWith('refrigerated'); + }); + + it('reports clearing a select back to the "all" option', async () => { + const user = userEvent.setup(); + renderFilters({ + filters: { ...EMPTY_JOB_FILTERS, cargoType: 'fragile' }, + hasActiveFilters: true, + }); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Cargo type' }), ''); + + expect(handlers.onCargoTypeChange).toHaveBeenCalledWith(''); + }); + + it('reports each keystroke in the keyword field', async () => { + const user = userEvent.setup(); + renderFilters(); + + await user.type(screen.getByLabelText('Keyword'), 'ab'); + + expect(handlers.onQueryChange).toHaveBeenCalledTimes(2); + expect(handlers.onQueryChange).toHaveBeenLastCalledWith('b'); + }); + }); + + describe('active filter summary', () => { + it('is hidden while no filter is active', () => { + renderFilters({ hasActiveFilters: false }); + + expect(screen.queryByTestId('filter-result-count')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Clear all filters' })).not.toBeInTheDocument(); + }); + + it('shows the match count and pluralises it', () => { + const { rerender } = renderFilters({ hasActiveFilters: true, resultCount: 1 }); + expect(screen.getByTestId('filter-result-count')).toHaveTextContent('1 matching job'); + + rerender( + , + ); + expect(screen.getByTestId('filter-result-count')).toHaveTextContent('3 matching jobs'); + }); + + it('shows a zero count when nothing matches', () => { + renderFilters({ hasActiveFilters: true, resultCount: 0 }); + + expect(screen.getByTestId('filter-result-count')).toHaveTextContent('0 matching jobs'); + }); + + it('reports a request to clear all filters', async () => { + const user = userEvent.setup(); + renderFilters({ hasActiveFilters: true, resultCount: 2 }); + + await user.click(screen.getByRole('button', { name: 'Clear all filters' })); + + expect(handlers.onResetFilters).toHaveBeenCalledTimes(1); + }); + }); + + describe('integration with useJobFilters', () => { + const JOBS: DeliveryJob[] = [ + { + id: '1', + pickupAddress: 'Ikeja Depot', + dropoffAddress: 'Yaba Hub', + packageDescription: 'Office chairs', + estimatedDistance: 10, + estimatedEarnings: 30, + region: 'Lagos', + cargoType: 'general', + createdAt: '2026-02-01T09:00:00.000Z', + status: 'unassigned', + }, + { + id: '2', + pickupAddress: 'Wuse Market', + dropoffAddress: 'Garki Plaza', + packageDescription: 'Glassware crate', + estimatedDistance: 6, + estimatedEarnings: 22, + region: 'Abuja', + cargoType: 'fragile', + createdAt: '2026-02-01T10:00:00.000Z', + status: 'unassigned', + }, + ]; + + function Marketplace() { + const [jobs] = useState(JOBS); + const { + filters, + filteredJobs, + hasActiveFilters, + availableLocations, + setQuery, + setLocation, + setCargoType, + resetFilters, + } = useJobFilters(jobs); + + return ( +
+ +
    + {filteredJobs.map((entry) => ( +
  • {entry.packageDescription}
  • + ))} +
+
+ ); + } + + it('narrows the visible jobs as filters are applied and restores them on reset', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText('Office chairs')).toBeInTheDocument(); + expect(screen.getByText('Glassware crate')).toBeInTheDocument(); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Location' }), 'Abuja'); + + expect(screen.queryByText('Office chairs')).not.toBeInTheDocument(); + expect(screen.getByText('Glassware crate')).toBeInTheDocument(); + expect(screen.getByTestId('filter-result-count')).toHaveTextContent('1 matching job'); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Cargo type' }), 'general'); + + expect(screen.queryByText('Glassware crate')).not.toBeInTheDocument(); + expect(screen.getByTestId('filter-result-count')).toHaveTextContent('0 matching jobs'); + + await user.click(screen.getByRole('button', { name: 'Clear all filters' })); + + expect(screen.getByText('Office chairs')).toBeInTheDocument(); + expect(screen.getByText('Glassware crate')).toBeInTheDocument(); + expect(screen.queryByTestId('filter-result-count')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/hooks/__tests__/useJobFilters.test.ts b/hooks/__tests__/useJobFilters.test.ts new file mode 100644 index 0000000..72c4fd8 --- /dev/null +++ b/hooks/__tests__/useJobFilters.test.ts @@ -0,0 +1,193 @@ +import { act, renderHook } from '@testing-library/react'; +import { useJobFilters, EMPTY_JOB_FILTERS } from '@/hooks/useJobFilters'; +import type { DeliveryJob } from '@/services/driverJobService'; + +const job = (overrides: Partial & { id: string }): DeliveryJob => ({ + pickupAddress: 'Ikeja Depot', + dropoffAddress: 'Yaba Hub', + packageDescription: 'Assorted parcels', + estimatedDistance: 12, + estimatedEarnings: 40, + region: 'Lagos', + cargoType: 'general', + createdAt: '2026-02-01T09:00:00.000Z', + status: 'unassigned', + ...overrides, +}); + +const JOBS: DeliveryJob[] = [ + job({ id: '1', region: 'Lagos', cargoType: 'general', packageDescription: 'Office chairs' }), + job({ id: '2', region: 'Abuja', cargoType: 'fragile', packageDescription: 'Glassware crate' }), + job({ id: '3', region: 'Lagos', cargoType: 'refrigerated', packageDescription: 'Vaccine cooler' }), + job({ id: '4', region: 'Kano', cargoType: 'general', packageDescription: 'Cement bags' }), +]; + +const ids = (jobs: DeliveryJob[]) => jobs.map((entry) => entry.id); + +describe('useJobFilters', () => { + it('starts with no active filters and the full job pool', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + expect(result.current.filters).toEqual(EMPTY_JOB_FILTERS); + expect(result.current.hasActiveFilters).toBe(false); + expect(ids(result.current.filteredJobs)).toEqual(['1', '2', '3', '4']); + }); + + it('derives the available locations from the pool, de-duplicated and sorted', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + expect(result.current.availableLocations).toEqual(['Abuja', 'Kano', 'Lagos']); + }); + + it('derives the available cargo types from the pool, de-duplicated and sorted', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + expect(result.current.availableCargoTypes).toEqual(['fragile', 'general', 'refrigerated']); + }); + + it('filters by location', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + act(() => result.current.setLocation('Lagos')); + + expect(result.current.filters.location).toBe('Lagos'); + expect(result.current.hasActiveFilters).toBe(true); + expect(ids(result.current.filteredJobs)).toEqual(['1', '3']); + }); + + it('matches a location against the pickup and drop-off addresses too', () => { + const { result } = renderHook(() => + useJobFilters([ + job({ id: 'a', region: 'Lagos', dropoffAddress: 'Enugu Terminal' }), + job({ id: 'b', region: 'Lagos', dropoffAddress: 'Yaba Hub' }), + ]), + ); + + act(() => result.current.setLocation('Enugu')); + + expect(ids(result.current.filteredJobs)).toEqual(['a']); + }); + + it('matches locations case-insensitively and ignores surrounding whitespace', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + act(() => result.current.setLocation(' lAgOs ')); + + expect(ids(result.current.filteredJobs)).toEqual(['1', '3']); + }); + + it('filters by cargo type with an exact match', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + act(() => result.current.setCargoType('general')); + + expect(ids(result.current.filteredJobs)).toEqual(['1', '4']); + }); + + it('combines the location and cargo type filters with AND', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + act(() => result.current.setLocation('Lagos')); + act(() => result.current.setCargoType('refrigerated')); + + expect(ids(result.current.filteredJobs)).toEqual(['3']); + }); + + it('filters by keyword across the description and addresses', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + act(() => result.current.setQuery('vaccine')); + + expect(ids(result.current.filteredJobs)).toEqual(['3']); + }); + + it('keeps each filter independent when another one changes', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + act(() => result.current.setLocation('Lagos')); + act(() => result.current.setCargoType('general')); + act(() => result.current.setLocation('Kano')); + + expect(result.current.filters).toEqual({ query: '', location: 'Kano', cargoType: 'general' }); + expect(ids(result.current.filteredJobs)).toEqual(['4']); + }); + + it('treats clearing a filter back to its empty value as a no-op filter', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + act(() => result.current.setCargoType('fragile')); + expect(ids(result.current.filteredJobs)).toEqual(['2']); + + act(() => result.current.setCargoType('')); + + expect(result.current.hasActiveFilters).toBe(false); + expect(ids(result.current.filteredJobs)).toEqual(['1', '2', '3', '4']); + }); + + it('does not treat a whitespace-only keyword as an active filter', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + act(() => result.current.setQuery(' ')); + + expect(result.current.hasActiveFilters).toBe(false); + expect(result.current.filteredJobs).toHaveLength(4); + }); + + it('resets every filter at once', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + act(() => result.current.setQuery('cement')); + act(() => result.current.setLocation('Kano')); + act(() => result.current.setCargoType('general')); + + act(() => result.current.resetFilters()); + + expect(result.current.filters).toEqual(EMPTY_JOB_FILTERS); + expect(result.current.hasActiveFilters).toBe(false); + expect(ids(result.current.filteredJobs)).toEqual(['1', '2', '3', '4']); + }); + + it('returns an empty result set when no job matches', () => { + const { result } = renderHook(() => useJobFilters(JOBS)); + + act(() => result.current.setLocation('Lagos')); + act(() => result.current.setCargoType('hazardous')); + + expect(result.current.filteredJobs).toEqual([]); + expect(result.current.hasActiveFilters).toBe(true); + }); + + it('excludes jobs with no cargo type when a cargo type filter is set', () => { + const { result } = renderHook(() => + useJobFilters([job({ id: 'x', cargoType: undefined }), job({ id: 'y', cargoType: 'fragile' })]), + ); + + act(() => result.current.setCargoType('fragile')); + + expect(ids(result.current.filteredJobs)).toEqual(['y']); + }); + + it('handles an empty job pool without throwing', () => { + const { result } = renderHook(() => useJobFilters([])); + + act(() => result.current.setLocation('Lagos')); + + expect(result.current.filteredJobs).toEqual([]); + expect(result.current.availableLocations).toEqual([]); + expect(result.current.availableCargoTypes).toEqual([]); + }); + + it('keeps the active filters applied when the job pool is refreshed', () => { + const { result, rerender } = renderHook(({ jobs }) => useJobFilters(jobs), { + initialProps: { jobs: JOBS }, + }); + + act(() => result.current.setCargoType('general')); + expect(ids(result.current.filteredJobs)).toEqual(['1', '4']); + + rerender({ jobs: [...JOBS, job({ id: '5', region: 'Jos', cargoType: 'general' })] }); + + expect(result.current.filters.cargoType).toBe('general'); + expect(ids(result.current.filteredJobs)).toEqual(['1', '4', '5']); + }); +}); diff --git a/hooks/useJobFilters.ts b/hooks/useJobFilters.ts new file mode 100644 index 0000000..af831e2 --- /dev/null +++ b/hooks/useJobFilters.ts @@ -0,0 +1,131 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import type { CargoType, DeliveryJob } from '@/services/driverJobService'; + +export interface JobFilterState { + /** Free-text keyword matched against the description and both addresses. */ + query: string; + /** Region or address fragment. Empty string means "any location". */ + location: string; + /** Exact cargo type. Empty string means "any cargo type". */ + cargoType: CargoType | ''; +} + +export const EMPTY_JOB_FILTERS: JobFilterState = { + query: '', + location: '', + cargoType: '', +}; + +export interface UseJobFiltersResult { + filters: JobFilterState; + /** Jobs left after every active filter has been applied. */ + filteredJobs: DeliveryJob[]; + /** True when at least one filter narrows the result set. */ + hasActiveFilters: boolean; + /** Distinct regions present in the supplied jobs, alphabetically sorted. */ + availableLocations: string[]; + /** Distinct cargo types present in the supplied jobs, alphabetically sorted. */ + availableCargoTypes: CargoType[]; + setQuery: (query: string) => void; + setLocation: (location: string) => void; + setCargoType: (cargoType: CargoType | '') => void; + resetFilters: () => void; +} + +const normalize = (value: string) => value.trim().toLowerCase(); + +function matchesLocation(job: DeliveryJob, location: string): boolean { + const needle = normalize(location); + if (!needle) return true; + return [job.region, job.pickupAddress, job.dropoffAddress].some((field) => + normalize(field ?? '').includes(needle), + ); +} + +function matchesQuery(job: DeliveryJob, query: string): boolean { + const needle = normalize(query); + if (!needle) return true; + return [job.packageDescription, job.pickupAddress, job.dropoffAddress].some((field) => + normalize(field ?? '').includes(needle), + ); +} + +function matchesCargoType(job: DeliveryJob, cargoType: CargoType | ''): boolean { + if (!cargoType) return true; + return job.cargoType === cargoType; +} + +/** + * useJobFilters — owns the advanced-search state for the driver job marketplace. + * + * Filtering happens client-side over the pool the board already holds, so + * changing a filter never triggers a refetch and the driver keeps their place + * in the list. Filters combine with AND; each unset filter is a no-op. + */ +export function useJobFilters(jobs: DeliveryJob[]): UseJobFiltersResult { + const [filters, setFilters] = useState(EMPTY_JOB_FILTERS); + + const setQuery = useCallback((query: string) => { + setFilters((current) => ({ ...current, query })); + }, []); + + const setLocation = useCallback((location: string) => { + setFilters((current) => ({ ...current, location })); + }, []); + + const setCargoType = useCallback((cargoType: CargoType | '') => { + setFilters((current) => ({ ...current, cargoType })); + }, []); + + const resetFilters = useCallback(() => { + setFilters(EMPTY_JOB_FILTERS); + }, []); + + const availableLocations = useMemo( + () => + Array.from( + new Set(jobs.map((job) => job.region).filter((region): region is string => Boolean(region))), + ).sort((a, b) => a.localeCompare(b)), + [jobs], + ); + + const availableCargoTypes = useMemo( + () => + Array.from( + new Set( + jobs + .map((job) => job.cargoType) + .filter((cargoType): cargoType is CargoType => Boolean(cargoType)), + ), + ).sort((a, b) => a.localeCompare(b)), + [jobs], + ); + + const filteredJobs = useMemo( + () => + jobs.filter( + (job) => + matchesQuery(job, filters.query) && + matchesLocation(job, filters.location) && + matchesCargoType(job, filters.cargoType), + ), + [jobs, filters], + ); + + const hasActiveFilters = + normalize(filters.query) !== '' || normalize(filters.location) !== '' || filters.cargoType !== ''; + + return { + filters, + filteredJobs, + hasActiveFilters, + availableLocations, + availableCargoTypes, + setQuery, + setLocation, + setCargoType, + resetFilters, + }; +} diff --git a/services/driverJobService.ts b/services/driverJobService.ts index e014509..f239fcc 100644 --- a/services/driverJobService.ts +++ b/services/driverJobService.ts @@ -2,6 +2,24 @@ import axios from 'axios'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; +/** Cargo categories a driver can filter the marketplace by. */ +export type CargoType = + | 'general' + | 'fragile' + | 'perishable' + | 'hazardous' + | 'oversized' + | 'refrigerated'; + +export const CARGO_TYPES: CargoType[] = [ + 'general', + 'fragile', + 'perishable', + 'hazardous', + 'oversized', + 'refrigerated', +]; + export interface DeliveryJob { id: string; pickupAddress: string; @@ -10,6 +28,8 @@ export interface DeliveryJob { estimatedDistance: number; // km estimatedEarnings: number; // XLM region: string; + /** Optional until the backend backfills historical jobs. */ + cargoType?: CargoType; createdAt: string; status: 'unassigned' | 'assigned' | 'in_progress' | 'delivered'; }