diff --git a/src/components/ArbitratorPicker.tsx b/src/components/ArbitratorPicker.tsx index 1b47a95c..f77a1976 100644 --- a/src/components/ArbitratorPicker.tsx +++ b/src/components/ArbitratorPicker.tsx @@ -23,6 +23,17 @@ interface Props { showVoteStatus?: boolean; /** List of arbitrators who have voted (used when showVoteStatus is true) */ votedArbitrators?: string[]; + /** + * Enable assignment mode: a multi-select list used by the dispute wizard + * and panel to choose the arbitrators for a dispute. + */ + assignmentMode?: boolean; + /** Addresses currently selected for assignment (assignment mode only) */ + assignedAddresses?: string[]; + /** Callback when the assignment selection changes (assignment mode only) */ + onAssign?: (addresses: string[]) => void; + /** Maximum number of arbitrators that may be assigned (assignment mode only) */ + maxAssignments?: number; } /** @@ -38,26 +49,60 @@ export default function ArbitratorPicker({ heading = "Assigned Arbitrators", showVoteStatus = false, votedArbitrators = [], + assignmentMode = false, + assignedAddresses = [], + onAssign, + maxAssignments, }: Props) { + const atLimit = + maxAssignments !== undefined && assignedAddresses.length >= maxAssignments; + + const toggleAssignment = (address: string) => { + if (disabled || !onAssign) return; + const isAssigned = assignedAddresses.includes(address); + if (isAssigned) { + onAssign(assignedAddresses.filter((a) => a !== address)); + } else { + if (atLimit) return; + onAssign([...assignedAddresses, address]); + } + }; + return (
-

{heading}

+
+

{heading}

+ {assignmentMode && maxAssignments !== undefined && ( + + {assignedAddresses.length}/{maxAssignments} selected + + )} +
{arbitrators.length === 0 ? (

- No arbitrators assigned + {assignmentMode ? "No arbitrators available" : "No arbitrators assigned"}

) : ( arbitrators.map((arb) => { - const isSelected = arb.address === selectedAddress; + const isSelected = assignmentMode + ? assignedAddresses.includes(arb.address) + : arb.address === selectedAddress; const hasVoted = votedArbitrators.includes(arb.address); + const isDisabled = + disabled || (assignmentMode && !isSelected && atLimit); return (
- {showVoteStatus && ( + {assignmentMode ? ( - {hasVoted ? "✓ Voted" : "Pending"} + {isSelected ? "✓ Selected" : "Select"} + ) : ( + showVoteStatus && ( + + {hasVoted ? "✓ Voted" : "Pending"} + + ) )}
diff --git a/src/components/DisputePanel.tsx b/src/components/DisputePanel.tsx index a0b4626c..1eafbd01 100644 --- a/src/components/DisputePanel.tsx +++ b/src/components/DisputePanel.tsx @@ -6,26 +6,13 @@ import { truncateAddress } from "@stellar-split/sdk"; import { uploadToIpfs } from "@/lib/ipfs"; import { getSplitClient } from "@/lib/stellar"; import ArbitratorPicker from "@/components/ArbitratorPicker"; - -interface DisputeMetadata { - reason: string; - description: string; - initiator: string; - timestamp: number; - arbitrators: string[]; - evidenceLinks: Array<{ cid: string; timestamp: number; submitter: string; filename: string }>; -} - -interface DisputeVoteTally { - releaseVotes: number; - refundVotes: number; - votedArbitrators: string[]; -} - -interface DisputeStatus extends DisputeMetadata, DisputeVoteTally { - resolved: boolean; - outcome?: "Release" | "Refund"; -} +import { + type DisputeRecord, + type DisputeState, + type DisputeOutcome, + isVotingComplete, + canEnforceResolution, +} from "@/lib/arbitrators"; interface Props { invoice: Invoice; @@ -33,6 +20,21 @@ interface Props { onRefresh?: () => Promise; } +/** Emit a toast notification if the global toast container is available. */ +function notify(message: string, type: "success" | "error" | "info" = "info") { + if (typeof window !== "undefined" && (window as any).__toastContainer?.addToast) { + (window as any).__toastContainer.addToast(message, type); + } +} + +const STATE_LABELS: Record = { + Open: "Open", + Evidence: "Evidence Collection", + Voting: "Arbitrator Voting", + Resolved: "Resolved", + Enforced: "Enforced", +}; + /** * DisputePanel — Full arbitration interface for Disputed invoices. * Shows dispute metadata, allows evidence submission via IPFS, @@ -44,18 +46,22 @@ export default function DisputePanel({ invoice, publicKey, onRefresh }: Props) { const [uploadError, setUploadError] = useState(null); const [voting, setVoting] = useState(false); const [voteError, setVoteError] = useState(null); + const [enforcing, setEnforcing] = useState(false); + const [enforceError, setEnforceError] = useState(null); // Only render for Disputed invoices if ((invoice.status as string) !== "Disputed") return null; // Access dispute data from extended invoice type - const dispute = (invoice as Invoice & { disputeStatus?: DisputeStatus }).disputeStatus; + const dispute = (invoice as Invoice & { disputeRecord?: DisputeRecord }).disputeRecord; if (!dispute) return null; const isArbitrator = dispute.arbitrators.includes(publicKey); - const hasVoted = dispute.votedArbitrators.includes(publicKey); + const hasVoted = dispute.votes.some((v) => v.arbitrator === publicKey); + const votingComplete = isVotingComplete(dispute); + const canEnforce = canEnforceResolution(dispute); - const handleVote = async (voteType: "Release" | "Refund") => { + const handleVote = async (voteType: DisputeOutcome) => { if (!isArbitrator || hasVoted) return; setVoteError(null); @@ -69,23 +75,52 @@ export default function DisputePanel({ invoice, publicKey, onRefresh }: Props) { vote: voteType === "Release" ? 1 : 0, }); - // Show success toast - if (typeof window !== "undefined" && (window as any).__toastContainer?.addToast) { - (window as any).__toastContainer.addToast(`Vote "${voteType}" submitted successfully`, "success"); - } + notify(`Vote "${voteType}" submitted successfully`, "success"); // Refresh invoice data if (onRefresh) await onRefresh(); } catch (err) { - setVoteError(err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + setVoteError(message); + notify(`Failed to submit vote: ${message}`, "error"); } finally { setVoting(false); } }; - const totalVotes = dispute.releaseVotes + dispute.refundVotes; - const releasePercent = totalVotes > 0 ? (dispute.releaseVotes / totalVotes) * 100 : 0; - const refundPercent = totalVotes > 0 ? (dispute.refundVotes / totalVotes) * 100 : 0; + const handleEnforce = async () => { + if (!canEnforce) return; + + setEnforceError(null); + setEnforcing(true); + + try { + const client = getSplitClient(); + await (client as any).enforceDisputeResolution({ + invoiceId: invoice.id, + arbitrator: publicKey, + }); + + notify( + `Resolution enforced: ${dispute.outcome === "Release" ? "funds released" : "funds refunded"}`, + "success", + ); + + if (onRefresh) await onRefresh(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setEnforceError(message); + notify(`Failed to enforce resolution: ${message}`, "error"); + } finally { + setEnforcing(false); + } + }; + + const releaseVotes = dispute.votes.filter((v) => v.vote === "Release").length; + const refundVotes = dispute.votes.filter((v) => v.vote === "Refund").length; + const totalVotes = releaseVotes + refundVotes; + const releasePercent = totalVotes > 0 ? (releaseVotes / totalVotes) * 100 : 0; + const refundPercent = totalVotes > 0 ? (refundVotes / totalVotes) * 100 : 0; return ( <> @@ -102,17 +137,23 @@ export default function DisputePanel({ invoice, publicKey, onRefresh }: Props) { This invoice is under arbitration review

- {dispute.resolved && ( - - {dispute.outcome === "Release" ? "✓ Approved" : "↩️ Refunded"} +
+ + {STATE_LABELS[dispute.state]} - )} + {dispute.state === "Resolved" || dispute.state === "Enforced" ? ( + + {dispute.outcome === "Release" ? "✓ Approved" : "↩️ Refunded"} + {dispute.state === "Enforced" ? " • Enforced" : ""} + + ) : null} +
{/* Dispute Metadata */} @@ -137,6 +178,28 @@ export default function DisputePanel({ invoice, publicKey, onRefresh }: Props) { + {/* Dispute Timeline */} + {dispute.timeline.length > 0 && ( +
+

Timeline

+
    + {dispute.timeline.map((event, idx) => ( +
  1. + + {new Date(event.timestamp * 1000).toLocaleString()} + + + {event.description} + {event.actor ? ( + — {truncateAddress(event.actor)} + ) : null} + +
  2. + ))} +
+
+ )} + {/* Description */} {dispute.description && (
@@ -159,7 +222,7 @@ export default function DisputePanel({ invoice, publicKey, onRefresh }: Props) {
✓ Approve Release - {dispute.releaseVotes} + {releaseVotes}
↩️ Reject / Refund - {dispute.refundVotes} + {refundVotes}
{/* Arbitrator Actions */} - {isArbitrator && !dispute.resolved && ( + {isArbitrator && dispute.state !== "Resolved" && dispute.state !== "Enforced" && (
⚖️ @@ -232,13 +295,43 @@ export default function DisputePanel({ invoice, publicKey, onRefresh }: Props) {
)} + {/* Resolution Enforcement */} + {isArbitrator && votingComplete && dispute.state !== "Enforced" && ( +
+
+ 🔒 +

+ Enforce Resolution +

+
+

+ Voting is complete. The outcome is{" "} + {dispute.outcome}. Enforce this + resolution to settle the invoice on-chain. +

+ + {enforceError && ( +

+ {enforceError} +

+ )} +
+ )} + {/* Evidence Section */}

Evidence Submitted

)} + {event.type === "ArbitratorSelected" && ( +
+

+ Arbitrator +

+

+ {event.metadata?.arbitrator + ? truncateAddress(event.metadata.arbitrator) + : truncateAddress(event.actor)} +

+
+ )} + {event.type === "VoteCast" && event.metadata?.vote && (
)} + + {event.type === "ResolutionEnforced" && ( +
+

+ Resolution Enforced +

+
+ + ✓ Enforced + + {(event.metadata?.enforcementTxHash || + disputeStatus.enforcementTxHash) && ( + + {truncateAddress( + event.metadata?.enforcementTxHash || + disputeStatus.enforcementTxHash || + "", + )} + + )} +
+
+ )}
); @@ -217,10 +274,14 @@ function getEventIcon(type: DisputeEvent["type"]): string { return "⚠️"; case "EvidenceSubmitted": return "📎"; + case "ArbitratorSelected": + return "🧑‍⚖️"; case "VoteCast": return "⚖️"; case "DisputeResolved": return "✓"; + case "ResolutionEnforced": + return "🔒"; default: return "•"; } @@ -232,10 +293,14 @@ function getEventColor(type: DisputeEvent["type"]): string { return "border-red-500 bg-red-500 text-white"; case "EvidenceSubmitted": return "border-blue-500 bg-blue-500 text-white"; + case "ArbitratorSelected": + return "border-purple-500 bg-purple-500 text-white"; case "VoteCast": return "border-indigo-500 bg-indigo-500 text-white"; case "DisputeResolved": return "border-green-500 bg-green-500 text-white"; + case "ResolutionEnforced": + return "border-emerald-500 bg-emerald-500 text-white"; default: return "border-gray-600 bg-gray-900 text-gray-400"; } @@ -247,10 +312,14 @@ function getEventTitle(type: DisputeEvent["type"]): string { return "Dispute Opened"; case "EvidenceSubmitted": return "Evidence Submitted"; + case "ArbitratorSelected": + return "Arbitrator Selected"; case "VoteCast": return "Vote Cast"; case "DisputeResolved": return "Dispute Resolved"; + case "ResolutionEnforced": + return "Resolution Enforced"; default: return "Event"; } diff --git a/src/components/DisputeWizard.tsx b/src/components/DisputeWizard.tsx index 7057832c..abdb320d 100644 --- a/src/components/DisputeWizard.tsx +++ b/src/components/DisputeWizard.tsx @@ -3,6 +3,9 @@ import { useState } from "react"; import { searchArbitrators, getArbitrators } from "@/lib/arbitrators"; import type { Arbitrator } from "@/lib/arbitrators"; +import { createDispute } from "@/lib/disputes"; +import type { DisputeRecord } from "@/lib/disputes"; +import { notifyDisputeOpened } from "@/lib/disputeNotifications"; const DISPUTE_REASONS = [ "Non-delivery", @@ -17,6 +20,8 @@ interface Props { invoiceId: string; onSubmit: (reason: string, description: string, arbitratorAddress?: string) => Promise; onClose: () => void; + /** Called after a dispute is successfully created so the invoice can be refreshed. */ + onCreated?: (dispute: DisputeRecord) => void; } /** @@ -64,7 +69,7 @@ function StepIndicator({ current, total }: { current: number; total: number }) { * DisputeWizard — multi-step modal form for filing an on-chain dispute. * Guides the user through: reason → description → arbitrator → confirmation. */ -export default function DisputeWizard({ invoiceId, onSubmit, onClose }: Props) { +export default function DisputeWizard({ invoiceId, onSubmit, onClose, onCreated }: Props) { const [step, setStep] = useState(1); const [reason, setReason] = useState(""); const [description, setDescription] = useState(""); @@ -72,6 +77,7 @@ export default function DisputeWizard({ invoiceId, onSubmit, onClose }: Props) { const [arbitratorSearch, setArbitratorSearch] = useState(""); const [manualArbitrator, setManualArbitrator] = useState(""); const [useManualEntry, setUseManualEntry] = useState(false); + const [evidenceNote, setEvidenceNote] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -94,9 +100,29 @@ export default function DisputeWizard({ invoiceId, onSubmit, onClose }: Props) { const handleConfirm = async () => { setError(null); + + if (!reason) { + setError("Please select a dispute reason."); + return; + } + if (description.trim().length < 20) { + setError("Description must be at least 20 characters."); + return; + } + setSubmitting(true); try { + const dispute = await createDispute({ + invoiceId, + reason, + description: description.trim(), + arbitratorAddress: effectiveArbitrator || undefined, + evidence: evidenceNote.trim() ? [{ note: evidenceNote.trim() }] : undefined, + }); + await onSubmit(reason, description, effectiveArbitrator || undefined); + notifyDisputeOpened(dispute); + onCreated?.(dispute); onClose(); } catch (err) { setError(String(err)); @@ -270,6 +296,17 @@ export default function DisputeWizard({ invoiceId, onSubmit, onClose }: Props) {
)}
+
+

Evidence (optional)

+