From 50977af7de32114cde2b4ba62f78acaba2207fa5 Mon Sep 17 00:00:00 2001 From: QueenColly Date: Mon, 1 Jun 2026 18:46:17 +0100 Subject: [PATCH 1/2] feat(escrow): build dispute resolution multi-step form --- components/escrow/DisputeForm.tsx | 481 ++++++++++++++++++ .../escrow/__tests__/DisputeForm.test.tsx | 320 ++++++++++++ hooks/__tests__/useDisputeForm.test.ts | 288 +++++++++++ hooks/useDisputeForm.ts | 129 +++++ services/escrowService.ts | 47 ++ 5 files changed, 1265 insertions(+) create mode 100644 components/escrow/DisputeForm.tsx create mode 100644 components/escrow/__tests__/DisputeForm.test.tsx create mode 100644 hooks/__tests__/useDisputeForm.test.ts create mode 100644 hooks/useDisputeForm.ts diff --git a/components/escrow/DisputeForm.tsx b/components/escrow/DisputeForm.tsx new file mode 100644 index 0000000..adf707a --- /dev/null +++ b/components/escrow/DisputeForm.tsx @@ -0,0 +1,481 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { AlertCircle, Upload, X, CheckCircle2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { useDisputeForm, DisputeFormData, OpenDisputeResponse } from '@/hooks/useDisputeForm'; + +interface DisputeFormProps { + deliveryId: string; + walletAddress: string; + onSuccess?: (response: OpenDisputeResponse) => void; + onError?: (error: string) => void; +} + +type FormStep = 'dispute_reason' | 'evidence' | 'confirmation' | 'success'; + +const DISPUTE_REASONS = [ + { value: 'damaged_items', label: 'Items Damaged', description: 'Items arrived damaged or broken' }, + { value: 'non_delivery', label: 'Non-Delivery', description: 'Items were not delivered' }, + { value: 'incorrect_items', label: 'Incorrect Items', description: 'Wrong items were delivered' }, + { value: 'other', label: 'Other Issue', description: 'Something else went wrong' }, +] as const; + +/** + * Confirmation Dialog Component - warns about funds being frozen + */ +function ConfirmationDialog({ + reason, + description, + onConfirm, + onCancel, + isSubmitting, +}: { + reason: string; + description: string; + onConfirm: () => void; + onCancel: () => void; + isSubmitting: boolean; +}): JSX.Element { + const overlayRef = useRef(null); + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current && !isSubmitting) onCancel(); + }; + + const reasonLabel = DISPUTE_REASONS.find(r => r.value === reason)?.label || reason; + + return ( +
+
+
+ +
+ +

+ Confirm Dispute +

+

+ Opening a dispute will freeze the escrow funds during arbitration. Confirm your dispute details below. +

+ +
+
+

Dispute Reason

+

{reasonLabel}

+
+
+

Description

+

{description}

+
+
+ +
+

+ ⚠️ Important: Your funds will be locked in escrow during arbitration. You will not be able to withdraw them until the dispute is resolved. +

+
+ +
+ + +
+
+
+ ); +} + +/** + * Success View - displayed after successful dispute submission + */ +function SuccessView({ + disputeId, + transactionHash, +}: { + disputeId?: string; + transactionHash?: string; +}): JSX.Element { + return ( +
+
+ +
+

Dispute Submitted

+

+ Your dispute has been successfully opened. The escrow funds are now frozen. +

+ {disputeId && ( +
+

+ Dispute ID: {disputeId} +

+
+ )} + {transactionHash && ( +
+

+ Transaction: {transactionHash} +

+
+ )} +
+ ); +} + +/** + * DisputeForm Component - Multi-step form for opening a delivery dispute + * + * Flow: + * 1. Select dispute reason + * 2. Upload evidence files + * 3. Confirmation dialog (warns about frozen funds) + * 4. Success screen + */ +export default function DisputeForm({ + deliveryId, + walletAddress, + onSuccess, + onError, +}: DisputeFormProps): JSX.Element { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + watch, + setValue, + submitDispute, + } = useDisputeForm(); + + const [currentStep, setCurrentStep] = useState('dispute_reason'); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [successData, setSuccessData] = useState(null); + const fileInputRef = useRef(null); + + const selectedReason = watch('reason'); + const description = watch('description'); + const transactionId = watch('transactionId'); + + const handleFileUpload = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + const totalFiles = uploadedFiles.length + files.length; + + if (totalFiles > 5) { + toast.error('Maximum 5 files allowed'); + return; + } + + const validFiles = files.filter(file => { + if (!file.type.startsWith('image/') && file.type !== 'application/pdf') { + toast.error(`${file.name} is not a supported format. Use images or PDF.`); + return false; + } + if (file.size > 10 * 1024 * 1024) { + toast.error(`${file.name} exceeds 10MB size limit`); + return false; + } + return true; + }); + + setUploadedFiles([...uploadedFiles, ...validFiles]); + setValue('evidenceFiles', [...uploadedFiles, ...validFiles]); + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const removeFile = (index: number) => { + const newFiles = uploadedFiles.filter((_, i) => i !== index); + setUploadedFiles(newFiles); + setValue('evidenceFiles', newFiles); + }; + + const onSubmit = handleSubmit(async (formData: DisputeFormData) => { + if (currentStep === 'dispute_reason') { + setCurrentStep('evidence'); + } else if (currentStep === 'evidence') { + setCurrentStep('confirmation'); + } + }); + + const handleConfirmDispute = async () => { + const formData: DisputeFormData = { + transactionId, + reason: selectedReason, + description, + evidenceFiles: uploadedFiles, + }; + + await submitDispute( + formData, + deliveryId, + walletAddress, + (response) => { + setSuccessData(response); + setCurrentStep('success'); + onSuccess?.(response); + }, + (error) => { + setCurrentStep('evidence'); + onError?.(error); + } + ); + }; + + // Step 1: Dispute Reason + if (currentStep === 'dispute_reason') { + return ( +
+
+

Open a Dispute

+

Step 1 of 3: Select the reason for your dispute

+
+ +
+ {/* Transaction ID */} +
+ + + {errors.transactionId && ( +

{errors.transactionId.message}

+ )} +
+ + {/* Dispute Reason */} +
+ +
+ {DISPUTE_REASONS.map((reason) => ( + + ))} +
+ {errors.reason && ( +

{errors.reason.message}

+ )} +
+ + {/* Description */} +
+ +