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
35 changes: 15 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,49 +1,44 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
push:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint-and-build:
lint-typecheck-build:
name: Lint, Typecheck & Build (Node ${{ matrix.node-version }})
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 }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'

- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
node-modules-${{ runner.os }}-node${{ matrix.node-version }}-
cache: npm

- name: Install dependencies
run: npm ci

- 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 36 additions & 0 deletions components/atoms/error-boundary/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react'

interface ErrorBoundaryProps {
children: React.ReactNode
fallback?: React.ReactNode
}

interface ErrorBoundaryState {
hasError: boolean
}

export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}

static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true }
}

render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="p-6 text-center">
<div className="text-3xl mb-2">⚠️</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Something went wrong rendering this section.
</p>
</div>
)
}

return this.props.children
}
}
1 change: 1 addition & 0 deletions components/atoms/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from './toast'
export * from './theme-toggle'
export * from './spacer'
export * from './markdown-renderer'
export * from './error-boundary'
94 changes: 94 additions & 0 deletions components/molecules/dispute-card/index.tsx
Original file line number Diff line number Diff line change
@@ -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<DisputeStatus, string> = {
pending: 'Pending',
active: 'Active',
voting: 'Voting',
resolved: 'Resolved',
appealed: 'Appealed',
}

const STATUS_COLORS: Record<DisputeStatus, string> = {
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 (
<Link href={`/dashboard/disputes/${dispute.id}`} className="block">
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-5 hover:shadow-md hover:border-indigo-300 dark:hover:border-indigo-700 transition-all duration-200">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0 mr-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{dispute.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
{dispute.description}
</p>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${STATUS_COLORS[dispute.status]}`}>
{STATUS_LABELS[dispute.status]}
</span>
</div>

<div className="flex flex-wrap items-center gap-4 text-sm">
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
<span className="w-2 h-2 rounded-full bg-indigo-500 flex-shrink-0" />
<span className="font-medium text-gray-900 dark:text-gray-100">{dispute.plaintiffName}</span>
<span className="text-gray-400 dark:text-gray-500">vs</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{dispute.defendantName}</span>
</div>

<span className="text-gray-300 dark:text-gray-700 hidden sm:inline">|</span>

<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{dispute.amount.toLocaleString()} USDC</span>
</div>

<span className="text-gray-300 dark:text-gray-700 hidden sm:inline">|</span>

<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{getRemainingTime(dispute.deadline)}</span>
</div>
</div>

{dispute.jurors.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{dispute.jurors.length} juror{dispute.jurors.length !== 1 ? 's' : ''} assigned</span>
{dispute.votes.length > 0 && (
<span className="text-gray-400">· {dispute.votes.length} vote{dispute.votes.length !== 1 ? 's' : ''} cast</span>
)}
</div>
</div>
)}
</div>
</Link>
)
}
65 changes: 65 additions & 0 deletions components/molecules/dispute-filters/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex flex-wrap gap-1.5">
{STATUS_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => onStatusChange(opt.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
statusFilter === opt.value
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
>
{opt.label}
</button>
))}
</div>

<select
value={roleFilter}
onChange={e => onRoleChange(e.target.value as RoleFilter)}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500 self-start"
>
{ROLE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)
}
92 changes: 92 additions & 0 deletions components/molecules/evidence-submission/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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<UploadedFile[]>([])
const [uploadError, setUploadError] = useState<string | null>(null)

const handleUploadComplete = (entry: UploadedFile) => {
setUploadError(null)
setUploadedFiles(prev => [...prev, entry])
if (entry.cid) {
onEvidenceSubmitted?.({ cid: entry.cid, fileName: entry.file.name, description })
setDescription('')
}
}

const handleUploadError = (entry: UploadedFile) => {
setUploadError(entry.error ?? 'Upload failed')
}

return (
<div className="space-y-4">
<div>
<label htmlFor="evidence-desc" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Evidence Description
</label>
<textarea
id="evidence-desc"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Describe what this evidence contains and why it's relevant to the dispute..."
rows={3}
className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
/>
</div>

<FileUpload
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
accept="image/*,application/pdf,text/plain,application/zip,.doc,.docx"
multiple={false}
/>

{uploadError && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900">
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<span className="font-medium">Upload failed:</span>
<span>{uploadError}</span>
</div>
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
You can retry by clicking the &ldquo;Pin&rdquo; button again.
</p>
<button
type="button"
onClick={() => setUploadError(null)}
className="mt-2 text-xs text-red-600 dark:text-red-400 underline hover:no-underline"
>
Dismiss
</button>
</div>
)}

{uploadedFiles.length > 0 && !uploadError && (
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900">
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-300">
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">Evidence submitted successfully:</span>
</div>
<ul className="mt-2 space-y-1">
{uploadedFiles.map(f => (
<li key={f.id} className="text-xs text-green-600 dark:text-green-400 font-mono">
{f.file.name} — CID: {f.cid?.slice(0, 12)}…{f.cid?.slice(-8)}
</li>
))}
</ul>
</div>
)}
</div>
)
}
Loading
Loading