From 8ff7266dbfdefcd33a15efd233e74ae81abb4ac4 Mon Sep 17 00:00:00 2001 From: razeprasine Date: Wed, 22 Jul 2026 14:49:15 +0100 Subject: [PATCH 1/8] Cards, Status Filter updates --- .gitignore | 2 +- hooks/index.ts | 3 + hooks/useDispute.ts | 50 +++++++ hooks/useDisputes.ts | 44 ++++++ hooks/useVoting.ts | 50 +++++++ pages/api/disputes/[id].ts | 238 ++++++++++++++++++++++++++++++++ pages/api/disputes/index.ts | 264 ++++++++++++++++++++++++++++++++++++ shared/types/dispute.ts | 53 ++++++++ 8 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 hooks/useDispute.ts create mode 100644 hooks/useDisputes.ts create mode 100644 hooks/useVoting.ts create mode 100644 pages/api/disputes/[id].ts create mode 100644 pages/api/disputes/index.ts create mode 100644 shared/types/dispute.ts diff --git a/.gitignore b/.gitignore index fcdd599..30ffc71 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,6 @@ yarn-error.log* *.sublime-workspace # Local scripts output - # Auto-generated contract bindings (regenerated via npm run codegen) /shared/contracts-gen +fix.md diff --git a/hooks/index.ts b/hooks/index.ts index e447294..3681fdc 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -9,3 +9,6 @@ export * from "./useSubscription"; export * from "./useContractEvents"; export * from "./useUSDCPrice"; export * from "./useUserProfile"; +export * from './useDisputes'; +export * from './useDispute'; +export * from './useVoting'; diff --git a/hooks/useDispute.ts b/hooks/useDispute.ts new file mode 100644 index 0000000..12fbe27 --- /dev/null +++ b/hooks/useDispute.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from 'react' +import type { Dispute } from '../shared/types/dispute' +import type { DisputeDetailResponse } from '../pages/api/disputes/[id]' + +type DisputeStatus = 'idle' | 'loading' | 'success' | 'error' + +interface UseDisputeResult { + dispute: Dispute | null + status: DisputeStatus + error: string | null + refetch: () => Promise +} + +export function useDispute(id: string | undefined): UseDisputeResult { + const [dispute, setDispute] = useState(null) + const [status, setStatus] = useState('idle') + const [error, setError] = useState(null) + + const fetchDispute = useCallback(async () => { + if (!id) { + setDispute(null) + setStatus('idle') + return + } + + setStatus('loading') + setError(null) + + try { + const response = await fetch(`/api/disputes/${encodeURIComponent(id)}`) + if (!response.ok) { + throw new Error(`Failed to load dispute: ${response.status}`) + } + + const payload = (await response.json()) as DisputeDetailResponse + setDispute(payload.dispute) + setStatus('success') + } catch { + setDispute(null) + setStatus('error') + setError('Unable to load dispute details. Please try again.') + } + }, [id]) + + useEffect(() => { + fetchDispute() + }, [fetchDispute]) + + return { dispute, status, error, refetch: fetchDispute } +} diff --git a/hooks/useDisputes.ts b/hooks/useDisputes.ts new file mode 100644 index 0000000..efd35c3 --- /dev/null +++ b/hooks/useDisputes.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useState } from 'react' +import type { Dispute } from '../shared/types/dispute' +import type { DisputesListResponse } from '../pages/api/disputes/index' + +type DisputesStatus = 'idle' | 'loading' | 'success' | 'error' + +interface UseDisputesResult { + disputes: Dispute[] + status: DisputesStatus + error: string | null + refetch: () => Promise +} + +export function useDisputes(): UseDisputesResult { + const [disputes, setDisputes] = useState([]) + const [status, setStatus] = useState('idle') + const [error, setError] = useState(null) + + const fetchDisputes = useCallback(async () => { + setStatus('loading') + setError(null) + + try { + const response = await fetch('/api/disputes') + if (!response.ok) { + throw new Error(`Failed to load disputes: ${response.status}`) + } + + const payload = (await response.json()) as DisputesListResponse + setDisputes(payload.disputes) + setStatus('success') + } catch { + setDisputes([]) + setStatus('error') + setError('Unable to load disputes. Please try again.') + } + }, []) + + useEffect(() => { + fetchDisputes() + }, [fetchDisputes]) + + return { disputes, status, error, refetch: fetchDisputes } +} diff --git a/hooks/useVoting.ts b/hooks/useVoting.ts new file mode 100644 index 0000000..0331ef6 --- /dev/null +++ b/hooks/useVoting.ts @@ -0,0 +1,50 @@ +import { useState, useCallback } from 'react' +import type { VoteOption } from '../shared/types/dispute' + +type VoteSubmitStatus = 'idle' | 'submitting' | 'success' | 'error' + +interface UseVotingResult { + submitStatus: VoteSubmitStatus + submitError: string | null + submitVote: (disputeId: string, option: VoteOption, justification?: string) => Promise + reset: () => void +} + +export function useVoting(): UseVotingResult { + const [submitStatus, setSubmitStatus] = useState('idle') + const [submitError, setSubmitError] = useState(null) + + const submitVote = useCallback( + async (disputeId: string, option: VoteOption, justification?: string): Promise => { + setSubmitStatus('submitting') + setSubmitError(null) + + try { + const response = await fetch(`/api/disputes/${encodeURIComponent(disputeId)}/vote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ option, justification }), + }) + + if (!response.ok) { + throw new Error(`Vote submission failed: ${response.status}`) + } + + setSubmitStatus('success') + return true + } catch (err) { + setSubmitStatus('error') + setSubmitError(err instanceof Error ? err.message : 'Failed to submit vote') + return false + } + }, + [], + ) + + const reset = useCallback(() => { + setSubmitStatus('idle') + setSubmitError(null) + }, []) + + return { submitStatus, submitError, submitVote, reset } +} diff --git a/pages/api/disputes/[id].ts b/pages/api/disputes/[id].ts new file mode 100644 index 0000000..3b56694 --- /dev/null +++ b/pages/api/disputes/[id].ts @@ -0,0 +1,238 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import type { Dispute } from '../../../shared/types/dispute' + +export const runtime = 'edge' + +export interface DisputeDetailResponse { + dispute: Dispute | null + source: 'backend' | 'mock' +} + +function getMockDispute(id: string): Dispute | null { + const disputes: Dispute[] = [ + { + id: 'dispute-001', + title: 'Milestone delivery dispute', + description: 'Freelancer claims full milestone payment but client says work is incomplete.', + plaintiffAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + plaintiffName: 'Alex Freelancer', + defendantAddress: 'GXYZ1234567890ABCDEF1234567890ABCDEF1234567890', + defendantName: 'Bob Client', + amount: 2500, + status: 'active', + evidence: [ + { + id: 'ev-001', + disputeId: 'dispute-001', + submittedBy: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + fileName: 'deliverables.zip', + fileSize: 4_200_000, + cid: 'QmTzQ1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1', + mimeType: 'application/zip', + description: 'Completed milestone deliverables', + status: 'submitted', + submittedAt: '2026-07-18T09:15:00.000Z', + }, + ], + jurors: [ + { + walletAddress: 'GJUROR1AAAAABBBBBCCCCCDDDDDEEEEEFFFFF1111111', + displayName: 'Juror One', + status: 'selected', + }, + ], + votes: [], + createdAt: '2026-07-15T08:00:00.000Z', + deadline: '2026-07-25T08:00:00.000Z', + }, + { + id: 'dispute-002', + title: 'Quality of work disagreement', + description: 'Client disputes the quality of submitted design work and requests partial refund.', + plaintiffAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + plaintiffName: 'Alex Freelancer', + defendantAddress: 'GXYZ1234567890ABCDEF1234567890ABCDEF1234567890', + defendantName: 'Carol Client', + amount: 1200, + status: 'voting', + evidence: [ + { + id: 'ev-002', + disputeId: 'dispute-002', + submittedBy: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + fileName: 'design-mockups.pdf', + fileSize: 1_800_000, + cid: 'QmTzQ2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2', + mimeType: 'application/pdf', + description: 'Final design mockups delivered to client', + status: 'approved', + submittedAt: '2026-07-14T14:30:00.000Z', + }, + { + id: 'ev-003', + disputeId: 'dispute-002', + submittedBy: 'GXYZ1234567890ABCDEF1234567890ABCDEF1234567890', + fileName: 'feedback-email.txt', + fileSize: 4_200, + cid: 'QmTzQ3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3', + mimeType: 'text/plain', + description: 'Email thread showing client feedback on deliverables', + status: 'submitted', + submittedAt: '2026-07-16T10:00:00.000Z', + }, + ], + jurors: [ + { + walletAddress: 'GJUROR1AAAAABBBBBCCCCCDDDDDEEEEEFFFFF1111111', + displayName: 'Juror One', + status: 'voted', + vote: 'plaintiff', + votedAt: '2026-07-20T12:00:00.000Z', + }, + { + walletAddress: 'GJUROR2AAAAABBBBBCCCCCDDDDDEEEEEFFFFF2222222', + displayName: 'Juror Two', + status: 'voted', + vote: 'defendant', + votedAt: '2026-07-20T14:30:00.000Z', + }, + { + walletAddress: 'GJUROR3AAAAABBBBBCCCCCDDDDDEEEEEFFFFF3333333', + displayName: 'Juror Three', + status: 'selected', + }, + ], + votes: [ + { + id: 'vote-001', + disputeId: 'dispute-002', + jurorAddress: 'GJUROR1AAAAABBBBBCCCCCDDDDDEEEEEFFFFF1111111', + option: 'plaintiff', + submittedAt: '2026-07-20T12:00:00.000Z', + }, + { + id: 'vote-002', + disputeId: 'dispute-002', + jurorAddress: 'GJUROR2AAAAABBBBBCCCCCDDDDDEEEEEFFFFF2222222', + option: 'defendant', + submittedAt: '2026-07-20T14:30:00.000Z', + }, + ], + createdAt: '2026-07-12T10:00:00.000Z', + deadline: '2026-07-26T10:00:00.000Z', + }, + { + id: 'dispute-003', + title: 'Late submission penalty', + description: 'Work was submitted 3 days past deadline. Client demands full refund.', + plaintiffAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + plaintiffName: 'Diana Freelancer', + defendantAddress: 'GXYZ1234567890ABCDEF1234567890ABCDEF1234567890', + defendantName: 'Eve Client', + amount: 3000, + status: 'resolved', + evidence: [ + { + id: 'ev-004', + disputeId: 'dispute-003', + submittedBy: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + fileName: 'submission-timestamp.pdf', + fileSize: 560_000, + cid: 'QmTzQ4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4', + mimeType: 'application/pdf', + description: 'Proof of submission timestamp from platform', + status: 'approved', + submittedAt: '2026-07-10T23:59:00.000Z', + }, + ], + jurors: [ + { + walletAddress: 'GJUROR1AAAAABBBBBCCCCCDDDDDEEEEEFFFFF1111111', + displayName: 'Juror One', + status: 'voted', + vote: 'split', + votedAt: '2026-07-18T09:00:00.000Z', + }, + { + walletAddress: 'GJUROR2AAAAABBBBBCCCCCDDDDDEEEEEFFFFF2222222', + displayName: 'Juror Two', + status: 'voted', + vote: 'defendant', + votedAt: '2026-07-18T10:30:00.000Z', + }, + ], + votes: [ + { + id: 'vote-003', + disputeId: 'dispute-003', + jurorAddress: 'GJUROR1AAAAABBBBBCCCCCDDDDDEEEEEFFFFF1111111', + option: 'split', + justification: 'Both parties share responsibility for the delay.', + submittedAt: '2026-07-18T09:00:00.000Z', + }, + { + id: 'vote-004', + disputeId: 'dispute-003', + jurorAddress: 'GJUROR2AAAAABBBBBCCCCCDDDDDEEEEEFFFFF2222222', + option: 'defendant', + submittedAt: '2026-07-18T10:30:00.000Z', + }, + ], + verdict: 'split', + createdAt: '2026-07-05T08:00:00.000Z', + deadline: '2026-07-19T08:00:00.000Z', + resolvedAt: '2026-07-19T08:00:00.000Z', + }, + ] + + return disputes.find(d => d.id === id) ?? null +} + +export default async function handler( + req: NextRequest, + { params }: { params: { id: string } }, +): Promise { + if (req.method !== 'GET') { + return NextResponse.json({ error: 'Method not allowed' }, { status: 405 }) + } + + const { id } = params + const cacheHeaders = { 'Cache-Control': 's-maxage=30, stale-while-revalidate=120' } + const backendBaseUrl = process.env.DISPUTE_API_BASE_URL + + if (!backendBaseUrl) { + const dispute = getMockDispute(id) + return NextResponse.json( + { dispute, source: 'mock' } satisfies DisputeDetailResponse, + { headers: cacheHeaders }, + ) + } + + try { + const response = await fetch(`${backendBaseUrl}/disputes/${id}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + const dispute = getMockDispute(id) + return NextResponse.json( + { dispute, source: 'mock' } satisfies DisputeDetailResponse, + { headers: cacheHeaders }, + ) + } + + const payload = (await response.json()) as Dispute + return NextResponse.json( + { dispute: payload, source: 'backend' } satisfies DisputeDetailResponse, + { headers: cacheHeaders }, + ) + } catch { + const dispute = getMockDispute(id) + return NextResponse.json( + { dispute, source: 'mock' } satisfies DisputeDetailResponse, + { headers: cacheHeaders }, + ) + } +} diff --git a/pages/api/disputes/index.ts b/pages/api/disputes/index.ts new file mode 100644 index 0000000..db5d3bf --- /dev/null +++ b/pages/api/disputes/index.ts @@ -0,0 +1,264 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import type { Dispute, Evidence, Juror, Vote } from '../../../shared/types/dispute' + +export const runtime = 'edge' + +export interface DisputesListResponse { + disputes: Dispute[] + source: 'backend' | 'mock' +} + +interface BackendDispute { + id: string + title: string + description: string + plaintiffAddress: string + plaintiffName: string + defendantAddress: string + defendantName: string + amount: number + status: string + evidence: Evidence[] + jurors: Juror[] + votes: Vote[] + verdict?: string + createdAt: string + deadline: string + resolvedAt?: string +} + +function getMockDisputes(): Dispute[] { + return [ + { + id: 'dispute-001', + title: 'Milestone delivery dispute', + description: 'Freelancer claims full milestone payment but client says work is incomplete.', + plaintiffAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + plaintiffName: 'Alex Freelancer', + defendantAddress: 'GXYZ1234567890ABCDEF1234567890ABCDEF1234567890', + defendantName: 'Bob Client', + amount: 2500, + status: 'active', + evidence: [ + { + id: 'ev-001', + disputeId: 'dispute-001', + submittedBy: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + fileName: 'deliverables.zip', + fileSize: 4_200_000, + cid: 'QmTzQ1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1', + mimeType: 'application/zip', + description: 'Completed milestone deliverables', + status: 'submitted', + submittedAt: '2026-07-18T09:15:00.000Z', + }, + ], + jurors: [ + { + walletAddress: 'GJUROR1AAAAABBBBBCCCCCDDDDDEEEEEFFFFF1111111', + displayName: 'Juror One', + status: 'selected', + }, + ], + votes: [], + createdAt: '2026-07-15T08:00:00.000Z', + deadline: '2026-07-25T08:00:00.000Z', + }, + { + id: 'dispute-002', + title: 'Quality of work disagreement', + description: 'Client disputes the quality of submitted design work and requests partial refund.', + plaintiffAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + plaintiffName: 'Alex Freelancer', + defendantAddress: 'GXYZ1234567890ABCDEF1234567890ABCDEF1234567890', + defendantName: 'Carol Client', + amount: 1200, + status: 'voting', + evidence: [ + { + id: 'ev-002', + disputeId: 'dispute-002', + submittedBy: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + fileName: 'design-mockups.pdf', + fileSize: 1_800_000, + cid: 'QmTzQ2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2', + mimeType: 'application/pdf', + description: 'Final design mockups delivered to client', + status: 'approved', + submittedAt: '2026-07-14T14:30:00.000Z', + }, + { + id: 'ev-003', + disputeId: 'dispute-002', + submittedBy: 'GXYZ1234567890ABCDEF1234567890ABCDEF1234567890', + fileName: 'feedback-email.txt', + fileSize: 4_200, + cid: 'QmTzQ3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3', + mimeType: 'text/plain', + description: 'Email thread showing client feedback on deliverables', + status: 'submitted', + submittedAt: '2026-07-16T10:00:00.000Z', + }, + ], + jurors: [ + { + walletAddress: 'GJUROR1AAAAABBBBBCCCCCDDDDDEEEEEFFFFF1111111', + displayName: 'Juror One', + status: 'voted', + vote: 'plaintiff', + votedAt: '2026-07-20T12:00:00.000Z', + }, + { + walletAddress: 'GJUROR2AAAAABBBBBCCCCCDDDDDEEEEEFFFFF2222222', + displayName: 'Juror Two', + status: 'voted', + vote: 'defendant', + votedAt: '2026-07-20T14:30:00.000Z', + }, + { + walletAddress: 'GJUROR3AAAAABBBBBCCCCCDDDDDEEEEEFFFFF3333333', + displayName: 'Juror Three', + status: 'selected', + }, + ], + votes: [ + { + id: 'vote-001', + disputeId: 'dispute-002', + jurorAddress: 'GJUROR1AAAAABBBBBCCCCCDDDDDEEEEEFFFFF1111111', + option: 'plaintiff', + submittedAt: '2026-07-20T12:00:00.000Z', + }, + { + id: 'vote-002', + disputeId: 'dispute-002', + jurorAddress: 'GJUROR2AAAAABBBBBCCCCCDDDDDEEEEEFFFFF2222222', + option: 'defendant', + submittedAt: '2026-07-20T14:30:00.000Z', + }, + ], + createdAt: '2026-07-12T10:00:00.000Z', + deadline: '2026-07-26T10:00:00.000Z', + }, + { + id: 'dispute-003', + title: 'Late submission penalty', + description: 'Work was submitted 3 days past deadline. Client demands full refund.', + plaintiffAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + plaintiffName: 'Diana Freelancer', + defendantAddress: 'GXYZ1234567890ABCDEF1234567890ABCDEF1234567890', + defendantName: 'Eve Client', + amount: 3000, + status: 'resolved', + evidence: [ + { + id: 'ev-004', + disputeId: 'dispute-003', + submittedBy: 'GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + fileName: 'submission-timestamp.pdf', + fileSize: 560_000, + cid: 'QmTzQ4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4', + mimeType: 'application/pdf', + description: 'Proof of submission timestamp from platform', + status: 'approved', + submittedAt: '2026-07-10T23:59:00.000Z', + }, + ], + jurors: [ + { + walletAddress: 'GJUROR1AAAAABBBBBCCCCCDDDDDEEEEEFFFFF1111111', + displayName: 'Juror One', + status: 'voted', + vote: 'split', + votedAt: '2026-07-18T09:00:00.000Z', + }, + { + walletAddress: 'GJUROR2AAAAABBBBBCCCCCDDDDDEEEEEFFFFF2222222', + displayName: 'Juror Two', + status: 'voted', + vote: 'defendant', + votedAt: '2026-07-18T10:30:00.000Z', + }, + ], + votes: [ + { + id: 'vote-003', + disputeId: 'dispute-003', + jurorAddress: 'GJUROR1AAAAABBBBBCCCCCDDDDDEEEEEFFFFF1111111', + option: 'split', + justification: 'Both parties share responsibility for the delay.', + submittedAt: '2026-07-18T09:00:00.000Z', + }, + { + id: 'vote-004', + disputeId: 'dispute-003', + jurorAddress: 'GJUROR2AAAAABBBBBCCCCCDDDDDEEEEEFFFFF2222222', + option: 'defendant', + submittedAt: '2026-07-18T10:30:00.000Z', + }, + ], + verdict: 'split', + createdAt: '2026-07-05T08:00:00.000Z', + deadline: '2026-07-19T08:00:00.000Z', + resolvedAt: '2026-07-19T08:00:00.000Z', + }, + ] +} + +function normalizeBackendDisputes(payload: BackendDispute[]): Dispute[] { + return payload.map(dispute => ({ + ...dispute, + amount: typeof dispute.amount === 'number' ? dispute.amount : 0, + evidence: Array.isArray(dispute.evidence) ? dispute.evidence : [], + jurors: Array.isArray(dispute.jurors) ? dispute.jurors : [], + votes: Array.isArray(dispute.votes) ? dispute.votes : [], + status: (['pending', 'active', 'voting', 'resolved', 'appealed'].includes(dispute.status) + ? dispute.status + : 'pending') as Dispute['status'], + verdict: (['plaintiff', 'defendant', 'split'].includes(dispute.verdict ?? '') + ? dispute.verdict + : undefined) as Dispute['verdict'], + })) +} + +export default async function handler(req: NextRequest): Promise { + if (req.method !== 'GET') { + return NextResponse.json({ error: 'Method not allowed' }, { status: 405 }) + } + + const cacheHeaders = { 'Cache-Control': 's-maxage=30, stale-while-revalidate=120' } + const backendBaseUrl = process.env.DISPUTE_API_BASE_URL + + if (!backendBaseUrl) { + return NextResponse.json( + { disputes: getMockDisputes(), source: 'mock' } satisfies DisputesListResponse, + { headers: cacheHeaders }, + ) + } + + try { + const response = await fetch(`${backendBaseUrl}/disputes`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + return NextResponse.json( + { disputes: getMockDisputes(), source: 'mock' } satisfies DisputesListResponse, + { headers: cacheHeaders }, + ) + } + + const payload = (await response.json()) as BackendDispute[] + return NextResponse.json( + { disputes: normalizeBackendDisputes(payload), source: 'backend' } satisfies DisputesListResponse, + { headers: cacheHeaders }, + ) + } catch { + return NextResponse.json( + { disputes: getMockDisputes(), source: 'mock' } satisfies DisputesListResponse, + { headers: cacheHeaders }, + ) + } +} diff --git a/shared/types/dispute.ts b/shared/types/dispute.ts new file mode 100644 index 0000000..c277baf --- /dev/null +++ b/shared/types/dispute.ts @@ -0,0 +1,53 @@ +export type DisputeStatus = 'pending' | 'active' | 'voting' | 'resolved' | 'appealed' +export type VoteOption = 'plaintiff' | 'defendant' | 'split' +export type EvidenceStatus = 'submitted' | 'approved' | 'rejected' +export type JurorStatus = 'selected' | 'voted' | 'dismissed' + +export interface Evidence { + id: string + disputeId: string + submittedBy: string + fileName: string + fileSize: number + cid: string + mimeType: string + description: string + status: EvidenceStatus + submittedAt: string +} + +export interface Juror { + walletAddress: string + displayName: string + status: JurorStatus + votedAt?: string + vote?: VoteOption +} + +export interface Vote { + id: string + disputeId: string + jurorAddress: string + option: VoteOption + justification?: string + submittedAt: string +} + +export interface Dispute { + id: string + title: string + description: string + plaintiffAddress: string + plaintiffName: string + defendantAddress: string + defendantName: string + amount: number + status: DisputeStatus + evidence: Evidence[] + jurors: Juror[] + votes: Vote[] + verdict?: VoteOption + createdAt: string + deadline: string + resolvedAt?: string +} From 15b4e2b3f5044ea554816a9b600a976987c07842 Mon Sep 17 00:00:00 2001 From: razeprasine Date: Wed, 22 Jul 2026 15:29:26 +0100 Subject: [PATCH 2/8] Court Room changes --- components/molecules/dispute-card/index.tsx | 94 +++++++ .../molecules/dispute-filters/index.tsx | 65 +++++ components/molecules/index.tsx | 2 + components/organisms/courtroom/index.tsx | 266 ++++++++++++++++++ components/organisms/index.tsx | 1 + pages/dashboard/disputes.tsx | 130 ++++++++- pages/dashboard/disputes/[id].tsx | 131 +++++++++ 7 files changed, 677 insertions(+), 12 deletions(-) create mode 100644 components/molecules/dispute-card/index.tsx create mode 100644 components/molecules/dispute-filters/index.tsx create mode 100644 components/organisms/courtroom/index.tsx create mode 100644 pages/dashboard/disputes/[id].tsx diff --git a/components/molecules/dispute-card/index.tsx b/components/molecules/dispute-card/index.tsx new file mode 100644 index 0000000..1c701eb --- /dev/null +++ b/components/molecules/dispute-card/index.tsx @@ -0,0 +1,94 @@ +import Link from 'next/link' +import type { Dispute, DisputeStatus } from '../../../shared/types/dispute' + +interface DisputeCardProps { + dispute: Dispute +} + +const STATUS_LABELS: Record = { + pending: 'Pending', + active: 'Active', + voting: 'Voting', + resolved: 'Resolved', + appealed: 'Appealed', +} + +const STATUS_COLORS: Record = { + pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', + active: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + voting: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + resolved: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', + appealed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', +} + +function getRemainingTime(deadline: string): string { + const diff = new Date(deadline).getTime() - Date.now() + if (diff <= 0) return 'Expired' + const days = Math.floor(diff / 86400000) + const hours = Math.floor((diff % 86400000) / 3600000) + if (days > 0) return `${days}d ${hours}h left` + return `${hours}h left` +} + +export function DisputeCard({ dispute }: DisputeCardProps) { + return ( + +
+
+
+

