diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f168348d..3f4d04dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88.0 with: targets: wasm32-unknown-unknown components: clippy @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88.0 with: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 @@ -42,7 +42,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88.0 with: components: clippy - uses: Swatinem/rust-cache@v2 @@ -73,7 +73,7 @@ jobs: PINATA_JWT: test-token steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88.0 - uses: Swatinem/rust-cache@v2 with: workspaces: ". -> target" @@ -141,7 +141,7 @@ jobs: needs: [contracts-test, backend-test, e2e] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88.0 with: targets: wasm32-unknown-unknown - name: Install Stellar CLI @@ -160,4 +160,3 @@ jobs: run: | echo "πŸš€ Frontend deployment logic (Vercel/etc)..." # vercel deploy --prefix apps/web --prod - diff --git a/apps/web/app/disputes/[id]/page.tsx b/apps/web/app/disputes/[id]/page.tsx index e88d8b29..1fd4ee86 100644 --- a/apps/web/app/disputes/[id]/page.tsx +++ b/apps/web/app/disputes/[id]/page.tsx @@ -1,8 +1,39 @@ +"use client"; + +import { useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { SiteShell } from "@/components/site-shell"; +import { api } from "@/lib/api"; + export default function DisputePage() { - return ( -
-

Dispute Verdict

-

Verdict details coming soon...

-
- ); + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + + useEffect(() => { + let active = true; + + void api.disputes + .get(id) + .then((dispute) => { + if (!active) return; + router.replace(`/jobs/${dispute.job_id}/dispute?disputeId=${dispute.id}`); + }) + .catch(() => { + if (!active) return; + }); + + return () => { + active = false; + }; + }, [id, router]); + + return ( + +
+ + ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a2dc41ec..44126c9b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,26 +1,45 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #f8f5ef; + --foreground: #0f172a; + --font-sans-stack: "Avenir Next", "Segoe UI", sans-serif; + --font-mono-stack: "SFMono-Regular", "SF Mono", "Roboto Mono", monospace; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-sans: var(--font-sans-stack); + --font-mono: var(--font-mono-stack); } @media (prefers-color-scheme: dark) { :root { - --background: #0a0a0a; - --foreground: #ededed; + --background: #0f172a; + --foreground: #f8fafc; } } +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans-stack); +} + +a { + color: inherit; + text-decoration: none; +} + +::selection { + background: rgba(245, 158, 11, 0.28); } diff --git a/apps/web/app/jobs/[id]/dispute/page.tsx b/apps/web/app/jobs/[id]/dispute/page.tsx new file mode 100644 index 00000000..166410a1 --- /dev/null +++ b/apps/web/app/jobs/[id]/dispute/page.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { + AlertTriangle, + ExternalLink, + FileWarning, + LoaderCircle, + Scale, +} from "lucide-react"; +import { SiteShell } from "@/components/site-shell"; +import { api, type Dispute, type Evidence, type Job, type Verdict } from "@/lib/api"; +import { formatDateTime, formatUsdc, shortenAddress } from "@/lib/format"; +import { connectWallet, getConnectedWalletAddress } from "@/lib/stellar"; + +const STELLAR_EXPLORER_URL = + process.env.NEXT_PUBLIC_STELLAR_NETWORK === "PUBLIC" + ? "https://stellar.expert/explorer/public/tx" + : "https://stellar.expert/explorer/testnet/tx"; + +export default function JobDisputeCenterPage() { + const { id } = useParams<{ id: string }>(); + const searchParams = useSearchParams(); + const disputeId = searchParams.get("disputeId"); + const [job, setJob] = useState(null); + const [dispute, setDispute] = useState(null); + const [evidence, setEvidence] = useState([]); + const [verdict, setVerdict] = useState(null); + const [viewerAddress, setViewerAddress] = useState(null); + const [statement, setStatement] = useState(""); + const [attachment, setAttachment] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + void getConnectedWalletAddress().then(setViewerAddress); + }, []); + + useEffect(() => { + let active = true; + + async function load() { + try { + const nextJob = await api.jobs.get(id); + const nextDispute = disputeId + ? await api.disputes.get(disputeId) + : await api.jobs.dispute.get(id); + const [nextEvidence, nextVerdict] = await Promise.all([ + api.disputes.evidence.list(nextDispute.id).catch(() => []), + api.disputes.verdict(nextDispute.id).catch(() => null), + ]); + + if (!active) return; + setJob(nextJob); + setDispute(nextDispute); + setEvidence(nextEvidence); + setVerdict(nextVerdict); + setError(null); + } catch (loadError) { + if (!active) return; + setError( + loadError instanceof Error + ? loadError.message + : "Unable to load dispute center.", + ); + } finally { + if (active) { + setLoading(false); + } + } + } + + void load(); + const interval = window.setInterval(() => { + void load(); + }, 6000); + + return () => { + active = false; + window.clearInterval(interval); + }; + }, [disputeId, id]); + + async function ensureViewer() { + if (viewerAddress) return viewerAddress; + const connected = await connectWallet(); + setViewerAddress(connected); + return connected; + } + + async function handleSubmitEvidence(event: React.FormEvent) { + event.preventDefault(); + if (!dispute) return; + setBusy(true); + + try { + const actor = await ensureViewer(); + let fileHash: string | undefined; + + if (attachment) { + const upload = await api.uploads.pin(attachment); + fileHash = upload.cid; + } + + await api.disputes.evidence.submit(dispute.id, { + submitted_by: actor, + content: statement, + file_hash: fileHash, + }); + + setStatement(""); + setAttachment(null); + setEvidence(await api.disputes.evidence.list(dispute.id)); + } catch { + alert("Failed to submit evidence"); + } finally { + setBusy(false); + } + } + + if (loading) { + return ( + +
+ + ); + } + + if (!job || !dispute) { + return ( + +
+ {error ?? "This job has not entered dispute resolution yet."} +
+
+ ); + } + + const freelancerShare = verdict ? verdict.freelancer_share_bps / 100 : null; + const clientShare = verdict ? (10000 - verdict.freelancer_share_bps) / 100 : null; + + return ( + +
+
+
+
+ +
+

+ Regular workflow is fully locked +

+

+ While this dispute is open, Lance freezes milestone approvals and + standard execution actions. The Agent Judge reviews the original + brief, submitted evidence, and both parties' written context. +

+
+
+
+ +
+
+
+

+ Docket +

+

+ Evidence submissions +

+
+ + {dispute.status} + +
+ +
+