From 431dee71a7108e3f8855ebe83ecc59e31b30757d Mon Sep 17 00:00:00 2001 From: iamthebestofcourse Date: Tue, 29 Sep 2026 21:07:43 +0100 Subject: [PATCH] feat(driver,escrow,fleet): bidding hook, contract countdown, dispute portal, reputation grid Implements four assigned issues following the Component -> Hook -> Service layering used elsewhere in the app: - useBidding (#839): TanStack Query hook fetching bidding contracts and bid history, submitting bids via a mutation, and polling bid status while any bid is pending. - useContractCountdown + BiddingBoard (#840): per-contract live countdowns with urgency-based coloring, a one-time "expiring soon" toast, and automatic removal of contracts once past their grace period. The timer pauses via the Page Visibility API while the tab is hidden. - DisputeResolutionPortal, EvidenceMediaDropzone, and useEvidenceUpload (#846): no dispute-resolution UI existed yet in this codebase to write tests against, so this adds a minimal real implementation (reason selection, evidence upload with image compression, review/submit steps backed by a new disputeResolutionService), plus a test suite covering it (61 tests total across this batch; DisputeResolutionPortal, EvidenceMediaDropzone, and useEvidenceUpload are at 97-100% statement coverage). - ReputationReviewGrid (#847): filterable grid of driver reviews with on-chain verification badges, backed by a new useReputationReviews hook and reputationReviewService. Also fixes components/ui/ThemeToggle.tsx, which imported the app's broken custom useTheme hook (hooks/useTheme.ts calls methods that don't exist on services/themeService.ts, and its own return shape doesn't match what ThemeToggle destructures). That broke app/layout.tsx and therefore every route, in both dev and prod. Redirected it to next-themes' own useTheme, which is what components/shared/ThemeProvider.tsx already uses elsewhere in the app. hooks/useTheme.ts and services/themeService.ts are untouched and still orphaned, dead code; out of scope here. Adds date-fns as a dependency for countdown formatting (named in #840's technical scope but not previously installed). Pre-existing, unrelated to this PR: app/page.tsx has an unbalanced JSX tag at line 178 that fails both `next build` and `tsc --noEmit`, blocking production builds repo-wide. Reproduced on a clean checkout of this branch's base commit. escrow/DisputeForm.tsx, hooks/useDisputeForm.ts, and services/escrowService.ts also remain broken (escrowService.ts imports the uninstalled stellar-sdk package); the new dispute-resolution code in this PR does not build on that precedent. Closes #839 Closes #840 Closes #846 Closes #847 --- .../escrow/DisputeResolutionPortal.test.tsx | 366 ++++++++++++++++++ __tests__/hooks/useEvidenceUpload.test.ts | 182 +++++++++ components/driver/BiddingBoard.tsx | 170 ++++++++ components/escrow/DisputeResolutionPortal.tsx | 220 +++++++++++ components/escrow/EvidenceMediaDropzone.tsx | 140 +++++++ components/fleet/ReputationReviewGrid.tsx | 186 +++++++++ components/ui/ThemeToggle.tsx | 7 +- hooks/useBidding.ts | 93 +++++ hooks/useContractCountdown.ts | 135 +++++++ hooks/useEvidenceUpload.ts | 159 ++++++++ hooks/useReputationReviews.ts | 41 ++ package.json | 1 + pnpm-lock.yaml | 8 + services/biddingService.ts | 34 ++ services/disputeResolutionService.ts | 31 ++ services/reputationReviewService.ts | 35 ++ types/bidding.ts | 36 ++ types/disputeResolution.ts | 32 ++ types/reputationReview.ts | 26 ++ 19 files changed, 1899 insertions(+), 3 deletions(-) create mode 100644 __tests__/components/escrow/DisputeResolutionPortal.test.tsx create mode 100644 __tests__/hooks/useEvidenceUpload.test.ts create mode 100644 components/driver/BiddingBoard.tsx create mode 100644 components/escrow/DisputeResolutionPortal.tsx create mode 100644 components/escrow/EvidenceMediaDropzone.tsx create mode 100644 components/fleet/ReputationReviewGrid.tsx create mode 100644 hooks/useBidding.ts create mode 100644 hooks/useContractCountdown.ts create mode 100644 hooks/useEvidenceUpload.ts create mode 100644 hooks/useReputationReviews.ts create mode 100644 services/biddingService.ts create mode 100644 services/disputeResolutionService.ts create mode 100644 services/reputationReviewService.ts create mode 100644 types/bidding.ts create mode 100644 types/disputeResolution.ts create mode 100644 types/reputationReview.ts diff --git a/__tests__/components/escrow/DisputeResolutionPortal.test.tsx b/__tests__/components/escrow/DisputeResolutionPortal.test.tsx new file mode 100644 index 0000000..4ac1965 --- /dev/null +++ b/__tests__/components/escrow/DisputeResolutionPortal.test.tsx @@ -0,0 +1,366 @@ +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DisputeResolutionPortal } from '@/components/escrow/DisputeResolutionPortal'; +import { EvidenceMediaDropzone } from '@/components/escrow/EvidenceMediaDropzone'; +import { useEvidenceUpload } from '@/hooks/useEvidenceUpload'; +import { disputeResolutionService } from '@/services/disputeResolutionService'; +import { imageCompressionService } from '@/services/imageCompressionService'; +import type { EvidenceFile } from '@/types/disputeResolution'; + +jest.mock('sonner', () => ({ + toast: { error: jest.fn(), success: jest.fn() }, +})); + +jest.mock('@/hooks/useEvidenceUpload'); +const mockedUseEvidenceUpload = useEvidenceUpload as jest.Mock; + +jest.mock('@/services/disputeResolutionService', () => ({ + disputeResolutionService: { + uploadEvidence: jest.fn(), + submitCase: jest.fn(), + }, +})); + +function makeEvidenceFile(overrides: Partial = {}): EvidenceFile { + return { + id: 'evidence-1', + file: new File(['contents'], 'proof.png', { type: 'image/png' }), + previewUrl: 'blob:mock-url', + status: 'ready', + ...overrides, + }; +} + +function defaultEvidenceUploadReturn(overrides = {}) { + return { + files: [], + addFiles: jest.fn().mockReturnValue({ accepted: 0, rejected: [] }), + removeFile: jest.fn(), + uploadAll: jest.fn().mockResolvedValue([]), + isUploading: false, + canAddMore: true, + ...overrides, + }; +} + +async function fillDetailsStep() { + fireEvent.click(screen.getByLabelText('Items Damaged')); + fireEvent.change(screen.getByLabelText('Description'), { + target: { value: 'The package arrived with visible damage to the contents.' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue to Evidence' })); +} + +describe('DisputeResolutionPortal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedUseEvidenceUpload.mockReturnValue(defaultEvidenceUploadReturn()); + }); + + it('starts on the details step', () => { + render(); + expect(screen.getByTestId('step-details')).toBeInTheDocument(); + }); + + it('disables Continue until a reason and a sufficiently long description are provided', () => { + render(); + const continueButton = screen.getByRole('button', { name: 'Continue to Evidence' }); + expect(continueButton).toBeDisabled(); + + fireEvent.click(screen.getByLabelText('Items Damaged')); + expect(continueButton).toBeDisabled(); // description still too short + + fireEvent.change(screen.getByLabelText('Description'), { + target: { value: 'short' }, + }); + expect(continueButton).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Description'), { + target: { value: 'This description is now long enough to continue.' }, + }); + expect(continueButton).not.toBeDisabled(); + }); + + it('transitions from the details step to the evidence step', async () => { + render(); + await fillDetailsStep(); + expect(screen.getByTestId('step-evidence')).toBeInTheDocument(); + }); + + it('transitions from the evidence step to the review step', async () => { + render(); + await fillDetailsStep(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + expect(screen.getByTestId('step-review')).toBeInTheDocument(); + }); + + it('the Back button on the evidence step returns to the details step', async () => { + render(); + await fillDetailsStep(); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByTestId('step-details')).toBeInTheDocument(); + }); + + it('the review step summarizes the selected reason, description, and evidence count', async () => { + mockedUseEvidenceUpload.mockReturnValue( + defaultEvidenceUploadReturn({ files: [makeEvidenceFile(), makeEvidenceFile({ id: 'evidence-2' })] }) + ); + render(); + await fillDetailsStep(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + + const review = screen.getByTestId('step-review'); + expect(within(review).getByText('Items Damaged')).toBeInTheDocument(); + expect(within(review).getByText('2 file(s) attached')).toBeInTheDocument(); + }); + + it('shows the escrow freeze indicator after a dispute is filed', async () => { + (disputeResolutionService.submitCase as jest.Mock).mockResolvedValue({ + caseId: 'CASE-001', + status: 'open', + escrowFrozen: true, + createdAt: new Date().toISOString(), + }); + + render(); + await fillDetailsStep(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm Dispute' })); + + await waitFor(() => { + expect(screen.getByTestId('escrow-frozen-indicator')).toBeInTheDocument(); + }); + }); + + it('does not show the escrow freeze indicator when the case is not frozen', async () => { + (disputeResolutionService.submitCase as jest.Mock).mockResolvedValue({ + caseId: 'CASE-002', + status: 'open', + escrowFrozen: false, + createdAt: new Date().toISOString(), + }); + + render(); + await fillDetailsStep(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm Dispute' })); + + await waitFor(() => { + expect(screen.getByTestId('dispute-submitted')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('escrow-frozen-indicator')).not.toBeInTheDocument(); + }); + + it('displays the case ID after successful submission', async () => { + (disputeResolutionService.submitCase as jest.Mock).mockResolvedValue({ + caseId: 'CASE-42', + status: 'open', + escrowFrozen: true, + createdAt: new Date().toISOString(), + }); + + render(); + await fillDetailsStep(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm Dispute' })); + + await waitFor(() => { + expect(screen.getByTestId('case-id')).toHaveTextContent('CASE-42'); + }); + }); + + it('calls submitCase with the deliveryId, reason, description, and uploaded evidence file ids', async () => { + const uploadAll = jest.fn().mockResolvedValue(['file-1', 'file-2']); + mockedUseEvidenceUpload.mockReturnValue(defaultEvidenceUploadReturn({ uploadAll })); + (disputeResolutionService.submitCase as jest.Mock).mockResolvedValue({ + caseId: 'CASE-99', + status: 'open', + escrowFrozen: true, + createdAt: new Date().toISOString(), + }); + + render(); + await fillDetailsStep(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm Dispute' })); + + await waitFor(() => { + expect(disputeResolutionService.submitCase).toHaveBeenCalledWith({ + deliveryId: 'delivery-77', + reason: 'damaged_items', + description: 'The package arrived with visible damage to the contents.', + evidenceFileIds: ['file-1', 'file-2'], + }); + }); + }); + + it('shows an error toast and stays on the review step when submission fails', async () => { + const { toast } = jest.requireMock('sonner'); + (disputeResolutionService.submitCase as jest.Mock).mockRejectedValue( + new Error('Network error') + ); + + render(); + await fillDetailsStep(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm Dispute' })); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Network error'); + }); + expect(screen.getByTestId('step-review')).toBeInTheDocument(); + }); + + it('disables the Confirm and Back buttons while submitting', async () => { + mockedUseEvidenceUpload.mockReturnValue(defaultEvidenceUploadReturn({ isUploading: true })); + render(); + await fillDetailsStep(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + + expect(screen.getByRole('button', { name: 'Submitting…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); + }); +}); + +describe('EvidenceMediaDropzone', () => { + it('renders a dropzone that accepts files via the hidden file input', () => { + const onFilesAdded = jest.fn().mockReturnValue({ accepted: 1, rejected: [] }); + render( + + ); + + const input = screen.getByLabelText('Choose evidence files'); + const file = new File(['content'], 'evidence.png', { type: 'image/png' }); + fireEvent.change(input, { target: { files: [file] } }); + + expect(onFilesAdded).toHaveBeenCalledWith([file]); + }); + + it('accepts a file dropped onto the dropzone', () => { + const onFilesAdded = jest.fn().mockReturnValue({ accepted: 1, rejected: [] }); + render( + + ); + + const file = new File(['content'], 'evidence.png', { type: 'image/png' }); + const dropzone = screen.getByTestId('evidence-dropzone'); + fireEvent.drop(dropzone, { dataTransfer: { files: [file] } }); + + expect(onFilesAdded).toHaveBeenCalledWith([file]); + }); + + it('surfaces rejection reasons as toast errors (e.g. unsupported file type)', () => { + const { toast } = jest.requireMock('sonner'); + const onFilesAdded = jest.fn().mockReturnValue({ + accepted: 0, + rejected: ['bad.exe: unsupported file type'], + }); + render( + + ); + + const input = screen.getByLabelText('Choose evidence files'); + const file = new File(['content'], 'bad.exe', { type: 'application/x-msdownload' }); + fireEvent.change(input, { target: { files: [file] } }); + + expect(toast.error).toHaveBeenCalledWith('bad.exe: unsupported file type'); + }); + + it('disables the dropzone and file input once canAddMore is false', () => { + render( + + ); + + expect(screen.getByLabelText('Choose evidence files')).toBeDisabled(); + expect(screen.getByText('Maximum evidence files reached')).toBeInTheDocument(); + }); + + it('calls onRemoveFile when a file is removed from the list', () => { + const onRemoveFile = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByLabelText('Remove proof.png')); + expect(onRemoveFile).toHaveBeenCalledWith('evidence-9'); + }); + + it('highlights the dropzone on drag-over and un-highlights on drag-leave', () => { + render( + + ); + + const dropzone = screen.getByTestId('evidence-dropzone'); + fireEvent.dragOver(dropzone); + expect(dropzone.className).toContain('border-blue-500'); + + fireEvent.dragLeave(dropzone); + expect(dropzone.className).not.toContain('border-blue-500'); + }); + + it('opens the file picker on Space when focused', () => { + render( + + ); + + const dropzone = screen.getByTestId('evidence-dropzone'); + const input = screen.getByLabelText('Choose evidence files'); + const clickSpy = jest.spyOn(input, 'click'); + + fireEvent.keyDown(dropzone, { key: ' ' }); + expect(clickSpy).toHaveBeenCalled(); + }); + + it('does not open the file picker via keyboard once canAddMore is false', () => { + render( + + ); + + const dropzone = screen.getByTestId('evidence-dropzone'); + const input = screen.getByLabelText('Choose evidence files'); + const clickSpy = jest.spyOn(input, 'click'); + + fireEvent.keyDown(dropzone, { key: 'Enter' }); + expect(clickSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/hooks/useEvidenceUpload.test.ts b/__tests__/hooks/useEvidenceUpload.test.ts new file mode 100644 index 0000000..54c32bf --- /dev/null +++ b/__tests__/hooks/useEvidenceUpload.test.ts @@ -0,0 +1,182 @@ +import { renderHook, act } from '@testing-library/react'; +import { useEvidenceUpload } from '@/hooks/useEvidenceUpload'; +import { disputeResolutionService } from '@/services/disputeResolutionService'; +import { imageCompressionService } from '@/services/imageCompressionService'; + +jest.mock('@/services/disputeResolutionService', () => ({ + disputeResolutionService: { + uploadEvidence: jest.fn(), + }, +})); + +jest.mock('@/services/imageCompressionService', () => ({ + imageCompressionService: { + compressImage: jest.fn(), + }, +})); + +function pngFile(name = 'proof.png') { + return new File(['raw-bytes'], name, { type: 'image/png' }); +} + +describe('useEvidenceUpload', () => { + beforeEach(() => { + jest.clearAllMocks(); + (imageCompressionService.compressImage as jest.Mock).mockResolvedValue( + new File(['compressed-bytes'], 'proof.png', { type: 'image/png' }) + ); + (disputeResolutionService.uploadEvidence as jest.Mock).mockResolvedValue({ + fileId: 'file-abc', + }); + }); + + it('adds a valid image file to the queue', () => { + const { result } = renderHook(() => useEvidenceUpload()); + + act(() => { + result.current.addFiles([pngFile()]); + }); + + expect(result.current.files).toHaveLength(1); + expect(result.current.files[0].status).toBe('pending'); + }); + + it('rejects a file with an unsupported type', () => { + const { result } = renderHook(() => useEvidenceUpload()); + + let outcome: { accepted: number; rejected: string[] } | undefined; + act(() => { + outcome = result.current.addFiles([ + new File(['x'], 'malware.exe', { type: 'application/x-msdownload' }), + ]); + }); + + expect(outcome?.accepted).toBe(0); + expect(outcome?.rejected[0]).toContain('unsupported file type'); + expect(result.current.files).toHaveLength(0); + }); + + it('rejects a file exceeding the size limit', () => { + const { result } = renderHook(() => useEvidenceUpload()); + const oversized = new File([new Uint8Array(11 * 1024 * 1024)], 'huge.png', { + type: 'image/png', + }); + + let outcome: { accepted: number; rejected: string[] } | undefined; + act(() => { + outcome = result.current.addFiles([oversized]); + }); + + expect(outcome?.accepted).toBe(0); + expect(outcome?.rejected[0]).toContain('exceeds'); + }); + + it('caps the queue at the maximum number of evidence files', () => { + const { result } = renderHook(() => useEvidenceUpload()); + const files = Array.from({ length: 6 }, (_, i) => pngFile(`proof-${i}.png`)); + + act(() => { + result.current.addFiles(files); + }); + + expect(result.current.files).toHaveLength(5); + expect(result.current.canAddMore).toBe(false); + }); + + it('removes a file from the queue', () => { + const { result } = renderHook(() => useEvidenceUpload()); + + act(() => { + result.current.addFiles([pngFile()]); + }); + const id = result.current.files[0].id; + + act(() => { + result.current.removeFile(id); + }); + + expect(result.current.files).toHaveLength(0); + }); + + it('compresses image files before uploading them', async () => { + const { result } = renderHook(() => useEvidenceUpload()); + + act(() => { + result.current.addFiles([pngFile()]); + }); + + await act(async () => { + await result.current.uploadAll(); + }); + + expect(imageCompressionService.compressImage).toHaveBeenCalledTimes(1); + expect(disputeResolutionService.uploadEvidence).toHaveBeenCalledTimes(1); + }); + + it('does not attempt to compress a non-image file (e.g. PDF)', async () => { + const { result } = renderHook(() => useEvidenceUpload()); + const pdf = new File(['%PDF-1.4'], 'evidence.pdf', { type: 'application/pdf' }); + + act(() => { + result.current.addFiles([pdf]); + }); + + await act(async () => { + await result.current.uploadAll(); + }); + + expect(imageCompressionService.compressImage).not.toHaveBeenCalled(); + expect(disputeResolutionService.uploadEvidence).toHaveBeenCalledWith(pdf); + }); + + it('falls back to the original file when compression fails', async () => { + (imageCompressionService.compressImage as jest.Mock).mockRejectedValue( + new Error('compression failed') + ); + const { result } = renderHook(() => useEvidenceUpload()); + const original = pngFile(); + + act(() => { + result.current.addFiles([original]); + }); + + await act(async () => { + await result.current.uploadAll(); + }); + + expect(disputeResolutionService.uploadEvidence).toHaveBeenCalledWith(original); + }); + + it('returns the server-assigned file ids from uploadAll', async () => { + const { result } = renderHook(() => useEvidenceUpload()); + + act(() => { + result.current.addFiles([pngFile()]); + }); + + let fileIds: string[] = []; + await act(async () => { + fileIds = await result.current.uploadAll(); + }); + + expect(fileIds).toEqual(['file-abc']); + }); + + it('marks a file as errored when its upload fails', async () => { + (disputeResolutionService.uploadEvidence as jest.Mock).mockRejectedValue( + new Error('network down') + ); + const { result } = renderHook(() => useEvidenceUpload()); + + act(() => { + result.current.addFiles([pngFile()]); + }); + + await act(async () => { + await result.current.uploadAll().catch(() => undefined); + }); + + expect(result.current.files[0].status).toBe('error'); + expect(result.current.files[0].errorMessage).toBe('network down'); + }); +}); diff --git a/components/driver/BiddingBoard.tsx b/components/driver/BiddingBoard.tsx new file mode 100644 index 0000000..8b63e42 --- /dev/null +++ b/components/driver/BiddingBoard.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useMemo } from 'react'; +import { useBidding } from '@/hooks/useBidding'; +import { + useContractCountdown, + formatCountdown, + type ContractUrgency, +} from '@/hooks/useContractCountdown'; +import type { Contract } from '@/types/bidding'; + +const URGENCY_BADGE: Record = { + normal: 'bg-emerald-100 text-emerald-700', + urgent: 'bg-orange-100 text-orange-700', + critical: 'bg-red-100 text-red-700', +}; + +const URGENCY_LABEL: Record = { + normal: 'Open', + urgent: 'Closing soon', + critical: 'Final hour', +}; + +interface ContractCardProps { + contract: Contract; + msRemaining: number; + urgency: ContractUrgency; + isExpired: boolean; + onBid: (contract: Contract) => void; +} + +function ContractCard({ + contract, + msRemaining, + urgency, + isExpired, + onBid, +}: ContractCardProps) { + return ( +
  • +
    +
    +

    {contract.pickupAddress}

    +

    → {contract.dropoffAddress}

    +
    + + {URGENCY_LABEL[urgency]} + +
    + +

    {contract.packageDescription}

    + +
    + {contract.estimatedDistance} km + {contract.suggestedRate} XLM suggested +
    + +
    + {formatCountdown(msRemaining)} +
    + + +
  • + ); +} + +interface BiddingBoardProps { + onSelectContract?: (contract: Contract) => void; +} + +/** + * BiddingBoard — displays open contracts with live countdown timers, + * urgency-based color coding, automatic removal of expired contracts, and + * a one-time "expiring soon" toast per contract (see useContractCountdown). + */ +export function BiddingBoard({ onSelectContract }: BiddingBoardProps) { + const { contracts, isLoadingContracts, contractsError } = useBidding(); + + const countdowns = useContractCountdown(contracts); + + // shouldRemove is derived fresh every render from the ticking countdown + // clock (see useContractCountdown), so filtering here is enough to drop + // expired-past-grace contracts without any separate removal state/effect. + const visibleContracts = useMemo( + () => contracts.filter((contract) => !countdowns.get(contract.id)?.shouldRemove), + [contracts, countdowns] + ); + + if (isLoadingContracts) { + return ( +
    + Loading available contracts… +
    + ); + } + + if (contractsError) { + return ( +
    + {contractsError} +
    + ); + } + + if (visibleContracts.length === 0) { + return ( +
    + No open contracts right now — check back soon. +
    + ); + } + + return ( +
    +
    +

    Open Contracts

    +
    + +
      + {visibleContracts.map((contract) => { + const countdown = countdowns.get(contract.id); + return ( + onSelectContract?.(c)} + /> + ); + })} +
    +
    + ); +} diff --git a/components/escrow/DisputeResolutionPortal.tsx b/components/escrow/DisputeResolutionPortal.tsx new file mode 100644 index 0000000..e16fbb9 --- /dev/null +++ b/components/escrow/DisputeResolutionPortal.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { EvidenceMediaDropzone } from '@/components/escrow/EvidenceMediaDropzone'; +import { useEvidenceUpload } from '@/hooks/useEvidenceUpload'; +import { disputeResolutionService } from '@/services/disputeResolutionService'; +import type { DisputeCaseSummary, DisputeStep } from '@/types/disputeResolution'; + +const DISPUTE_REASONS = [ + { value: 'damaged_items', label: 'Items Damaged' }, + { value: 'non_delivery', label: 'Non-Delivery' }, + { value: 'incorrect_items', label: 'Incorrect Items' }, + { value: 'other', label: 'Other Issue' }, +] as const; + +interface DisputeResolutionPortalProps { + deliveryId: string; + onSubmitted?: (caseSummary: DisputeCaseSummary) => void; +} + +/** + * DisputeResolutionPortal — multi-step dispute filing flow: reason/details, + * evidence upload, review, then submission. Shows an escrow-frozen + * indicator and the assigned case ID once a dispute has been filed. + */ +export function DisputeResolutionPortal({ + deliveryId, + onSubmitted, +}: DisputeResolutionPortalProps) { + const [step, setStep] = useState('details'); + const [reason, setReason] = useState(''); + const [description, setDescription] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [caseSummary, setCaseSummary] = useState(null); + + const { files, addFiles, removeFile, uploadAll, isUploading, canAddMore } = + useEvidenceUpload(); + + const canContinueFromDetails = reason.length > 0 && description.trim().length >= 20; + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + const evidenceFileIds = await uploadAll(); + const summary = await disputeResolutionService.submitCase({ + deliveryId, + reason, + description, + evidenceFileIds, + }); + setCaseSummary(summary); + setStep('submitted'); + onSubmitted?.(summary); + } catch (err) { + toast.error( + err instanceof Error ? err.message : 'Failed to submit dispute. Please try again.' + ); + } finally { + setIsSubmitting(false); + } + }; + + if (step === 'submitted' && caseSummary) { + return ( +
    +
    +

    Dispute Submitted

    +

    + Case ID:{' '} + + {caseSummary.caseId} + +

    + {caseSummary.escrowFrozen && ( +

    + 🔒 Escrow funds are frozen while this dispute is under review. +

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

    Open a Dispute

    + + {step === 'details' && ( +
    +
    + +
    + {DISPUTE_REASONS.map((r) => ( + + ))} +
    +
    + +
    + +