+ {dispute.title} +

+

+ {dispute.description} +

+
+ + {STATUS_LABELS[dispute.status]} + +
+ +
+
+ + {dispute.plaintiffName} + vs + {dispute.defendantName} +
+ + | + +
+ + + + {dispute.amount.toLocaleString()} USDC +
+ + | + +
+ + + + {getRemainingTime(dispute.deadline)} +
+
+ + {dispute.jurors.length > 0 && ( +
+
+ + + + {dispute.jurors.length} juror{dispute.jurors.length !== 1 ? 's' : ''} assigned + {dispute.votes.length > 0 && ( + ยท {dispute.votes.length} vote{dispute.votes.length !== 1 ? 's' : ''} cast + )} +
+
+ )} +
+ + ) +} diff --git a/components/molecules/dispute-filters/index.tsx b/components/molecules/dispute-filters/index.tsx new file mode 100644 index 0000000..9d49f40 --- /dev/null +++ b/components/molecules/dispute-filters/index.tsx @@ -0,0 +1,65 @@ +import type { DisputeStatus } from '../../../shared/types/dispute' + +type RoleFilter = 'all' | 'plaintiff' | 'defendant' | 'juror' + +interface DisputeFiltersProps { + statusFilter: DisputeStatus | 'all' + roleFilter: RoleFilter + onStatusChange: (status: DisputeStatus | 'all') => void + onRoleChange: (role: RoleFilter) => void +} + +const STATUS_OPTIONS: { value: DisputeStatus | 'all'; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'active', label: 'Active' }, + { value: 'voting', label: 'Voting' }, + { value: 'resolved', label: 'Resolved' }, + { value: 'pending', label: 'Pending' }, + { value: 'appealed', label: 'Appealed' }, +] + +const ROLE_OPTIONS: { value: RoleFilter; label: string }[] = [ + { value: 'all', label: 'All Roles' }, + { value: 'plaintiff', label: 'As Plaintiff' }, + { value: 'defendant', label: 'As Defendant' }, + { value: 'juror', label: 'As Juror' }, +] + +export function DisputeFilters({ + statusFilter, + roleFilter, + onStatusChange, + onRoleChange, +}: DisputeFiltersProps) { + return ( +
+
+ {STATUS_OPTIONS.map(opt => ( + + ))} +
+ + +
+ ) +} diff --git a/components/molecules/index.tsx b/components/molecules/index.tsx index 227d858..33333dd 100644 --- a/components/molecules/index.tsx +++ b/components/molecules/index.tsx @@ -8,3 +8,5 @@ export * from './markdown-editor' export * from './deliverable-viewer' export * from './dashboard-sidebar' export * from './dispute-event-feed' +export * from './dispute-card' +export * from './dispute-filters' diff --git a/components/organisms/courtroom/index.tsx b/components/organisms/courtroom/index.tsx new file mode 100644 index 0000000..ec27115 --- /dev/null +++ b/components/organisms/courtroom/index.tsx @@ -0,0 +1,266 @@ +import type { Dispute, DisputeStatus, Evidence as EvidenceType } from '../../../shared/types/dispute' + +interface CourtroomProps { + dispute: Dispute +} + +const STATUS_LABELS: Record = { + pending: 'Pending', + active: 'Active', + voting: 'Voting', + resolved: 'Resolved', + appealed: 'Appealed', +} + +const STATUS_COLORS: Record = { + pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', + active: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + voting: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + resolved: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', + appealed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', +} + +const STATUS_BG: Record = { + pending: 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-900', + active: 'bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-900', + voting: 'bg-purple-50 dark:bg-purple-950/20 border-purple-200 dark:border-purple-900', + resolved: 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900', + appealed: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900', +} + +function getRemainingTime(deadline: string): string { + const diff = new Date(deadline).getTime() - Date.now() + if (diff <= 0) return 'Expired' + const days = Math.floor(diff / 86400000) + const hours = Math.floor((diff % 86400000) / 3600000) + if (days > 0) return `${days}d ${hours}h remaining` + return `${hours}h remaining` +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+
{children}
+
+ ) +} + +function EvidenceRow({ evidence }: { evidence: EvidenceType }) { + const icon = evidence.mimeType.startsWith('image/') + ? '๐Ÿ–ผ๏ธ' + : evidence.mimeType === 'application/pdf' + ? '๐Ÿ“„' + : evidence.mimeType.startsWith('text/') + ? '๐Ÿ“' + : '๐Ÿ“Ž' + + const statusColor = evidence.status === 'approved' + ? 'text-green-600 dark:text-green-400' + : evidence.status === 'rejected' + ? 'text-red-600 dark:text-red-400' + : 'text-yellow-600 dark:text-yellow-400' + + return ( +
+ {icon} +
+
+ + {evidence.fileName} + + + {evidence.status} + +
+ {evidence.description && ( +

{evidence.description}

+ )} +
+ + CID: {evidence.cid.slice(0, 8)}โ€ฆ{evidence.cid.slice(-6)} + + + {(evidence.fileSize / 1024).toFixed(1)} KB + + + View on IPFS โ†— + +
+
+
+ ) +} + +function PartyCard({ + label, + name, + address, +}: { + label: string + name: string + address: string +}) { + return ( +
+
+ {label} +
+
{name}
+
+ {address.slice(0, 8)}โ€ฆ{address.slice(-6)} +
+
+ ) +} + +export function Courtroom({ dispute }: CourtroomProps) { + const votedJurors = dispute.jurors.filter(j => j.status === 'voted') + const selectedJurors = dispute.jurors.filter(j => j.status === 'selected') + + return ( +
+
+
+
+
+

+ {dispute.title} +

+ + {STATUS_LABELS[dispute.status]} + +
+

+ Case #{dispute.id} ยท Created {new Date(dispute.createdAt).toLocaleDateString()} +

+
+
+
+ {dispute.amount.toLocaleString()} USDC +
+ {dispute.status !== 'resolved' && ( +
+ {getRemainingTime(dispute.deadline)} +
+ )} + {dispute.resolvedAt && ( +
+ Resolved {new Date(dispute.resolvedAt).toLocaleDateString()} +
+ )} +
+
+
+ +
+ +
+ โš–๏ธ +
+ +
+ + {dispute.evidence.length > 0 && ( +
+ {dispute.evidence.map(ev => ( + + ))} +
+ )} + + {dispute.jurors.length > 0 && ( +
+
+ {dispute.jurors.map(juror => ( +
+
+
+ {juror.displayName.charAt(0)} +
+
+
+ {juror.displayName} +
+
+ {juror.walletAddress.slice(0, 8)}โ€ฆ{juror.walletAddress.slice(-6)} +
+
+
+
+ {juror.status === 'voted' ? ( + + Voted {juror.vote && `โ€” ${juror.vote.charAt(0).toUpperCase() + juror.vote.slice(1)}`} + + ) : juror.status === 'dismissed' ? ( + Dismissed + ) : ( + Pending vote + )} + {juror.votedAt && ( +
+ {new Date(juror.votedAt).toLocaleDateString()} +
+ )} +
+
+ ))} +
+
+
+ {votedJurors.length} + of {dispute.jurors.length} + {' '}jurors have voted +
+
+
+ )} + + {dispute.verdict && ( +
+
+
+ {dispute.verdict === 'plaintiff' ? '๐Ÿ‘ค' : dispute.verdict === 'defendant' ? '๐Ÿ‘ค' : 'โš–๏ธ'} +
+
+ {dispute.verdict === 'plaintiff' && `In favor of ${dispute.plaintiffName}`} + {dispute.verdict === 'defendant' && `In favor of ${dispute.defendantName}`} + {dispute.verdict === 'split' && 'Split decision'} +
+
+ {dispute.verdict === 'split' + ? 'Funds will be distributed proportionally based on juror votes.' + : 'The ruling has been finalized and will be executed on-chain.'} +
+
+
+ )} + + {dispute.status === 'voting' && selectedJurors.length > 0 && ( +
+
+
๐Ÿ—ณ๏ธ
+
+ You have been selected as a juror for this case. Review all evidence before casting your vote. +
+
+ +
+
+
+ )} +
+ ) +} diff --git a/components/organisms/index.tsx b/components/organisms/index.tsx index c63a2e0..75dd17c 100644 --- a/components/organisms/index.tsx +++ b/components/organisms/index.tsx @@ -1,3 +1,4 @@ export * from './pledge' export * from './campaign' export * from './navbar' +export * from './courtroom' diff --git a/pages/dashboard/disputes.tsx b/pages/dashboard/disputes.tsx index eaab3de..917c16d 100644 --- a/pages/dashboard/disputes.tsx +++ b/pages/dashboard/disputes.tsx @@ -2,8 +2,11 @@ import type { NextPage } from 'next' import Head from 'next/head' import Link from 'next/link' import { Navbar } from '../../components/organisms' -import { DashboardSidebar, DisputeEventFeed } from '../../components/molecules' -import { useState } from 'react' +import { DashboardSidebar, DisputeEventFeed, DisputeCard, DisputeFilters } from '../../components/molecules' +import { useDisputes } from '../../hooks' +import { useState, useMemo } from 'react' +import { useRouter } from 'next/router' +import type { DisputeStatus } from '../../shared/types/dispute' interface NavItem { label: string @@ -19,8 +22,32 @@ const NAV_ITEMS: NavItem[] = [ { label: 'Settings', href: '/dashboard/settings', icon: 'โš™๏ธ', description: 'Account and preferences' }, ] +type RoleFilter = 'all' | 'plaintiff' | 'defendant' | 'juror' + const Disputes: NextPage = () => { + const router = useRouter() const [sidebarOpen, setSidebarOpen] = useState(false) + const [statusFilter, setStatusFilter] = useState('all') + const [roleFilter, setRoleFilter] = useState('all') + const { disputes, status, error, refetch } = useDisputes() + const isActive = (href: string) => router.pathname === href + + const filteredDisputes = useMemo(() => { + return disputes.filter(d => { + if (statusFilter !== 'all' && d.status !== statusFilter) return false + if (roleFilter === 'plaintiff') return false + if (roleFilter === 'defendant') return false + if (roleFilter === 'juror') return d.jurors.length > 0 + return true + }) + }, [disputes, statusFilter, roleFilter]) + + const stats = useMemo(() => ({ + total: disputes.length, + active: disputes.filter(d => d.status === 'active').length, + voting: disputes.filter(d => d.status === 'voting').length, + resolved: disputes.filter(d => d.status === 'resolved').length, + }), [disputes]) return ( <> @@ -51,17 +78,96 @@ const Disputes: NextPage = () => {

Track and manage active dispute resolutions

-
- -
+ {status === 'error' && ( +
+
+ + + + {error} +
+ +
+ )} -
-
โš–๏ธ
-

No active disputes

-

- When you raise a dispute or get selected as a juror, you'll see them here with evidence and voting options. -

-
+ {disputes.length > 0 && ( +
+
+
{stats.total}
+
Total
+
+
+
{stats.active}
+
Active
+
+
+
{stats.voting}
+
Voting
+
+
+
{stats.resolved}
+
Resolved
+
+
+ )} + + {status === 'loading' && ( +
+ {[1, 2, 3].map(i => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {status === 'success' && disputes.length > 0 && ( + <> +
+ +
+ +
+ {filteredDisputes.length > 0 ? ( + filteredDisputes.map(d => ( + + )) + ) : ( +
+
๐Ÿ”
+

No matching disputes

+

+ No disputes match your current filters. Try adjusting the status or role filter. +

+
+ )} +
+ + )} + + {status === 'success' && disputes.length === 0 && ( +
+
โš–๏ธ
+

No disputes yet

+

+ When you raise a dispute or get selected as a juror, you'll see them here with evidence and voting options. +

+
+ )}
diff --git a/pages/dashboard/disputes/[id].tsx b/pages/dashboard/disputes/[id].tsx new file mode 100644 index 0000000..476e428 --- /dev/null +++ b/pages/dashboard/disputes/[id].tsx @@ -0,0 +1,131 @@ +import type { NextPage } from 'next' +import Head from 'next/head' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { Navbar } from '../../../components/organisms' +import { Courtroom } from '../../../components/organisms/courtroom' +import { useDispute } from '../../../hooks' + +interface NavItem { + label: string + href: string + icon: string + description: string +} + +const NAV_ITEMS: NavItem[] = [ + { label: 'My Gigs', href: '/dashboard', icon: '๐Ÿ’ผ', description: 'View and manage your active gigs' }, + { label: 'Disputes', href: '/dashboard/disputes', icon: 'โš–๏ธ', description: 'Active dispute resolutions' }, + { label: 'Profile', href: '/dashboard/profile', icon: '๐Ÿ‘ค', description: 'Your reputation and work history' }, + { label: 'Settings', href: '/dashboard/settings', icon: 'โš™๏ธ', description: 'Account and preferences' }, +] + +const DisputeDetail: NextPage = () => { + const router = useRouter() + const { id } = router.query + const [sidebarOpen, setSidebarOpen] = useState(false) + const { dispute, status, error, refetch } = useDispute(typeof id === 'string' ? id : undefined) + const isActive = (href: string) => router.pathname.startsWith(href) + + return ( + <> + + {dispute ? `${dispute.title} - TrustFlow` : 'Dispute - TrustFlow'} + + + +
+ + +
+ {sidebarOpen && ( +
setSidebarOpen(false)} /> + )} + + + +
+ + +
+ + + + + Back to disputes + +
+ + {status === 'loading' && ( +
+
+
+
+
+
+
+
+
+
+ )} + + {status === 'error' && ( +
+
โš ๏ธ
+

Failed to load dispute

+

{error || 'Unable to load dispute details.'}

+
+ + + Back to disputes + +
+
+ )} + + {status === 'success' && !dispute && ( +
+
๐Ÿ”
+

Dispute not found

+

The dispute you're looking for doesn't exist or has been removed.

+ + Back to disputes + +
+ )} + + {status === 'success' && dispute && } +
+
+
+ + ) +} + +export default DisputeDetail From b8b76e113df1c57ba7b7e8ee7fe67f420a79f288 Mon Sep 17 00:00:00 2001 From: razeprasine Date: Wed, 22 Jul 2026 21:52:56 +0100 Subject: [PATCH 3/8] Add English Dispute CI.yml created and Responsive dark mode --- .github/workflows/ci.yml | 48 +++--- .../molecules/evidence-submission/index.tsx | 68 ++++++++ .../molecules/evidence-viewer/index.tsx | 124 +++++++++++++ components/molecules/file-upload/index.tsx | 62 ++++--- components/molecules/index.tsx | 4 + .../molecules/juror-vote-panel/index.tsx | 163 ++++++++++++++++++ components/molecules/vote-tally/index.tsx | 82 +++++++++ components/organisms/courtroom/index.tsx | 99 +++-------- messages/en.json | 45 +++++ pages/api/ipfs/pin.ts | 65 +++++++ 10 files changed, 634 insertions(+), 126 deletions(-) create mode 100644 components/molecules/evidence-submission/index.tsx create mode 100644 components/molecules/evidence-viewer/index.tsx create mode 100644 components/molecules/juror-vote-panel/index.tsx create mode 100644 components/molecules/vote-tally/index.tsx create mode 100644 pages/api/ipfs/pin.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6834544..d086ac7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,49 +1,53 @@ name: CI on: - push: - branches: [main] pull_request: branches: [main] + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: '18' jobs: - lint-and-build: + lint-typecheck-build: + name: Lint, Typecheck & Build runs-on: ubuntu-latest - strategy: - matrix: - node-version: [18, 20] - steps: - - name: Checkout code + - name: Checkout uses: actions/checkout@v4 - - name: Setup Node.js ${{ matrix.node-version }} + - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} - cache: 'npm' + node-version: ${{ env.NODE_VERSION }} - - name: Cache node_modules - id: cache-node-modules + - name: Cache npm dependencies uses: actions/cache@v4 + id: npm-cache with: - path: node_modules - key: node-modules-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }} + path: ~/.npm + key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }} restore-keys: | - node-modules-${{ runner.os }}-node${{ matrix.node-version }}- + ${{ runner.os }}-node-${{ env.NODE_VERSION }}- - name: Install dependencies - run: npm ci + run: npm ci --prefer-offline + if: steps.npm-cache.outputs.cache-hit != 'true' - name: Verify contract bindings are up-to-date run: npm run codegen:validate - - name: Run linter - run: npm run lint - - - name: Run type check + - name: TypeScript type check run: npm run typecheck - - name: Build project + - name: Lint + run: npm run lint + + - name: Build run: npm run build diff --git a/components/molecules/evidence-submission/index.tsx b/components/molecules/evidence-submission/index.tsx new file mode 100644 index 0000000..26930c6 --- /dev/null +++ b/components/molecules/evidence-submission/index.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import { FileUpload } from '../file-upload' +import type { UploadedFile } from '../file-upload' + +export interface EvidenceSubmissionProps { + disputeId: string + onEvidenceSubmitted?: (evidence: { cid: string; fileName: string; description: string }) => void +} + +export function EvidenceSubmission({ disputeId, onEvidenceSubmitted }: EvidenceSubmissionProps) { + const [description, setDescription] = useState('') + const [uploadedFiles, setUploadedFiles] = useState([]) + + const handleUploadComplete = (entry: UploadedFile) => { + setUploadedFiles(prev => [...prev, entry]) + if (entry.cid) { + onEvidenceSubmitted?.({ cid: entry.cid, fileName: entry.file.name, description }) + setDescription('') + } + } + + const handleUploadError = (entry: UploadedFile) => { + console.error('Evidence upload failed:', entry.error) + } + + return ( +
+
+ +