diff --git a/components/driver/LoadMatchingBoard.tsx b/components/driver/LoadMatchingBoard.tsx new file mode 100644 index 0000000..db72ac0 --- /dev/null +++ b/components/driver/LoadMatchingBoard.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useState } from 'react'; +import { useLoadMatching } from '@/hooks/useLoadMatching'; +import type { BulkContract, CargoType } from '@/types/loadMatching'; + +const CARGO_LABEL: Record = { + general: 'General', + refrigerated: 'Refrigerated', + hazardous: 'Hazardous', + oversized: 'Oversized', + fragile: 'Fragile', +}; + +const STATUS_BADGE: Record = { + submitted: 'bg-blue-100 text-blue-700', + accepted: 'bg-emerald-100 text-emerald-700', + rejected: 'bg-red-100 text-red-700', + expired: 'bg-gray-200 text-gray-700', +}; + +interface BidFormState { + bidAmount: string; + proposedTimeline: string; +} + +function BidForm({ + contract, + onSubmit, + isSubmitting, +}: { + contract: BulkContract; + onSubmit: (bidAmount: number, proposedTimeline: string) => void; + isSubmitting: boolean; +}) { + const [form, setForm] = useState({ bidAmount: '', proposedTimeline: '' }); + const [validationError, setValidationError] = useState(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const amount = Number(form.bidAmount); + if (!form.bidAmount || Number.isNaN(amount) || amount <= 0) { + setValidationError('Enter a valid bid amount greater than 0'); + return; + } + if (!form.proposedTimeline.trim()) { + setValidationError('Enter a proposed timeline'); + return; + } + setValidationError(null); + onSubmit(amount, form.proposedTimeline.trim()); + }; + + return ( +
+
+ + setForm((f) => ({ ...f, bidAmount: e.target.value }))} + className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm" + /> +
+
+ + setForm((f) => ({ ...f, proposedTimeline: e.target.value }))} + className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm" + /> +
+ {validationError &&

{validationError}

} + +
+ ); +} + +export function LoadMatchingBoard() { + const { + contracts, + bidsByContractId, + isLoadingContracts, + contractsError, + filters, + setFilters, + submitBid, + isSubmittingBid, + } = useLoadMatching(); + + if (isLoadingContracts) { + return ( +
+ Loading available contracts... +
+ ); + } + + if (contractsError) { + return ( +
+ {contractsError} +
+ ); + } + + return ( +
+
+ + + + + setFilters((f) => ({ ...f, region: e.target.value || undefined }))} + className="rounded-md border border-gray-300 px-2 py-1 text-sm" + /> + + + + setFilters((f) => ({ + ...f, + minRate: e.target.value ? Number(e.target.value) : undefined, + })) + } + className="w-24 rounded-md border border-gray-300 px-2 py-1 text-sm" + /> + + + + setFilters((f) => ({ + ...f, + maxRate: e.target.value ? Number(e.target.value) : undefined, + })) + } + className="w-24 rounded-md border border-gray-300 px-2 py-1 text-sm" + /> +
+ + {contracts.length === 0 ? ( +
+ No contracts match the current filters. +
+ ) : ( +
+ {contracts.map((contract) => { + const existingBid = bidsByContractId.get(contract.id); + return ( +
+
+ + {CARGO_LABEL[contract.cargoType]} + + {existingBid && ( + + {existingBid.status} + + )} +
+

{contract.route}

+
+
+
Region
+
{contract.region}
+
+
+
Timeline
+
{contract.timeline}
+
+
+
Base rate
+
{contract.baseRate} XLM
+
+
+ + {!existingBid && ( + + submitBid({ contractId: contract.id, bidAmount, proposedTimeline }) + } + /> + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/components/driver/__tests__/LoadMatchingBoard.test.tsx b/components/driver/__tests__/LoadMatchingBoard.test.tsx new file mode 100644 index 0000000..680e44a --- /dev/null +++ b/components/driver/__tests__/LoadMatchingBoard.test.tsx @@ -0,0 +1,224 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { LoadMatchingBoard } from '@/components/driver/LoadMatchingBoard'; +import { loadMatchingService } from '@/services/loadMatchingService'; +import { useToast } from '@/hooks/useToast'; + +jest.mock('@/services/loadMatchingService', () => ({ + loadMatchingService: { + getContracts: jest.fn(), + getBids: jest.fn(), + submitBid: jest.fn(), + }, +})); + +jest.mock('@/hooks/useToast', () => ({ + useToast: jest.fn(), +})); + +const mockedLoadMatchingService = loadMatchingService as jest.Mocked; + +const CONTRACTS = [ + { + id: 'contract-1', + cargoType: 'refrigerated' as const, + route: 'Accra to Kumasi', + region: 'Ashanti', + timeline: '2 days', + baseRate: 500, + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'contract-2', + cargoType: 'general' as const, + route: 'Lagos to Ibadan', + region: 'Oyo', + timeline: '1 day', + baseRate: 200, + createdAt: '2026-01-01T00:00:00.000Z', + }, +]; + +const renderWithClient = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + + ); +}; + +describe('LoadMatchingBoard', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useToast as jest.Mock).mockReturnValue({ success: jest.fn(), error: jest.fn() }); + mockedLoadMatchingService.getBids.mockResolvedValue([]); + }); + + it('shows a loading state before contracts arrive', () => { + mockedLoadMatchingService.getContracts.mockReturnValue(new Promise(() => {})); + renderWithClient(); + expect(screen.getByText('Loading available contracts...')).toBeInTheDocument(); + }); + + it('renders contract cards with all required details', async () => { + mockedLoadMatchingService.getContracts.mockResolvedValue(CONTRACTS); + renderWithClient(); + + await waitFor(() => expect(screen.getByText('Accra to Kumasi')).toBeInTheDocument()); + expect(screen.getByText('Ashanti')).toBeInTheDocument(); + expect(screen.getByText('2 days')).toBeInTheDocument(); + expect(screen.getByText('500 XLM')).toBeInTheDocument(); + expect(screen.getAllByText('Refrigerated').length).toBeGreaterThan(0); + }); + + it('shows an empty state when no contracts match', async () => { + mockedLoadMatchingService.getContracts.mockResolvedValue([]); + renderWithClient(); + + await waitFor(() => + expect(screen.getByText('No contracts match the current filters.')).toBeInTheDocument() + ); + }); + + it('shows an error message when fetching contracts fails', async () => { + mockedLoadMatchingService.getContracts.mockRejectedValue(new Error('Network error')); + renderWithClient(); + + await waitFor(() => expect(screen.getByText('Network error')).toBeInTheDocument()); + }); + + it('validates the bid amount before submitting', async () => { + mockedLoadMatchingService.getContracts.mockResolvedValue(CONTRACTS); + renderWithClient(); + + await waitFor(() => expect(screen.getByText('Accra to Kumasi')).toBeInTheDocument()); + + const [submitButton] = screen.getAllByRole('button', { name: 'Submit Bid' }); + fireEvent.click(submitButton); + + expect( + screen.getByText('Enter a valid bid amount greater than 0') + ).toBeInTheDocument(); + expect(mockedLoadMatchingService.submitBid).not.toHaveBeenCalled(); + }); + + it('requires a proposed timeline before submitting', async () => { + mockedLoadMatchingService.getContracts.mockResolvedValue(CONTRACTS); + renderWithClient(); + await waitFor(() => expect(screen.getByText('Accra to Kumasi')).toBeInTheDocument()); + + const [amountInput] = screen.getAllByPlaceholderText(/Base rate/); + await userEvent.type(amountInput, '550'); + + const [submitButton] = screen.getAllByRole('button', { name: 'Submit Bid' }); + fireEvent.click(submitButton); + + expect(screen.getByText('Enter a proposed timeline')).toBeInTheDocument(); + expect(mockedLoadMatchingService.submitBid).not.toHaveBeenCalled(); + }); + + it('submits a valid custom bid', async () => { + mockedLoadMatchingService.getContracts.mockResolvedValue(CONTRACTS); + mockedLoadMatchingService.submitBid.mockResolvedValue({ + id: 'bid-1', + contractId: 'contract-1', + bidAmount: 550, + proposedTimeline: '2 days', + status: 'submitted', + createdAt: '2026-01-01T00:00:00.000Z', + }); + renderWithClient(); + await waitFor(() => expect(screen.getByText('Accra to Kumasi')).toBeInTheDocument()); + + const [amountInput] = screen.getAllByPlaceholderText(/Base rate/); + const [timelineInput] = screen.getAllByPlaceholderText(/Proposed timeline/); + await userEvent.type(amountInput, '550'); + await userEvent.type(timelineInput, '2 days'); + + const [submitButton] = screen.getAllByRole('button', { name: 'Submit Bid' }); + fireEvent.click(submitButton); + + await waitFor(() => + expect(mockedLoadMatchingService.submitBid).toHaveBeenCalledWith({ + contractId: 'contract-1', + bidAmount: 550, + proposedTimeline: '2 days', + }) + ); + }); + + it('shows the bid status badge instead of the form once a bid exists', async () => { + mockedLoadMatchingService.getContracts.mockResolvedValue(CONTRACTS); + mockedLoadMatchingService.getBids.mockResolvedValue([ + { + id: 'bid-1', + contractId: 'contract-1', + bidAmount: 550, + proposedTimeline: '2 days', + status: 'accepted', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]); + renderWithClient(); + + await waitFor(() => expect(screen.getByText('accepted')).toBeInTheDocument()); + // The contract with an existing bid no longer shows its own bid form. + expect(screen.getAllByRole('button', { name: 'Submit Bid' })).toHaveLength(1); + }); + + it('narrows contracts by region and rate range filters', async () => { + mockedLoadMatchingService.getContracts.mockResolvedValue(CONTRACTS); + renderWithClient(); + await waitFor(() => expect(screen.getByText('Accra to Kumasi')).toBeInTheDocument()); + + mockedLoadMatchingService.getContracts.mockClear(); + mockedLoadMatchingService.getContracts.mockResolvedValue([CONTRACTS[0]]); + + fireEvent.change(screen.getByLabelText('Filter by region'), { + target: { value: 'Ashanti' }, + }); + await waitFor(() => expect(screen.getByText('Accra to Kumasi')).toBeInTheDocument()); + + fireEvent.change(screen.getByLabelText('Minimum rate'), { target: { value: '100' } }); + fireEvent.change(screen.getByLabelText('Maximum rate'), { target: { value: '1000' } }); + + await waitFor(() => + expect(mockedLoadMatchingService.getContracts).toHaveBeenCalledWith( + expect.objectContaining({ region: 'Ashanti', minRate: 100, maxRate: 1000 }), + expect.anything() + ) + ); + + fireEvent.change(screen.getByLabelText('Minimum rate'), { target: { value: '' } }); + await waitFor(() => + expect(mockedLoadMatchingService.getContracts).toHaveBeenCalledWith( + expect.objectContaining({ minRate: undefined }), + expect.anything() + ) + ); + }); + + it('narrows contracts by cargo type filter', async () => { + mockedLoadMatchingService.getContracts.mockResolvedValue(CONTRACTS); + renderWithClient(); + await waitFor(() => expect(screen.getByText('Accra to Kumasi')).toBeInTheDocument()); + + mockedLoadMatchingService.getContracts.mockClear(); + mockedLoadMatchingService.getContracts.mockResolvedValue([CONTRACTS[0]]); + + fireEvent.change(screen.getByLabelText('Filter by cargo type'), { + target: { value: 'refrigerated' }, + }); + + await waitFor(() => + expect(mockedLoadMatchingService.getContracts).toHaveBeenCalledWith( + expect.objectContaining({ cargoType: 'refrigerated' }), + expect.anything() + ) + ); + }); +}); diff --git a/components/escrow/DisputeCasePortal.tsx b/components/escrow/DisputeCasePortal.tsx new file mode 100644 index 0000000..0dd335b --- /dev/null +++ b/components/escrow/DisputeCasePortal.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useState } from 'react'; +import { useDisputeCase } from '@/hooks/useDisputeCase'; +import { EvidenceDropzone } from '@/components/escrow/EvidenceDropzone'; +import type { DisputeCaseResponse, DisputeReason } from '@/types/dispute'; +import { useToast } from '@/hooks/useToast'; + +type Step = 'issue' | 'evidence' | 'confirmation'; + +interface DisputeCasePortalProps { + deliveryId: string; +} + +const REASONS: { value: DisputeReason; label: string }[] = [ + { value: 'damaged_items', label: 'Items Damaged' }, + { value: 'non_delivery', label: 'Non-Delivery' }, + { value: 'incorrect_items', label: 'Incorrect Items' }, + { value: 'other', label: 'Other Issue' }, +]; + +export function DisputeCasePortal({ deliveryId }: DisputeCasePortalProps) { + const [step, setStep] = useState('issue'); + const [reason, setReason] = useState(null); + const [description, setDescription] = useState(''); + const [submittedCase, setSubmittedCase] = useState(null); + const { error: toastError } = useToast(); + + const { files, addFiles, removeFile, submitCase, isSubmitting, uploadProgress } = + useDisputeCase(deliveryId); + + const canContinueFromIssue = reason !== null && description.trim().length >= 20; + + const handleSubmit = async () => { + if (!reason) return; + try { + const result = await submitCase(reason, description); + setSubmittedCase(result); + setStep('confirmation'); + } catch { + // useDisputeCase already surfaces a toast; stay on the evidence step. + } + }; + + if (step === 'confirmation' && submittedCase) { + return ( +
+

Dispute Filed

+

+ Case ID: {submittedCase.caseId} +

+ {submittedCase.escrowFrozen && ( +

+ Escrow payout frozen pending review +

+ )} +
+ ); + } + + return ( +
+

Report a Delivery Issue

+ + {step === 'issue' && ( +
+
+ +
+ {REASONS.map((r) => ( + + ))} +
+
+ +
+ +