From 04555082ed774933e25944572475f5ad87e79a92 Mon Sep 17 00:00:00 2001 From: DIFoundation Date: Wed, 25 Mar 2026 14:35:16 +0100 Subject: [PATCH 1/9] [Frontend] Implement Complete Dispute Resolution UI - Add FileDisputeModal with reason, severity, and evidence management - Create DisputeSection for viewing dispute details and timeline - Build ArbitratorResolutionModal for dispute resolution - Update escrow detail page with dispute-specific actions - Add comprehensive dispute type definitions - Support role-based access control (buyer/seller/arbitrator) - Implement fund distribution preview for split outcomes - Add visual timeline and status progression - Create reusable Badge component - Replace axios with fetch for consistency Features: - File disputes with validation and confirmation - View dispute details and evidence - Resolve disputes with outcome selection - Track dispute timeline and history - Role-based permissions and actions --- apps/frontend/app/escrow/[id]/page.tsx | 40 ++- .../detail/ArbitratorResolutionModal.tsx | 337 ++++++++++++++++++ .../escrow/detail/DisputeSection.tsx | 292 +++++++++++++++ .../components/escrow/detail/EscrowHeader.tsx | 18 +- .../escrow/detail/PartiesSection.tsx | 2 +- .../components/escrow/detail/TermsSection.tsx | 2 +- .../escrow/detail/file-dispute-modal.tsx | 331 ++++++++++++----- apps/frontend/components/ui/badge.tsx | 36 ++ apps/frontend/types/escrow.ts | 36 ++ 9 files changed, 991 insertions(+), 103 deletions(-) create mode 100644 apps/frontend/components/escrow/detail/ArbitratorResolutionModal.tsx create mode 100644 apps/frontend/components/escrow/detail/DisputeSection.tsx create mode 100644 apps/frontend/components/ui/badge.tsx diff --git a/apps/frontend/app/escrow/[id]/page.tsx b/apps/frontend/app/escrow/[id]/page.tsx index e3c2f9a..a528bbb 100644 --- a/apps/frontend/app/escrow/[id]/page.tsx +++ b/apps/frontend/app/escrow/[id]/page.tsx @@ -13,6 +13,8 @@ import TransactionHistory from '@/components/escrow/detail/TransactionHistory'; import ActivityFeed from '@/components/common/ActivityFeed'; import { IEscrowExtended } from '@/types/escrow'; import FileDisputeModal from '@/components/escrow/detail/file-dispute-modal'; +import DisputeSection from '@/components/escrow/detail/DisputeSection'; +import ArbitratorResolutionModal from '@/components/escrow/detail/ArbitratorResolutionModal'; import { Button } from '@/components/ui/button'; import { EscrowDetailSkeleton } from '@/components/ui/EscrowDetailSkeleton'; @@ -21,15 +23,20 @@ const EscrowDetailPage = () => { const { escrow, error, loading } = useEscrow(id as string); const { connected, publicKey, connect } = useWallet(); // Assuming wallet hook exists - const [userRole, setUserRole] = useState<'creator' | 'counterparty' | null>(null); + const [userRole, setUserRole] = useState<'creator' | 'counterparty' | 'arbitrator' | null>(null); const [disputeOpen, setDisputeOpen] = useState(false); + const [resolutionOpen, setResolutionOpen] = useState(false); + const [dispute, setDispute] = useState(null); useEffect(() => { if (escrow && publicKey) { if (escrow.creatorId === publicKey) { setUserRole('creator'); } else if (escrow.parties?.some((party: any) => party.userId === publicKey)) { - setUserRole('counterparty'); + const party = escrow.parties.find((p: any) => p.userId === publicKey); + if (party) { + setUserRole(party.role === 'ARBITRATOR' ? 'arbitrator' : 'counterparty'); + } } } }, [escrow, publicKey]); @@ -90,10 +97,25 @@ const EscrowDetailPage = () => { connected={connected} connect={connect} publicKey={publicKey} + onFileDispute={() => setDisputeOpen(true)} />
+ {/* Dispute Section (only show if disputed) */} + {escrow.status === 'DISPUTED' && ( + { + // Refresh escrow data to get updated status + window.location.reload(); + }} + /> + )} + {/* Parties Section */} @@ -115,6 +137,20 @@ const EscrowDetailPage = () => { open={disputeOpen} onClose={() => setDisputeOpen(false)} escrowId={escrow.id} + userRole={userRole} + escrowStatus={escrow.status} + /> + + setResolutionOpen(false)} + dispute={dispute} + escrowAmount={escrow.amount} + escrowAsset={escrow.asset} + onResolutionComplete={() => { + // Refresh escrow data to get updated status + window.location.reload(); + }} />
); diff --git a/apps/frontend/components/escrow/detail/ArbitratorResolutionModal.tsx b/apps/frontend/components/escrow/detail/ArbitratorResolutionModal.tsx new file mode 100644 index 0000000..e6ca011 --- /dev/null +++ b/apps/frontend/components/escrow/detail/ArbitratorResolutionModal.tsx @@ -0,0 +1,337 @@ +"use client"; + +import React, { useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, Scale, AlertTriangle, DollarSign } from 'lucide-react'; +import { IDispute, IDisputeResolution } from '@/types/escrow'; + +interface ArbitratorResolutionModalProps { + open: boolean; + onClose: () => void; + dispute: IDispute | null; + escrowAmount: string; + escrowAsset: string; + onResolutionComplete?: () => void; +} + +const resolutionOutcomes = [ + { value: 'RELEASED_TO_SELLER', label: 'Release to Seller', description: 'Full amount released to the seller' }, + { value: 'REFUNDED_TO_BUYER', label: 'Refund to Buyer', description: 'Full amount refunded to the buyer' }, + { value: 'SPLIT', label: 'Split Between Parties', description: 'Custom split between buyer and seller' }, +]; + +export default function ArbitratorResolutionModal({ + open, + onClose, + dispute, + escrowAmount, + escrowAsset, + onResolutionComplete, +}: ArbitratorResolutionModalProps) { + const [outcome, setOutcome] = useState(''); + const [notes, setNotes] = useState(''); + const [buyerPercentage, setBuyerPercentage] = useState('50'); + const [sellerPercentage, setSellerPercentage] = useState('50'); + const [loading, setLoading] = useState(false); + const [showConfirmation, setShowConfirmation] = useState(false); + + const amount = parseFloat(escrowAmount) || 0; + + // Update percentages when outcome changes + React.useEffect(() => { + if (outcome === 'RELEASED_TO_SELLER') { + setBuyerPercentage('0'); + setSellerPercentage('100'); + } else if (outcome === 'REFUNDED_TO_BUYER') { + setBuyerPercentage('100'); + setSellerPercentage('0'); + } else if (outcome === 'SPLIT') { + setBuyerPercentage('50'); + setSellerPercentage('50'); + } + }, [outcome]); + + const handleBuyerPercentageChange = (value: string) => { + const buyerPct = Math.max(0, Math.min(100, parseInt(value) || 0)); + setBuyerPercentage(buyerPct.toString()); + setSellerPercentage((100 - buyerPct).toString()); + }; + + const handleSellerPercentageChange = (value: string) => { + const sellerPct = Math.max(0, Math.min(100, parseInt(value) || 0)); + setSellerPercentage(sellerPct.toString()); + setBuyerPercentage((100 - sellerPct).toString()); + }; + + const calculateDistribution = () => { + const buyerAmount = (amount * parseInt(buyerPercentage)) / 100; + const sellerAmount = (amount * parseInt(sellerPercentage)) / 100; + return { buyerAmount, sellerAmount }; + }; + + const handleSubmit = async () => { + if (!outcome || !notes.trim()) { + alert('Please select an outcome and provide resolution notes'); + return; + } + + if (outcome === 'SPLIT' && (parseInt(buyerPercentage) + parseInt(sellerPercentage) !== 100)) { + alert('Split percentages must total 100%'); + return; + } + + setShowConfirmation(true); + }; + + const confirmResolution = async () => { + if (!dispute) return; + + try { + setLoading(true); + + const resolutionData = { + outcome, + notes: notes.trim(), + ...(outcome === 'SPLIT' && { + splitPercentage: { + buyer: parseInt(buyerPercentage), + seller: parseInt(sellerPercentage), + }, + }), + }; + + const response = await fetch(`/api/escrows/${dispute.escrowId}/dispute/resolve`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(resolutionData), + }); + + const result = await response.json(); + + if (result.success) { + alert('Dispute resolved successfully. Funds have been distributed according to your decision.'); + onResolutionComplete?.(); + onClose(); + // Reset form + setOutcome(''); + setNotes(''); + setBuyerPercentage('50'); + setSellerPercentage('50'); + setShowConfirmation(false); + } else { + alert(result.message || 'Failed to resolve dispute.'); + } + } catch (error: any) { + console.error('Resolution error:', error); + alert('Failed to resolve dispute. Please try again.'); + } finally { + setLoading(false); + } + }; + + const { buyerAmount, sellerAmount } = calculateDistribution(); + + if (!dispute) return null; + + return ( + <> + + + + + + Resolve Dispute + + + + {/* Dispute Summary */} +
+

Dispute Summary

+
+
+ Reason: +

{dispute.reason.replace('_', ' ')}

+
+
+ Severity: + + {dispute.severity} + +
+
+ Description: +

{dispute.description}

+
+
+
+ + {/* Resolution Outcome */} +
+ + +
+ + {/* Split Distribution (only for SPLIT outcome) */} + {outcome === 'SPLIT' && ( +
+ +
+
+
+ +
+ handleBuyerPercentageChange(e.target.value)} + className="w-20" + /> + % +
+
+
+ +
+ handleSellerPercentageChange(e.target.value)} + className="w-20" + /> + % +
+
+
+ + {/* Distribution Preview */} +
+
Distribution Preview
+
+
+ Buyer receives: + + {buyerAmount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 7 })} {escrowAsset} + +
+
+ Seller receives: + + {sellerAmount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 7 })} {escrowAsset} + +
+
+ Total: + {amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 7 })} {escrowAsset} +
+
+
+
+
+ )} + + {/* Resolution Notes */} +
+ +