Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/components/ArbitratorPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";

import { truncateAddress } from "@stellar-split/sdk";

interface Arbitrator {
address: string;
name: string;
resolvedDisputeCount: number | null;
}

interface Props {
/** List of arbitrators to display */
arbitrators: Arbitrator[];
/** Currently selected arbitrator address */
selectedAddress: string;
/** Callback when an arbitrator is selected */
onSelect: (address: string) => void;
/** Whether the component is disabled (e.g., dispute resolved) */
disabled?: boolean;
/** Heading text (defaults to "Assigned Arbitrators") */
heading?: string;
/** Show vote status badges (default: false) */
showVoteStatus?: boolean;
/** List of arbitrators who have voted (used when showVoteStatus is true) */
votedArbitrators?: string[];
}

/**
* ArbitratorPicker — displays a selectable list of arbitrators.
* Extracted from DisputePanel for reusability and testability.
* Can show vote status badges and is fully typed.
*/
export default function ArbitratorPicker({
arbitrators,
selectedAddress,
onSelect,
disabled = false,
heading = "Assigned Arbitrators",
showVoteStatus = false,
votedArbitrators = [],
}: Props) {
return (
<div>
<h3 className="text-sm font-semibold text-white mb-3">{heading}</h3>
<div className="space-y-2">
{arbitrators.length === 0 ? (
<p className="text-sm text-gray-500 bg-gray-900/40 border border-gray-700 rounded-lg p-4 text-center">
No arbitrators assigned
</p>
) : (
arbitrators.map((arb) => {
const isSelected = arb.address === selectedAddress;
const hasVoted = votedArbitrators.includes(arb.address);

return (
<button
key={arb.address}
type="button"
onClick={() => !disabled && onSelect(arb.address)}
disabled={disabled}
className={`w-full p-3 rounded-lg border-2 transition-colors text-left disabled:cursor-not-allowed disabled:opacity-60 ${
isSelected
? "border-indigo-500 bg-indigo-500/10"
: "border-gray-700 bg-gray-800 hover:border-gray-600"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{arb.name}
</p>
<p className="text-xs text-gray-400 font-mono truncate mt-0.5">
{truncateAddress(arb.address)}
</p>
{arb.resolvedDisputeCount !== null && (
<p className="text-xs text-gray-500 mt-1">
{arb.resolvedDisputeCount} resolved
</p>
)}
</div>

{showVoteStatus && (
<span
className={`text-xs px-2 py-1 rounded-full font-medium shrink-0 ${
hasVoted
? "bg-green-500/20 text-green-400"
: "bg-gray-700/50 text-gray-500"
}`}
>
{hasVoted ? "✓ Voted" : "Pending"}
</span>
)}
</div>
</button>
);
})
)}
</div>
</div>
);
}
38 changes: 13 additions & 25 deletions src/components/DisputePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Invoice } from "@stellar-split/sdk";
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;
Expand Down Expand Up @@ -281,31 +282,18 @@ export default function DisputePanel({ invoice, publicKey, onRefresh }: Props) {
</div>

{/* Arbitrators List */}
<div>
<h3 className="text-sm font-semibold text-white mb-3">Assigned Arbitrators</h3>
<div className="space-y-2">
{dispute.arbitrators.map((arb, idx) => {
const voted = dispute.votedArbitrators.includes(arb);
return (
<div
key={idx}
className="bg-gray-900/60 border border-gray-700 rounded-lg p-3 flex items-center justify-between"
>
<span className="text-sm font-mono text-gray-200">{truncateAddress(arb)}</span>
<span
className={`text-xs px-2 py-1 rounded-full font-medium ${
voted
? "bg-green-500/20 text-green-400"
: "bg-gray-700/50 text-gray-500"
}`}
>
{voted ? "✓ Voted" : "Pending"}
</span>
</div>
);
})}
</div>
</div>
<ArbitratorPicker
arbitrators={dispute.arbitrators.map((address) => ({
address,
name: truncateAddress(address),
resolvedDisputeCount: null,
}))}
selectedAddress=""
onSelect={() => {}}
disabled={true}
showVoteStatus={true}
votedArbitrators={dispute.votedArbitrators}
/>
</section>

{/* Evidence Upload Modal */}
Expand Down
63 changes: 59 additions & 4 deletions src/components/DisputeWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,59 @@ const DISPUTE_REASONS = [
"Other",
];

const TOTAL_STEPS = 4;

interface Props {
invoiceId: string;
onSubmit: (reason: string, description: string, arbitratorAddress?: string) => Promise<void>;
onClose: () => void;
}

/**
* StepIndicator — displays dot-based progress through the wizard steps.
* Completed steps are filled, the current step is highlighted, upcoming steps are muted.
*/
function StepIndicator({ current, total }: { current: number; total: number }) {
return (
<nav
aria-label={`Step ${current} of ${total}`}
className="flex items-center justify-center gap-2"
>
{Array.from({ length: total }, (_, i) => {
const stepNumber = i + 1;
const isCompleted = stepNumber < current;
const isCurrent = stepNumber === current;

return (
<div key={stepNumber} className="flex items-center gap-2">
<div
aria-current={isCurrent ? "step" : undefined}
className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${
isCompleted
? "bg-indigo-500"
: isCurrent
? "bg-indigo-400 ring-2 ring-indigo-400/40 scale-125"
: "bg-gray-600"
}`}
/>
{stepNumber < total && (
<div
className={`h-px w-6 transition-colors duration-300 ${
isCompleted ? "bg-indigo-500" : "bg-gray-700"
}`}
/>
)}
</div>
);
})}
</nav>
);
}

/**
* 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) {
const [step, setStep] = useState(1);
const [reason, setReason] = useState("");
Expand Down Expand Up @@ -78,10 +125,18 @@ export default function DisputeWizard({ invoiceId, onSubmit, onClose }: Props) {
</button>
</div>

{/* Step indicator */}
<div className="flex flex-col items-center gap-1.5">
<StepIndicator current={step} total={TOTAL_STEPS} />
<p className="text-xs text-gray-500 tabular-nums">
Step {step} of {TOTAL_STEPS}
</p>
</div>

