diff --git a/apps/frontend/app/escrow/[id]/page.tsx b/apps/frontend/app/escrow/[id]/page.tsx index 1e6a107..e17fb3a 100644 --- a/apps/frontend/app/escrow/[id]/page.tsx +++ b/apps/frontend/app/escrow/[id]/page.tsx @@ -13,6 +13,9 @@ import ActivityFeed from '@/components/common/ActivityFeed'; import ConditionsList from '@/component/escrow/ConditionsList'; import { IParty } 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'; const EscrowDetailPage = () => { @@ -20,9 +23,11 @@ const EscrowDetailPage = () => { const { escrow, error, loading, refetch } = useEscrow(id as string); const { connected, publicKey, connect } = useWallet(); - const [userRole, setUserRole] = useState<'creator' | 'counterparty' | null>(null); + const [userRole, setUserRole] = useState<'creator' | 'counterparty' | 'arbitrator' | null>(null); const [currentParty, setCurrentParty] = useState(null); const [disputeOpen, setDisputeOpen] = useState(false); + const [resolutionOpen, setResolutionOpen] = useState(false); + const [dispute, setDispute] = useState(null); useEffect(() => { if (escrow && publicKey) { @@ -91,14 +96,29 @@ 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(); + }} + /> + )} { 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 */} +
+ +