{/* Step 1: Reason Selection */}
{step === 1 && (
<div className="space-y-3">
<p className="text-sm text-gray-400">Step 1 of 4: Select reason</p>
<p className="text-sm text-gray-400">Select reason</p>
{DISPUTE_REASONS.map((r) => (
<button
key={r}
Expand All @@ -102,7 +157,7 @@ export default function DisputeWizard({ invoiceId, onSubmit, onClose }: Props) {
{/* Step 2: Description */}
{step === 2 && (
<div className="space-y-3">
<p className="text-sm text-gray-400">Step 2 of 4: Describe the issue</p>
<p className="text-sm text-gray-400">Describe the issue</p>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
Expand All @@ -119,7 +174,7 @@ export default function DisputeWizard({ invoiceId, onSubmit, onClose }: Props) {
{/* Step 3: Arbitrator Selection */}
{step === 3 && (
<div className="space-y-3">
<p className="text-sm text-gray-400">Step 3 of 4: Select arbitrator (optional)</p>
<p className="text-sm text-gray-400">Select arbitrator (optional)</p>

{registryEmpty ? (
<div className="space-y-3">
Expand Down Expand Up @@ -198,7 +253,7 @@ export default function DisputeWizard({ invoiceId, onSubmit, onClose }: Props) {
{/* Step 4: Confirmation */}
{step === 4 && (
<div className="space-y-4">
<p className="text-sm text-gray-400">Step 4 of 4: Confirm dispute</p>
<p className="text-sm text-gray-400">Confirm dispute</p>
<div className="bg-gray-800 rounded-lg p-4 space-y-2">
<div>
<p className="text-xs text-gray-500">Reason</p>
Expand Down
Loading