-
Escrow Funded!
-
+
+
+
+ Escrow Funded!
+
+
{formatUsdc(total)} is now locked on-chain.
- {txHash && (
-
+ {txHash ? (
+
Transaction: {txHash}
- )}
-
- Both you and the freelancer can now see the job as "Actively Funded".
-
+ ) : null}
router.push(`/jobs/${id}`)}
- className="mt-6 px-5 py-2 rounded-lg bg-green-600 text-white font-semibold hover:bg-green-700"
+ className="mt-8 rounded-full bg-emerald-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-emerald-500"
>
Go to Job
-
+
);
}
return (
-
- Fund Escrow
-
- Review the breakdown carefully before authorising the transfer.
-
-
- {/* Summary card */}
-
-
Escrow Funding Summary
-
-
-
- Job
- {job.title}
-
-
- Milestones
- {job.milestones}
-
-
- Contract value
- {formatUsdc(job.budget_usdc)}
-
-
- Platform fee (2%)
- {formatUsdc(platformFee)}
-
-
-
Total to deposit
-
{formatUsdc(total)}
+
+
+
+
+
+ Escrow Funding Summary
+
+
+
+ Job
+ {job.title}
+
+
+ Milestones
+ {job.milestones}
+
+
+ Contract value
+ {formatUsdc(job.budget_usdc)}
+
+
+ Platform fee (2%)
+ {formatUsdc(platformFee)}
+
+
+ Total to deposit
+ {formatUsdc(total)}
+
+
-
- {/* Freelancer address */}
- {job.freelancer_address && (
-
- Freelancer: {job.freelancer_address}
-
- )}
-
+
+ setChecked(event.target.checked)}
+ className="mt-1 h-4 w-4 accent-amber-600"
+ />
+
+ I have verified the wallet addresses, milestone breakdown, and total amount. I understand these funds stay locked until approvals or a dispute resolution completes.
+
+
+
+ {fundingState === "error" && errorMsg ? (
+
+ {errorMsg}
+
+ ) : null}
+
+
setFundingState("confirming")}
+ disabled={!checked || fundingState !== "idle"}
+ className="mt-6 w-full rounded-full bg-amber-500 px-6 py-4 text-sm font-semibold text-white transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:bg-amber-200"
+ >
+ Deposit {formatUsdc(total)} into Escrow
+
+
- {/* Caution banner */}
-
-
Caution: Once funds are deposited into the smart-contract escrow they can
- only be released by milestone approval or a dispute verdict. This action cannot be undone.
+
+
+
+ High-conviction action
+
+
+ This step flips the job into capital-backed execution.
+
+
+ Once funds are deposited into escrow they can only move through milestone releases or a dispute verdict. Make sure the freelancer wallet and milestone count are correct.
+
+
- {/* Confirmation checkbox */}
-
- setChecked(e.target.checked)}
- className="mt-0.5 h-4 w-4 accent-amber-600"
- />
-
- I have verified the job details, milestone breakdown, and total amount above. I understand
- funds will be locked on-chain until milestones are released or a dispute is resolved.
-
-
-
- {/* Error display */}
- {fundingState === "error" && errorMsg && (
-
- {errorMsg}
-
- )}
-
- {/* CTA button */}
-
setFundingState("confirming")}
- disabled={!checked || fundingState !== "idle"}
- className="w-full py-3 rounded-lg bg-amber-600 text-white font-bold text-base
- hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed"
- >
- Deposit {formatUsdc(total)} into Escrow
-
-
- {/* Final confirmation modal */}
- {fundingState === "confirming" && (
-
-
-
Final Confirmation
-
+ {fundingState === "confirming" ? (
+
+
+
+ Final Confirmation
+
+
You are about to transfer{" "}
- {formatUsdc(total)} (including 2%
- platform fee) into the escrow smart contract for:
-
-
{job.title}
-
- This is a blockchain transaction. Make sure your wallet is connected and you have
- sufficient USDC balance.
+ {formatUsdc(total)} into the escrow smart
+ contract for {job.title} .
-
+
setFundingState("idle")}
- className="flex-1 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50"
+ className="flex-1 rounded-full border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700"
>
Cancel
Confirm & Sign
- )}
-
- {/* Signing / polling overlay */}
- {(fundingState === "signing" || fundingState === "polling") && (
-
-
-
-
+ ) : null}
+
+ {fundingState === "signing" || fundingState === "polling" ? (
+
+
+
+
{fundingState === "signing"
- ? "Waiting for wallet signatureβ¦"
- : "Broadcasting transaction⦠confirming on-chain"}
+ ? "Waiting for wallet signature..."
+ : "Broadcasting transaction and confirming on-chain..."}
- {txHash && (
-
tx: {txHash}
- )}
+ {txHash ? (
+
tx: {txHash}
+ ) : null}
- )}
-
+ ) : null}
+
);
}
diff --git a/apps/web/app/jobs/[id]/page.tsx b/apps/web/app/jobs/[id]/page.tsx
index 6c8692f4..a3b6da8d 100644
--- a/apps/web/app/jobs/[id]/page.tsx
+++ b/apps/web/app/jobs/[id]/page.tsx
@@ -1,155 +1,652 @@
"use client";
-import React, { useEffect, useState } from "react";
+import { useEffect, useState } from "react";
+import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
-import { api, type Job, type Bid } from "@/lib/api";
-import { releaseMilestone } from "@/lib/contracts";
+import {
+ CheckCircle2,
+ Clock3,
+ FileUp,
+ Gavel,
+ LoaderCircle,
+ ShieldAlert,
+ Wallet,
+} from "lucide-react";
+import { SiteShell } from "@/components/site-shell";
+import { Stars } from "@/components/stars";
+import { useLiveJobWorkspace } from "@/hooks/use-live-job-workspace";
+import { api } from "@/lib/api";
+import { releaseFunds, openDispute } from "@/lib/contracts";
+import {
+ formatDate,
+ formatDateTime,
+ formatUsdc,
+ shortenAddress,
+} from "@/lib/format";
+import { connectWallet, getConnectedWalletAddress } from "@/lib/stellar";
export default function JobDetailsPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
- const [job, setJob] = useState
(null);
- const [bids, setBids] = useState([]);
+ const workspace = useLiveJobWorkspace(id);
+ const [viewerAddress, setViewerAddress] = useState(null);
const [proposal, setProposal] = useState("");
- const [loading, setLoading] = useState(false);
+ const [deliverableLabel, setDeliverableLabel] = useState("");
+ const [deliverableLink, setDeliverableLink] = useState("");
+ const [deliverableFile, setDeliverableFile] = useState(null);
+ const [busyAction, setBusyAction] = useState(null);
useEffect(() => {
- refresh();
- }, [id]);
-
- const refresh = async () => {
- const [j, b] = await Promise.all([api.jobs.get(id), api.bids.list(id)]);
- setJob(j);
- setBids(b);
- };
-
- const handleBid = async (e: React.FormEvent) => {
- e.preventDefault();
- setLoading(true);
+ void getConnectedWalletAddress().then(setViewerAddress);
+ }, []);
+
+ async function ensureViewerAddress() {
+ if (viewerAddress) return viewerAddress;
+ const connected = await connectWallet();
+ setViewerAddress(connected);
+ return connected;
+ }
+
+ async function handleBid(event: React.FormEvent) {
+ event.preventDefault();
+ setBusyAction("bid");
+
try {
+ const freelancerAddress =
+ (await getConnectedWalletAddress()) ?? "GD...FREELANCER";
await api.bids.create(id, {
- freelancer_address: "GD...FREELANCER",
+ freelancer_address: freelancerAddress,
proposal,
});
setProposal("");
- refresh();
- } catch (err) {
+ await workspace.refresh();
+ } catch {
alert("Failed to submit bid");
} finally {
- setLoading(false);
+ setBusyAction(null);
}
- };
+ }
+
+ async function handleAcceptBid(bidId: string) {
+ if (!workspace.job) return;
+ setBusyAction(`accept-${bidId}`);
- const handleAccept = async (freelancerAddress: string) => {
- setLoading(true);
try {
- // In a real app, this would be a PATCH to /v1/jobs/:id
- // but here we simulation by posting a bid acceptance
- // Let's assume the API has a way to accept.
- // For the E2E test, we can just navigate to fund page if we want
- // or check if the backend updated.
- // Based on api.ts, there is no explicit 'accept' method, but let's assume it works.
+ await api.bids.accept(id, bidId, {
+ client_address: workspace.job.client_address,
+ });
+ await workspace.refresh();
router.push(`/jobs/${id}/fund`);
+ } catch {
+ alert("Failed to accept bid");
} finally {
- setLoading(false);
+ setBusyAction(null);
}
- };
+ }
+
+ async function handleSubmitDeliverable(event: React.FormEvent) {
+ event.preventDefault();
+ if (!workspace.job) return;
+ setBusyAction("deliverable");
+
+ try {
+ const submitter =
+ workspace.job.freelancer_address ??
+ (await ensureViewerAddress()) ??
+ "GD...FREELANCER";
+
+ let url = deliverableLink;
+ let fileHash: string | undefined;
+ let kind = deliverableLink ? "link" : "file";
+
+ if (deliverableFile) {
+ const upload = await api.uploads.pin(deliverableFile);
+ url = `ipfs://${upload.cid}`;
+ fileHash = upload.cid;
+ kind = "file";
+ }
+
+ await api.jobs.deliverables.submit(id, {
+ submitted_by: submitter,
+ label: deliverableLabel || "Milestone submission",
+ kind,
+ url,
+ file_hash: fileHash,
+ });
+
+ setDeliverableFile(null);
+ setDeliverableLabel("");
+ setDeliverableLink("");
+ await workspace.refresh();
+ } catch {
+ alert("Failed to submit deliverable");
+ } finally {
+ setBusyAction(null);
+ }
+ }
+
+ async function handleReleaseFunds() {
+ if (!workspace.job) return;
+ const nextMilestone = workspace.milestones.find(
+ (milestone) => milestone.status === "pending",
+ );
+ if (!nextMilestone) return;
+
+ setBusyAction("release");
- const handleRelease = async () => {
- setLoading(true);
try {
- await releaseMilestone(BigInt(job?.on_chain_job_id ?? 0));
- alert("Milestone released!");
- refresh();
- } catch (err) {
+ await releaseFunds(
+ BigInt(workspace.job.on_chain_job_id ?? 0),
+ Math.max(0, nextMilestone.index - 1),
+ );
+ await api.jobs.releaseMilestone(id, nextMilestone.id);
+ await workspace.refresh();
+ } catch {
alert("Failed to release milestone");
} finally {
- setLoading(false);
+ setBusyAction(null);
}
- };
+ }
- if (!job) return Loading...
;
+ async function handleOpenDispute() {
+ if (!workspace.job) return;
+ setBusyAction("dispute");
- return (
-
-
-
-
{job.title}
-
ID: {job.id} | Status: {job.status}
+ try {
+ const actor = (await ensureViewerAddress()) ?? workspace.job.client_address;
+ await openDispute(BigInt(workspace.job.on_chain_job_id ?? 0));
+ const dispute = await api.jobs.dispute.open(id, { opened_by: actor });
+ router.push(`/jobs/${id}/dispute?disputeId=${dispute.id}`);
+ } catch {
+ alert("Failed to open dispute");
+ } finally {
+ setBusyAction(null);
+ }
+ }
+
+ if (workspace.loading && !workspace.job) {
+ return (
+
+
+
+ );
+ }
+
+ if (!workspace.job) {
+ return (
+
+
+ {workspace.error ?? "Job not found."}
-
-
${(job.budget_usdc / 10_000_000).toLocaleString()} USDC
-
{job.milestones} Milestones
+
+ );
+ }
+
+ const job = workspace.job;
+ const nextMilestone = workspace.milestones.find(
+ (milestone) => milestone.status === "pending",
+ );
+ const workflowLocked = job.status === "disputed" || workspace.dispute !== null;
+
+ return (
+
+
+
+
+
+
+
+ Status
+
+
+
+ {job.title}
+
+
+ {job.status}
+
+
+
+ {job.description}
+
+
+
+
+ Contract Value
+
+
+ {formatUsdc(job.budget_usdc)}
+
+
+ {job.milestones} milestone approvals
+
+
+
+
+
+
+
+ Client
+
+
+ {shortenAddress(job.client_address)}
+
+
+
+
+ Freelancer
+
+
+ {job.freelancer_address
+ ? shortenAddress(job.freelancer_address)
+ : "Not assigned"}
+
+
+
+
+ Updated
+
+
+ {formatDateTime(job.updated_at)}
+
+
+
+
+ {workflowLocked ? (
+
+
+
+
+
+ Regular workflow is locked while the dispute center is active.
+
+
+ Deliverable uploads and release actions stay frozen until the
+ Agent Judge returns an immutable verdict.
+
+
+ Open dispute center
+
+
+
+
+ ) : null}
+
+
+ {job.status === "open" ? (
+
+
+
+ Submit a Proposal
+
+
+ Pitch your approach, timing, and why your previous work maps cleanly to this brief.
+
+
+
+
+
+
+
+ Bids ({workspace.bids.length})
+
+
+ Client shortlist
+
+
+
+ {workspace.bids.map((bid) => (
+
+
+ {shortenAddress(bid.freelancer_address)}
+
+
+ {bid.proposal}
+
+ handleAcceptBid(bid.id)}
+ disabled={busyAction === `accept-${bid.id}`}
+ className="mt-4 w-full rounded-full bg-slate-950 px-4 py-3 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:opacity-50"
+ id={`accept-bid-${bid.id}`}
+ >
+ {busyAction === `accept-${bid.id}` ? "Accepting..." : "Accept Bid"}
+
+
+ ))}
+
+
+
+ ) : null}
+
+ {job.status !== "open" ? (
+
+
+
+
+
+ Milestone Ledger
+
+
+ Each milestone is time-stamped so both parties can see what is pending, submitted, and released.
+
+
+ {workspace.loading ? (
+
+ ) : null}
+
+
+
+ {workspace.milestones.map((milestone) => (
+
+
+
+
+ Milestone {milestone.index}
+
+
+ {milestone.title}
+
+
+
+
+ {formatUsdc(milestone.amount_usdc)}
+
+
+ {milestone.status}
+
+
+
+ {milestone.released_at ? (
+
+ Released {formatDateTime(milestone.released_at)}
+
+ ) : null}
+
+ ))}
+
+
+
+
+
+
+
+ Deliverables
+
+
+ Freelancers can pin files to IPFS or share links, then the client gets a dedicated approval moment.
+
+
+
+
+
+ {!workflowLocked ? (
+
+ setDeliverableLabel(event.target.value)}
+ placeholder="Submission title"
+ className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-950 outline-none transition focus:border-amber-400"
+ />
+ setDeliverableLink(event.target.value)}
+ placeholder="GitHub repo, Figma file, hosted ZIP link, or leave blank to upload a file"
+ className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-950 outline-none transition focus:border-amber-400"
+ />
+
+
+ {deliverableFile ? deliverableFile.name : "Upload ZIP, image, JSON, or PDF evidence"}
+
+ setDeliverableFile(event.target.files?.[0] ?? null)
+ }
+ />
+
+
+ {busyAction === "deliverable"
+ ? "Submitting..."
+ : "Submit Milestone"}
+
+
+ ) : null}
+
+
+ {workspace.deliverables.length === 0 ? (
+
+ No milestone evidence has been submitted yet.
+
+ ) : (
+ workspace.deliverables.map((deliverable) => (
+
+
+
+
+ Milestone {deliverable.milestone_index}
+
+
+ {deliverable.label}
+
+
+
+ {formatDateTime(deliverable.created_at)}
+
+
+
+ Open evidence
+
+
+ ))
+ )}
+
+
+
+ ) : null}
-
-
-
-
- Description
- {job.description}
+
-
+
+
+
+
);
}
diff --git a/apps/web/app/jobs/new/page.tsx b/apps/web/app/jobs/new/page.tsx
index 46e73c41..8694e4b8 100644
--- a/apps/web/app/jobs/new/page.tsx
+++ b/apps/web/app/jobs/new/page.tsx
@@ -1,8 +1,11 @@
"use client";
-import React, { useState } from "react";
+import { useState } from "react";
import { useRouter } from "next/navigation";
+import { Wallet } from "lucide-react";
+import { SiteShell } from "@/components/site-shell";
import { api } from "@/lib/api";
+import { connectWallet, getConnectedWalletAddress } from "@/lib/stellar";
export default function NewJobPage() {
const router = useRouter();
@@ -10,88 +13,140 @@ export default function NewJobPage() {
const [description, setDescription] = useState("");
const [budget, setBudget] = useState(1000);
const [milestones, setMilestones] = useState(1);
+ const [walletAddress, setWalletAddress] = useState("GD...CLIENT");
const [loading, setLoading] = useState(false);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
+ async function ensureWallet() {
+ const connected = await getConnectedWalletAddress();
+ if (connected) {
+ setWalletAddress(connected);
+ return connected;
+ }
+
+ const newlyConnected = await connectWallet();
+ setWalletAddress(newlyConnected);
+ return newlyConnected;
+ }
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
setLoading(true);
+
try {
+ const clientAddress = await ensureWallet().catch(() => walletAddress);
const job = await api.jobs.create({
title,
description,
budget_usdc: budget * 10_000_000,
milestones,
- client_address: "GD...CLIENT",
+ client_address: clientAddress,
});
router.push(`/jobs/${job.id}`);
- } catch (err) {
+ } catch {
alert("Failed to create job");
} finally {
setLoading(false);
}
- };
+ }
return (
-
- Post a New Job
-
-
- Title
- setTitle(e.target.value)}
- className="w-full p-3 rounded-lg border border-gray-300 dark:bg-zinc-900"
- placeholder="e.g. Build a Soroban Smart Contract"
- required
- id="job-title"
- />
-
-
- Description
- setDescription(e.target.value)}
- className="w-full p-3 rounded-lg border border-gray-300 dark:bg-zinc-900 min-h-[150px]"
- placeholder="Describe the project requirements..."
- required
- id="job-description"
- />
-
-
-
-
Budget (USDC)
-
setBudget(Number(e.target.value))}
- className="w-full p-3 rounded-lg border border-gray-300 dark:bg-zinc-900"
- required
- id="job-budget"
- />
+
+
+
+
+
+
+ Title
+
+ setTitle(event.target.value)}
+ className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-950 outline-none transition focus:border-amber-400"
+ placeholder="Build a Soroban Smart Contract"
+ required
+ id="job-title"
+ />
+
+
+
+
+ Scope
+
+ setDescription(event.target.value)}
+ className="min-h-[180px] w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-950 outline-none transition focus:border-amber-400"
+ placeholder="Describe requirements, acceptance criteria, and what counts as a complete milestone."
+ required
+ id="job-description"
+ />
+
+
+
+
+
+ {loading ? "Posting..." : "Post Job"}
+
-
-
Milestones
-
setMilestones(Number(e.target.value))}
- className="w-full p-3 rounded-lg border border-gray-300 dark:bg-zinc-900"
- min="1"
- required
- id="job-milestones"
- />
+
+
+
-
- {loading ? "Posting..." : "Post Job"}
-
-
-
+
+ Better briefs produce smoother milestone releases.
+
+
+ Explain what success looks like so the freelancer can submit evidence decisively.
+ Split the budget into meaningful milestones to keep approval moments clean.
+ Assume the dispute center may need to read this brief later and write accordingly.
+
+
+
+
);
}
diff --git a/apps/web/app/jobs/page.tsx b/apps/web/app/jobs/page.tsx
index 3373613b..61c7cf1c 100644
--- a/apps/web/app/jobs/page.tsx
+++ b/apps/web/app/jobs/page.tsx
@@ -1,8 +1,189 @@
+"use client";
+
+import Link from "next/link";
+import { ArrowUpRight, Clock3, Search, SlidersHorizontal } from "lucide-react";
+import { SiteShell } from "@/components/site-shell";
+import { Stars } from "@/components/stars";
+import { useJobBoard } from "@/hooks/use-job-board";
+import { formatDate, formatUsdc, shortenAddress } from "@/lib/format";
+
+const sortOptions = [
+ { id: "chronological", label: "Newest" },
+ { id: "budget", label: "Highest Budget" },
+ { id: "reputation", label: "Best Client Reputation" },
+] as const;
+
export default function JobsPage() {
- return (
-
- Jobs
- Job board coming soon...
-
- );
+ const { jobs, loading, error, query, activeTag, sortBy, availableTags, actions } =
+ useJobBoard();
+
+ return (
+
+
+
+
+
+ actions.setQuery(event.target.value)}
+ placeholder="Search by stack, brief, or client wallet"
+ className="w-full bg-transparent text-sm text-slate-900 outline-none placeholder:text-slate-400"
+ />
+
+
+
+
+ Sort
+
+ {sortOptions.map((option) => (
+
actions.setSortBy(option.id)}
+ className={`rounded-xl px-4 py-2 text-sm font-medium transition ${
+ sortBy === option.id
+ ? "bg-slate-950 text-white"
+ : "bg-white text-slate-600 hover:text-slate-950"
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+
+
+
+ {availableTags.map((tag) => (
+ actions.setActiveTag(tag)}
+ className={`rounded-full px-4 py-2 text-sm font-medium transition ${
+ activeTag === tag
+ ? "bg-amber-500 text-white"
+ : "border border-slate-200 bg-white text-slate-600 hover:border-amber-300 hover:text-slate-950"
+ }`}
+ >
+ {tag}
+
+ ))}
+
+
+ {error ? (
+
+ Live API data was unavailable, so the board is showing resilient mock
+ listings instead. {error}
+
+ ) : null}
+
+
+
+ {loading ? (
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+ ))}
+
+ ) : (
+
+ {jobs.map((job) => (
+
+
+
+
+ {job.status}
+
+
+ {job.title}
+
+
+
+
+
+
+ {job.description}
+
+
+
+ {job.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+ Budget
+
+
+ {formatUsdc(job.budget_usdc)}
+
+
+
+
+ Deadline
+
+
+
+ {formatDate(job.deadlineAt)}
+
+
+
+
+ Milestones
+
+
+ {job.milestones} tracked approvals
+
+
+
+
+
+
+
+ Client
+
+
+ {shortenAddress(job.client_address)}
+
+
+
+
+
+ {job.clientReputation.averageStars.toFixed(1)}
+
+
+ {job.clientReputation.totalJobs} completed jobs on-chain
+
+
+
+
+ ))}
+
+ )}
+
+ {!loading && jobs.length === 0 ? (
+
+ No open jobs matched that filter.
+
+ ) : null}
+
+
+ );
}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index c0c31280..2f47ccf8 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,18 +1,7 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ToastProvider } from "@/components/ui/toast-provider";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
export const metadata: Metadata = {
title: "Lance - Decentralized Freelance Marketplace",
description: "Stellar-native freelance marketplace with AI-powered dispute resolution",
@@ -25,9 +14,7 @@ export default function RootLayout({
}>) {
return (
-
+
{children}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 295f8fdf..1f90e6f1 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,65 +1,121 @@
-import Image from "next/image";
+import Link from "next/link";
+import { ArrowRight, BriefcaseBusiness, Gavel, ShieldCheck, Star } from "lucide-react";
+import { SiteShell } from "@/components/site-shell";
+
+const highlights = [
+ {
+ title: "Trustless Profiles",
+ description:
+ "Blend editable bios and portfolio links with Soroban reputation math so serious freelancers can market verified credibility everywhere.",
+ href: "/profile/GD...CLIENT",
+ icon: Star,
+ },
+ {
+ title: "Live Job Workspaces",
+ description:
+ "Keep both sides aligned around milestones, evidence, escrow state, and payout actions inside a single shared dashboard.",
+ href: "/jobs",
+ icon: BriefcaseBusiness,
+ },
+ {
+ title: "Neutral Dispute Center",
+ description:
+ "Explain evidence, AI reasoning, and final payout splits with courtroom-level clarity once cooperation breaks down.",
+ href: "/jobs",
+ icon: Gavel,
+ },
+];
export default function Home() {
return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
+
+
+
+
+
+ Trust by design
+
+
+ Every page is built to make strong operators look stronger.
+
+
+ Public profiles become acquisition funnels, active jobs become
+ command centers, and disputes become legible instead of chaotic.
+
+
+
+ Explore Job Board
+
+
+
+ Post a Job
+
+
+
+
+
+
-
-
-
+
+
+
4
+
+ Core surfaces now aligned: profiles, marketplace, job overview,
+ and dispute resolution.
+
+
+
+
+
+
Escrow-first workflow
+
+
+ Fund milestones, upload proof, approve releases, or escalate
+ into a locked dispute flow with on-chain receipts.
+
+
+
+
+
+
+
+ {highlights.map((item) => {
+ const Icon = item.icon;
+ return (
+
+
+
+
+
+ {item.title}
+
+
+ {item.description}
+
+
+ Open surface
+
+
+
+ );
+ })}
+
+
);
}
diff --git a/apps/web/app/profile/[address]/page.tsx b/apps/web/app/profile/[address]/page.tsx
new file mode 100644
index 00000000..86d5a3d3
--- /dev/null
+++ b/apps/web/app/profile/[address]/page.tsx
@@ -0,0 +1,450 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import {
+ ExternalLink,
+ PencilLine,
+ ShieldCheck,
+ Wallet,
+} from "lucide-react";
+import { SiteShell } from "@/components/site-shell";
+import { Stars } from "@/components/stars";
+import { api, type PublicProfile } from "@/lib/api";
+import {
+ formatDate,
+ formatPercent,
+ formatUsdc,
+ shortenAddress,
+} from "@/lib/format";
+import {
+ getReputationMetrics,
+ type ReputationMetrics,
+} from "@/lib/reputation";
+import { connectWallet, getConnectedWalletAddress } from "@/lib/stellar";
+
+type TabId = "overview" | "history" | "reliability";
+
+export default function PublicProfilePage() {
+ const { address } = useParams<{ address: string }>();
+ const [profile, setProfile] = useState
(null);
+ const [freelancerRep, setFreelancerRep] = useState(null);
+ const [clientRep, setClientRep] = useState(null);
+ const [viewerAddress, setViewerAddress] = useState(null);
+ const [activeTab, setActiveTab] = useState("overview");
+ const [editing, setEditing] = useState(false);
+ const [displayName, setDisplayName] = useState("");
+ const [headline, setHeadline] = useState("");
+ const [bio, setBio] = useState("");
+ const [portfolioLinks, setPortfolioLinks] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ void getConnectedWalletAddress().then(setViewerAddress);
+ }, []);
+
+ useEffect(() => {
+ let active = true;
+
+ async function loadProfile() {
+ try {
+ const [profileData, nextFreelancerRep, nextClientRep] = await Promise.all([
+ api.users.getProfile(address),
+ getReputationMetrics(address, "freelancer"),
+ getReputationMetrics(address, "client"),
+ ]);
+
+ if (!active) return;
+ setProfile(profileData);
+ setFreelancerRep(nextFreelancerRep);
+ setClientRep(nextClientRep);
+ setDisplayName(profileData.display_name ?? "");
+ setHeadline(profileData.headline);
+ setBio(profileData.bio);
+ setPortfolioLinks(profileData.portfolio_links.join("\n"));
+ setError(null);
+ } catch (loadError) {
+ if (!active) return;
+ setError(
+ loadError instanceof Error
+ ? loadError.message
+ : "Unable to load this profile.",
+ );
+ } finally {
+ if (active) {
+ setLoading(false);
+ }
+ }
+ }
+
+ void loadProfile();
+
+ return () => {
+ active = false;
+ };
+ }, [address]);
+
+ const isOwner = viewerAddress === address;
+
+ async function handleConnectWallet() {
+ const connected = await connectWallet();
+ setViewerAddress(connected);
+ }
+
+ async function handleSaveProfile(event: React.FormEvent) {
+ event.preventDefault();
+ if (!isOwner) return;
+ setSaving(true);
+
+ try {
+ const updated = await api.users.updateProfile(address, address, {
+ display_name: displayName || undefined,
+ headline,
+ bio,
+ portfolio_links: portfolioLinks
+ .split("\n")
+ .map((link) => link.trim())
+ .filter(Boolean),
+ });
+ setProfile(updated);
+ setEditing(false);
+ } catch {
+ alert("Failed to update profile");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!profile) {
+ return (
+
+
+ {error ?? "Profile unavailable."}
+
+
+ );
+ }
+
+ const tabs: TabId[] = ["overview", "history", "reliability"];
+
+ return (
+
+
+
+
+
+
+
+
+ Immutable reputation + editable brand layer
+
+
+ {profile.display_name || shortenAddress(profile.address, 10, 6)}
+
+
+ {profile.headline || "Independent specialist building with verifiable delivery signals."}
+
+
+ {profile.bio || "No public bio has been added yet."}
+
+
+
+
+
+ Wallet address
+
+
+ {shortenAddress(profile.address, 12, 6)}
+
+
+ Updated {formatDate(profile.updated_at)}
+
+
+
+
+
+
+
+ Freelancer score
+
+
+
+
+ {freelancerRep?.averageStars.toFixed(1) ?? "2.5"}
+
+
+
+ {freelancerRep?.scoreBps ?? 5000} bps on-chain
+
+
+
+
+ Completed jobs
+
+
+ {profile.metrics.completed_jobs}
+
+
+ {formatUsdc(profile.metrics.verified_volume_usdc)} verified volume
+
+
+
+
+ Dispute rate
+
+
+ {formatPercent(profile.metrics.dispute_rate)}
+
+
+ Historical resolution pressure
+
+
+
+
+
+ {tabs.map((tab) => (
+ setActiveTab(tab)}
+ className={`rounded-full px-4 py-2 text-sm font-semibold capitalize transition ${
+ activeTab === tab
+ ? "bg-slate-950 text-white"
+ : "border border-slate-200 bg-white text-slate-600 hover:border-amber-300 hover:text-slate-950"
+ }`}
+ >
+ {tab}
+
+ ))}
+
+
+
+ {activeTab === "overview" ? (
+
+
+ Portfolio links
+
+
+ {profile.portfolio_links.length === 0 ? (
+
+ No public links have been added yet.
+
+ ) : (
+ profile.portfolio_links.map((link) => (
+
+ {link}
+
+
+ ))
+ )}
+
+
+ ) : null}
+
+ {activeTab === "history" ? (
+
+
+ Chronological execution ledger
+
+
+ {profile.history.length === 0 ? (
+
+ No completed contracts have been recorded yet.
+
+ ) : (
+ profile.history.map((entry) => (
+
+
+
+
+ {entry.role}
+
+
+ {entry.title}
+
+
+ Counterparty: {shortenAddress(entry.counterparty)}
+
+
+
+
+ {formatUsdc(entry.budget_usdc)}
+
+
{formatDate(entry.completed_at)}
+
+
+
+ ))
+ )}
+
+
+ ) : null}
+
+ {activeTab === "reliability" ? (
+
+
+ Reliability breakdown
+
+
+
+
+ Completion rate
+
+
+ {formatPercent(profile.metrics.completion_rate)}
+
+
+
+
+ Client score
+
+
+ {clientRep?.scoreBps ?? 5000} bps
+
+
+
+
+ Active jobs
+
+
+ {profile.metrics.active_jobs}
+
+
+
+
+ Reviews counted
+
+
+ {freelancerRep?.reviews ?? 0}
+
+
+
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/site-shell.tsx b/apps/web/components/site-shell.tsx
new file mode 100644
index 00000000..c7875a70
--- /dev/null
+++ b/apps/web/components/site-shell.tsx
@@ -0,0 +1,74 @@
+import Link from "next/link";
+import { BriefcaseBusiness, Gavel, ShieldCheck, UserRound } from "lucide-react";
+import type { ReactNode } from "react";
+
+const NAV_ITEMS = [
+ { href: "/jobs", label: "Job Board", icon: BriefcaseBusiness },
+ { href: "/jobs/new", label: "Post Job", icon: ShieldCheck },
+ { href: "/profile/GD...CLIENT", label: "Profiles", icon: UserRound },
+ { href: "/jobs", label: "Disputes", icon: Gavel },
+];
+
+export function SiteShell({
+ eyebrow,
+ title,
+ description,
+ children,
+}: {
+ eyebrow?: string;
+ title: string;
+ description?: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+
+
+ {eyebrow ? (
+
+ {eyebrow}
+
+ ) : null}
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+
{children}
+
+
+ );
+}
diff --git a/apps/web/components/stars.tsx b/apps/web/components/stars.tsx
new file mode 100644
index 00000000..2aa994ff
--- /dev/null
+++ b/apps/web/components/stars.tsx
@@ -0,0 +1,30 @@
+import { Star } from "lucide-react";
+
+export function Stars({
+ value,
+ total = 5,
+ className = "",
+}: {
+ value: number;
+ total?: number;
+ className?: string;
+}) {
+ return (
+
+ {Array.from({ length: total }, (_, index) => {
+ const fill = Math.max(0, Math.min(1, value - index));
+ return (
+
+
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/hooks/use-job-board.ts b/apps/web/hooks/use-job-board.ts
new file mode 100644
index 00000000..d5d6282c
--- /dev/null
+++ b/apps/web/hooks/use-job-board.ts
@@ -0,0 +1,208 @@
+"use client";
+
+import { startTransition, useDeferredValue, useEffect, useState } from "react";
+import { api, type Job } from "@/lib/api";
+import {
+ getReputationMetrics,
+ type ReputationMetrics,
+} from "@/lib/reputation";
+
+export type JobSort = "budget" | "chronological" | "reputation";
+
+export interface BoardJob extends Job {
+ tags: string[];
+ deadlineAt: string;
+ clientReputation: ReputationMetrics;
+}
+
+const TAG_PATTERNS: Array<[string, RegExp]> = [
+ ["soroban", /soroban|stellar|smart contract|escrow/i],
+ ["frontend", /frontend|react|next|ui|dashboard/i],
+ ["design", /design|brand|graphic|figma/i],
+ ["devops", /deploy|infra|ci|ops|automation/i],
+ ["ai", /judge|llm|agent|ai/i],
+ ["growth", /seo|marketing|community|content/i],
+];
+
+const MOCK_TITLES = [
+ "Design a Stellar-native creator dashboard",
+ "Ship a Soroban escrow milestone system",
+ "Refactor dispute evidence ingestion pipeline",
+ "Brand identity system for premium freelancing studio",
+ "Build an analytics cockpit for job execution",
+ "OpenClaw judge prompt evaluation sprint",
+ "Marketing site revamp for high-ticket consulting",
+ "DevOps hardening for release workflows",
+ "Client portal for milestone approvals",
+ "Portfolio refresh for an enterprise freelancer collective",
+ "Soroban reputation viewer with trust signals",
+ "Creative direction pack for product launch",
+];
+
+function inferTags(job: Job): string[] {
+ const source = `${job.title} ${job.description}`;
+ const tags = TAG_PATTERNS.filter(([, pattern]) => pattern.test(source)).map(
+ ([tag]) => tag,
+ );
+
+ if (tags.length === 0) {
+ tags.push("general");
+ }
+
+ return tags.slice(0, 3);
+}
+
+function buildDeadline(index: number, createdAt: string): string {
+ const base = new Date(createdAt);
+ base.setDate(base.getDate() + 5 + index * 3);
+ return base.toISOString();
+}
+
+function createMockJobs(): Job[] {
+ return Array.from({ length: 18 }, (_, index) => {
+ const createdAt = new Date(Date.now() - index * 86400000).toISOString();
+ return {
+ id: `mock-job-${index + 1}`,
+ title: MOCK_TITLES[index % MOCK_TITLES.length],
+ description:
+ "Curated mock marketplace record used when the backend is unavailable. This still exercises filtering, sorting, and the presentational layout cleanly.",
+ budget_usdc: (1800 + index * 350) * 10_000_000,
+ milestones: (index % 3) + 1,
+ client_address: `GMOCKCLIENTADDRESS${String(index).padStart(2, "0")}XXXXXXXXXXXXXXXXXXXX`,
+ freelancer_address: undefined,
+ status: "open",
+ metadata_hash: undefined,
+ on_chain_job_id: undefined,
+ created_at: createdAt,
+ updated_at: createdAt,
+ };
+ });
+}
+
+async function buildBoardJobs(sourceJobs: Job[]): Promise {
+ const uniqueClients = [...new Set(sourceJobs.map((job) => job.client_address))];
+ const reputationEntries: Array<[string, ReputationMetrics]> = await Promise.all(
+ uniqueClients.map(async (address) => [
+ address,
+ await getReputationMetrics(address, "client"),
+ ] as [string, ReputationMetrics]),
+ );
+ const reputationMap = new Map(reputationEntries);
+
+ return sourceJobs.map((job, index) => ({
+ ...job,
+ tags: inferTags(job),
+ deadlineAt: buildDeadline(index, job.created_at),
+ clientReputation: reputationMap.get(job.client_address) ?? {
+ scoreBps: 5000,
+ totalJobs: 0,
+ totalPoints: 0,
+ reviews: 0,
+ starRating: 2.5,
+ averageStars: 2.5,
+ },
+ }));
+}
+
+export function useJobBoard() {
+ const [jobs, setJobs] = useState([]);
+ const [query, setQuery] = useState("");
+ const [activeTag, setActiveTag] = useState("all");
+ const [sortBy, setSortBy] = useState("chronological");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const deferredQuery = useDeferredValue(query);
+
+ useEffect(() => {
+ let active = true;
+
+ async function loadBoard() {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const jobsFromApi = await api.jobs.list();
+ const sourceJobs = jobsFromApi.length > 0 ? jobsFromApi : createMockJobs();
+ const hydrated = await buildBoardJobs(sourceJobs);
+ if (active) {
+ setJobs(hydrated);
+ }
+ } catch (loadError) {
+ const fallback = await buildBoardJobs(createMockJobs());
+ if (active) {
+ setJobs(fallback);
+ setError(
+ loadError instanceof Error
+ ? loadError.message
+ : "Unable to load live jobs right now.",
+ );
+ }
+ } finally {
+ if (active) {
+ setLoading(false);
+ }
+ }
+ }
+
+ void loadBoard();
+
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ const availableTags = ["all", ...new Set(jobs.flatMap((job) => job.tags))];
+ let visibleJobs = jobs.filter((job) => job.status === "open");
+
+ if (activeTag !== "all") {
+ visibleJobs = visibleJobs.filter((job) => job.tags.includes(activeTag));
+ }
+
+ if (deferredQuery.trim()) {
+ const term = deferredQuery.trim().toLowerCase();
+ visibleJobs = visibleJobs.filter((job) =>
+ [job.title, job.description, job.client_address, ...job.tags]
+ .join(" ")
+ .toLowerCase()
+ .includes(term),
+ );
+ }
+
+ visibleJobs = [...visibleJobs].sort((left, right) => {
+ if (sortBy === "budget") {
+ return right.budget_usdc - left.budget_usdc;
+ }
+ if (sortBy === "reputation") {
+ return right.clientReputation.scoreBps - left.clientReputation.scoreBps;
+ }
+ return (
+ new Date(right.created_at).getTime() - new Date(left.created_at).getTime()
+ );
+ });
+
+ const actions = {
+ setQuery,
+ setActiveTag(nextTag: string) {
+ startTransition(() => {
+ setActiveTag(nextTag);
+ });
+ },
+ setSortBy(nextSort: JobSort) {
+ startTransition(() => {
+ setSortBy(nextSort);
+ });
+ },
+ };
+
+ return {
+ jobs: visibleJobs,
+ loading,
+ error,
+ query,
+ activeTag,
+ sortBy,
+ availableTags,
+ actions,
+ };
+}
diff --git a/apps/web/hooks/use-live-job-workspace.ts b/apps/web/hooks/use-live-job-workspace.ts
new file mode 100644
index 00000000..3512fc59
--- /dev/null
+++ b/apps/web/hooks/use-live-job-workspace.ts
@@ -0,0 +1,114 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import {
+ api,
+ type Bid,
+ type Deliverable,
+ type Dispute,
+ type Job,
+ type Milestone,
+} from "@/lib/api";
+import {
+ getReputationMetrics,
+ type ReputationMetrics,
+} from "@/lib/reputation";
+
+export interface LiveJobWorkspace {
+ job: Job | null;
+ bids: Bid[];
+ milestones: Milestone[];
+ deliverables: Deliverable[];
+ dispute: Dispute | null;
+ clientReputation: ReputationMetrics | null;
+ freelancerReputation: ReputationMetrics | null;
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+}
+
+async function safeLoadDispute(jobId: string) {
+ try {
+ return await api.jobs.dispute.get(jobId);
+ } catch {
+ return null;
+ }
+}
+
+export function useLiveJobWorkspace(jobId: string): LiveJobWorkspace {
+ const [job, setJob] = useState(null);
+ const [bids, setBids] = useState([]);
+ const [milestones, setMilestones] = useState([]);
+ const [deliverables, setDeliverables] = useState([]);
+ const [dispute, setDispute] = useState(null);
+ const [clientReputation, setClientReputation] =
+ useState(null);
+ const [freelancerReputation, setFreelancerReputation] =
+ useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ const [nextJob, nextBids, nextMilestones, nextDeliverables, nextDispute] =
+ await Promise.all([
+ api.jobs.get(jobId),
+ api.bids.list(jobId).catch(() => []),
+ api.jobs.milestones(jobId).catch(() => []),
+ api.jobs.deliverables.list(jobId).catch(() => []),
+ safeLoadDispute(jobId),
+ ]);
+
+ setJob(nextJob);
+ setBids(nextBids);
+ setMilestones(nextMilestones);
+ setDeliverables(nextDeliverables);
+ setDispute(nextDispute);
+
+ const [nextClientRep, nextFreelancerRep] = await Promise.all([
+ getReputationMetrics(nextJob.client_address, "client"),
+ nextJob.freelancer_address
+ ? getReputationMetrics(nextJob.freelancer_address, "freelancer")
+ : Promise.resolve(null),
+ ]);
+
+ setClientReputation(nextClientRep);
+ setFreelancerReputation(nextFreelancerRep);
+ setError(null);
+ } catch (loadError) {
+ setError(
+ loadError instanceof Error
+ ? loadError.message
+ : "Unable to load job workspace.",
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, [jobId]);
+
+ useEffect(() => {
+ setLoading(true);
+ void refresh();
+
+ const interval = window.setInterval(() => {
+ void refresh();
+ }, 4000);
+
+ return () => {
+ window.clearInterval(interval);
+ };
+ }, [jobId, refresh]);
+
+ return {
+ job,
+ bids,
+ milestones,
+ deliverables,
+ dispute,
+ clientReputation,
+ freelancerReputation,
+ loading,
+ error,
+ refresh,
+ };
+}
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts
index 361ab9b7..863fdb36 100644
--- a/apps/web/lib/api.ts
+++ b/apps/web/lib/api.ts
@@ -2,10 +2,26 @@ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
async function request(path: string, init?: RequestInit): Promise {
const res = await fetch(`${API}/api${path}`, {
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ ...(init?.headers ?? {}),
+ },
...init,
});
- if (!res.ok) throw new Error(await res.text());
+
+ if (!res.ok) {
+ const body = await res.text();
+ let parsedMessage: string | undefined;
+ try {
+ const parsed = JSON.parse(body) as { error?: string };
+ parsedMessage = parsed.error;
+ } catch {
+ parsedMessage = undefined;
+ }
+
+ throw new Error(parsedMessage || body || `Request failed with status ${res.status}`);
+ }
+
return res.json() as Promise;
}
@@ -15,6 +31,32 @@ export const api = {
get: (id: string) => request(`/v1/jobs/${id}`),
create: (body: CreateJobBody) =>
request("/v1/jobs", { method: "POST", body: JSON.stringify(body) }),
+ markFunded: (id: string, body: MarkFundedBody) =>
+ request(`/v1/jobs/${id}/fund`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ milestones: (id: string) => request(`/v1/jobs/${id}/milestones`),
+ releaseMilestone: (id: string, milestoneId: string) =>
+ request(`/v1/jobs/${id}/milestones/${milestoneId}/release`, {
+ method: "POST",
+ }),
+ deliverables: {
+ list: (jobId: string) => request(`/v1/jobs/${jobId}/deliverables`),
+ submit: (jobId: string, body: SubmitDeliverableBody) =>
+ request(`/v1/jobs/${jobId}/deliverables`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ dispute: {
+ get: (jobId: string) => request(`/v1/jobs/${jobId}/dispute`),
+ open: (jobId: string, body: { opened_by: string }) =>
+ request(`/v1/jobs/${jobId}/dispute`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
},
bids: {
list: (jobId: string) => request(`/v1/jobs/${jobId}/bids`),
@@ -23,37 +65,54 @@ export const api = {
method: "POST",
body: JSON.stringify(body),
}),
- },
- disputes: {
- open: (jobId: string, body: { opened_by: string }) =>
- request(`/v1/jobs/${jobId}/dispute`, {
+ accept: (jobId: string, bidId: string, body: AcceptBidBody) =>
+ request(`/v1/jobs/${jobId}/bids/${bidId}/accept`, {
method: "POST",
body: JSON.stringify(body),
}),
+ },
+ disputes: {
get: (id: string) => request(`/v1/disputes/${id}`),
verdict: (id: string) => request(`/v1/disputes/${id}/verdict`),
- submitEvidence: (id: string, body: EvidenceBody) =>
- request(`/v1/disputes/${id}/evidence`, {
- method: "POST",
- body: JSON.stringify(body),
- }),
+ evidence: {
+ list: (id: string) => request(`/v1/disputes/${id}/evidence`),
+ submit: (id: string, body: EvidenceBody) =>
+ request(`/v1/disputes/${id}/evidence`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
},
uploads: {
pin: (file: File): Promise<{ cid: string; filename: string }> => {
const form = new FormData();
form.append("file", file);
- return fetch(`${API}/api/v1/uploads`, { method: "POST", body: form }).then(
- async (res) => {
- if (!res.ok) throw new Error(await res.text());
- return res.json();
+
+ return fetch(`${API}/api/v1/uploads`, {
+ method: "POST",
+ body: form,
+ }).then(async (res) => {
+ if (!res.ok) {
+ throw new Error(await res.text());
}
- );
+ return res.json();
+ });
},
},
+ users: {
+ getProfile: (address: string) =>
+ request(`/v1/users/${address}/profile`),
+ updateProfile: (address: string, walletAddress: string, body: UpdateProfileBody) =>
+ request(`/v1/users/${address}/profile`, {
+ method: "PUT",
+ headers: {
+ "x-wallet-address": walletAddress,
+ },
+ body: JSON.stringify(body),
+ }),
+ },
};
-// βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
export interface Job {
id: string;
title: string;
@@ -68,6 +127,7 @@ export interface Job {
created_at: string;
updated_at: string;
}
+
export interface CreateJobBody {
title: string;
description: string;
@@ -75,6 +135,11 @@ export interface CreateJobBody {
milestones: number;
client_address: string;
}
+
+export interface MarkFundedBody {
+ client_address: string;
+}
+
export interface Bid {
id: string;
job_id: string;
@@ -83,10 +148,47 @@ export interface Bid {
status: string;
created_at: string;
}
+
export interface CreateBidBody {
freelancer_address: string;
proposal: string;
}
+
+export interface AcceptBidBody {
+ client_address: string;
+}
+
+export interface Milestone {
+ id: string;
+ job_id: string;
+ index: number;
+ title: string;
+ amount_usdc: number;
+ status: string;
+ tx_hash?: string;
+ released_at?: string;
+}
+
+export interface Deliverable {
+ id: string;
+ job_id: string;
+ milestone_index: number;
+ submitted_by: string;
+ label: string;
+ kind: string;
+ url: string;
+ file_hash?: string;
+ created_at: string;
+}
+
+export interface SubmitDeliverableBody {
+ submitted_by: string;
+ label: string;
+ kind: string;
+ url: string;
+ file_hash?: string;
+}
+
export interface Dispute {
id: string;
job_id: string;
@@ -94,18 +196,22 @@ export interface Dispute {
status: string;
created_at: string;
}
+
export interface Evidence {
id: string;
dispute_id: string;
submitted_by: string;
content: string;
file_hash?: string;
+ created_at: string;
}
+
export interface EvidenceBody {
submitted_by: string;
content: string;
file_hash?: string;
}
+
export interface Verdict {
id: string;
dispute_id: string;
@@ -113,4 +219,43 @@ export interface Verdict {
freelancer_share_bps: number;
reasoning: string;
on_chain_tx?: string;
+ created_at: string;
+}
+
+export interface ProfileMetrics {
+ total_jobs: number;
+ completed_jobs: number;
+ active_jobs: number;
+ disputed_jobs: number;
+ verified_volume_usdc: number;
+ completion_rate: number;
+ dispute_rate: number;
+}
+
+export interface ProfileJobLedgerEntry {
+ job_id: string;
+ title: string;
+ budget_usdc: number;
+ role: string;
+ counterparty: string;
+ status: string;
+ completed_at: string;
+}
+
+export interface PublicProfile {
+ address: string;
+ display_name?: string;
+ headline: string;
+ bio: string;
+ portfolio_links: string[];
+ updated_at: string;
+ metrics: ProfileMetrics;
+ history: ProfileJobLedgerEntry[];
+}
+
+export interface UpdateProfileBody {
+ display_name?: string;
+ headline: string;
+ bio: string;
+ portfolio_links: string[];
}
diff --git a/apps/web/lib/contracts.ts b/apps/web/lib/contracts.ts
index 0b5e0850..a1dbd7c4 100644
--- a/apps/web/lib/contracts.ts
+++ b/apps/web/lib/contracts.ts
@@ -166,6 +166,32 @@ export async function releaseMilestone(jobId: bigint): Promise {
]);
}
+/**
+ * Calls escrow.release_funds for an explicit milestone index.
+ *
+ * @returns Confirmed transaction hash.
+ */
+export async function releaseFunds(
+ jobId: bigint,
+ milestoneIndex: number,
+): Promise {
+ if (process.env.NEXT_PUBLIC_E2E === "true") return "FAKE_TX_HASH";
+ if (jobId < 0n) {
+ throw new Error("Invalid jobId: must be a non-negative integer.");
+ }
+ if (milestoneIndex < 0) {
+ throw new Error("Invalid milestone index.");
+ }
+
+ const callerAddress = await getCallerAddress();
+
+ return invokeEscrow(callerAddress, "release_funds", [
+ nativeToScVal(jobId, { type: "u64" }),
+ Address.fromString(callerAddress).toScVal(),
+ nativeToScVal(milestoneIndex, { type: "u32" }),
+ ]);
+}
+
/**
* Calls escrow.open_dispute β raises a dispute on the escrow job.
* The caller (client or freelancer) is derived from the connected wallet.
diff --git a/apps/web/lib/format.ts b/apps/web/lib/format.ts
new file mode 100644
index 00000000..24cb0d2f
--- /dev/null
+++ b/apps/web/lib/format.ts
@@ -0,0 +1,44 @@
+const MICRO_USDC = 10_000_000;
+
+export function formatUsdc(microUsdc: number): string {
+ return (microUsdc / MICRO_USDC).toLocaleString("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+}
+
+export function formatPercent(value: number): string {
+ return `${Math.round(value * 100)}%`;
+}
+
+export function formatDate(value?: string): string {
+ if (!value) return "Pending";
+ return new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }).format(new Date(value));
+}
+
+export function formatDateTime(value?: string): string {
+ if (!value) return "Pending";
+ return new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ }).format(new Date(value));
+}
+
+export function shortenAddress(address: string, lead = 6, tail = 4): string {
+ if (!address) return "";
+ if (address.length <= lead + tail + 1) return address;
+ return `${address.slice(0, lead)}...${address.slice(-tail)}`;
+}
+
+export function toStarRating(scoreBps: number): number {
+ return Math.max(0, Math.min(5, scoreBps / 2000));
+}
diff --git a/apps/web/lib/reputation.ts b/apps/web/lib/reputation.ts
new file mode 100644
index 00000000..2c994931
--- /dev/null
+++ b/apps/web/lib/reputation.ts
@@ -0,0 +1,103 @@
+import {
+ Account,
+ Address,
+ BASE_FEE,
+ Contract,
+ Keypair,
+ Networks,
+ TransactionBuilder,
+ nativeToScVal,
+ scValToNative,
+} from "@stellar/stellar-sdk";
+import { Server as SorobanServer } from "@stellar/stellar-sdk/rpc";
+import { toStarRating } from "./format";
+
+const REPUTATION_CONTRACT_ID =
+ process.env.NEXT_PUBLIC_REPUTATION_CONTRACT_ID ?? "";
+const RPC_URL =
+ process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ??
+ "https://soroban-testnet.stellar.org";
+const NETWORK_PASSPHRASE =
+ (process.env.NEXT_PUBLIC_STELLAR_NETWORK as Networks) ?? Networks.TESTNET;
+
+export type ReputationRole = "client" | "freelancer";
+
+export interface ReputationMetrics {
+ scoreBps: number;
+ totalJobs: number;
+ totalPoints: number;
+ reviews: number;
+ starRating: number;
+ averageStars: number;
+}
+
+function normalizeNumber(value: unknown): number {
+ if (typeof value === "number") return value;
+ if (typeof value === "bigint") return Number(value);
+ if (typeof value === "string") return Number(value);
+ return 0;
+}
+
+function fallbackMetrics(): ReputationMetrics {
+ const scoreBps = 5000;
+ return {
+ scoreBps,
+ totalJobs: 0,
+ totalPoints: 0,
+ reviews: 0,
+ starRating: toStarRating(scoreBps),
+ averageStars: 2.5,
+ };
+}
+
+export async function getReputationMetrics(
+ address: string,
+ role: ReputationRole,
+): Promise {
+ if (!REPUTATION_CONTRACT_ID) {
+ return fallbackMetrics();
+ }
+
+ try {
+ const rpc = new SorobanServer(RPC_URL);
+ const contract = new Contract(REPUTATION_CONTRACT_ID);
+ const account = new Account(Keypair.random().publicKey(), "0");
+
+ const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: NETWORK_PASSPHRASE,
+ })
+ .addOperation(
+ contract.call(
+ "get_public_metrics",
+ Address.fromString(address).toScVal(),
+ nativeToScVal(role, { type: "symbol" }),
+ ),
+ )
+ .setTimeout(30)
+ .build();
+
+ const simulation = await rpc.simulateTransaction(tx);
+ const raw =
+ "result" in simulation && simulation.result?.retval
+ ? (scValToNative(simulation.result.retval) as unknown[])
+ : [];
+
+ const scoreBps = normalizeNumber(raw[0]);
+ const totalJobs = normalizeNumber(raw[1]);
+ const totalPoints = normalizeNumber(raw[2]);
+ const reviews = normalizeNumber(raw[3]);
+ const averageStars = reviews > 0 ? totalPoints / reviews : toStarRating(scoreBps);
+
+ return {
+ scoreBps,
+ totalJobs,
+ totalPoints,
+ reviews,
+ starRating: toStarRating(scoreBps),
+ averageStars,
+ };
+ } catch {
+ return fallbackMetrics();
+ }
+}
diff --git a/apps/web/lib/stellar.ts b/apps/web/lib/stellar.ts
index 6375707f..f476939d 100644
--- a/apps/web/lib/stellar.ts
+++ b/apps/web/lib/stellar.ts
@@ -37,6 +37,16 @@ export async function connectWallet(): Promise {
});
}
+export async function getConnectedWalletAddress(): Promise {
+ if (process.env.NEXT_PUBLIC_E2E === "true") return "GD...CLIENT";
+ try {
+ const { address } = await getWalletsKit().getAddress();
+ return address ?? null;
+ } catch {
+ return null;
+ }
+}
+
/**
* Signs an XDR transaction string via the connected wallet.
* Returns the signed XDR string ready for submission to the Soroban RPC.
diff --git a/apps/web/lib/toast.tsx b/apps/web/lib/toast.tsx
index 9e8597d4..bbe8891a 100644
--- a/apps/web/lib/toast.tsx
+++ b/apps/web/lib/toast.tsx
@@ -1,6 +1,6 @@
"use client";
-import { toast as sonnerToast, type ToastT } from "sonner";
+import { toast as sonnerToast } from "sonner";
import { Loader2, CheckCircle, XCircle, AlertCircle, Info } from "lucide-react";
import type { ReactNode } from "react";
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index e9ffa308..078e9e1e 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -1,7 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ turbopack: {
+ root: process.cwd(),
+ },
};
export default nextConfig;
diff --git a/apps/web/package.json b/apps/web/package.json
index f93a098a..02de207e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
- "build": "next build",
+ "build": "next build --webpack",
"start": "next start",
"lint": "eslint"
},
diff --git a/backend/migrations/20260329000001_profiles_and_deliverables.sql b/backend/migrations/20260329000001_profiles_and_deliverables.sql
new file mode 100644
index 00000000..733d473a
--- /dev/null
+++ b/backend/migrations/20260329000001_profiles_and_deliverables.sql
@@ -0,0 +1,30 @@
+-- Migration 003: public profiles and milestone deliverables
+
+CREATE TABLE IF NOT EXISTS profiles (
+ address TEXT PRIMARY KEY,
+ display_name TEXT,
+ headline TEXT NOT NULL DEFAULT '',
+ bio TEXT NOT NULL DEFAULT '',
+ portfolio_links JSONB NOT NULL DEFAULT '[]'::jsonb,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS deliverables (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
+ milestone_index INT NOT NULL,
+ submitted_by TEXT NOT NULL,
+ label TEXT NOT NULL DEFAULT '',
+ kind TEXT NOT NULL DEFAULT 'link',
+ url TEXT NOT NULL DEFAULT '',
+ file_hash TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS deliverables_job_created_idx
+ ON deliverables (job_id, created_at DESC);
+
+DROP TRIGGER IF EXISTS profiles_updated_at ON profiles;
+CREATE TRIGGER profiles_updated_at
+ BEFORE UPDATE ON profiles
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
diff --git a/backend/src/models.rs b/backend/src/models.rs
index 38080e81..59f1ef4b 100644
--- a/backend/src/models.rs
+++ b/backend/src/models.rs
@@ -13,7 +13,7 @@ pub struct Job {
pub milestones: i32,
pub client_address: String, // Stellar G⦠address
pub freelancer_address: Option,
- pub status: String, // open | in_progress | deliverable_submitted | completed | disputed
+ pub status: String, // open | awaiting_funding | funded | deliverable_submitted | completed | disputed
pub metadata_hash: Option, // IPFS CID
pub on_chain_job_id: Option,
pub created_at: DateTime,
@@ -29,6 +29,11 @@ pub struct CreateJobRequest {
pub client_address: String,
}
+#[derive(Debug, Deserialize)]
+pub struct MarkJobFundedRequest {
+ pub client_address: String,
+}
+
// ββ Bid βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
@@ -48,6 +53,11 @@ pub struct CreateBidRequest {
pub proposal: String,
}
+#[derive(Debug, Deserialize)]
+pub struct AcceptBidRequest {
+ pub client_address: String,
+}
+
// ββ Milestone βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
@@ -62,6 +72,30 @@ pub struct Milestone {
pub released_at: Option>,
}
+// ββ Deliverable βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
+pub struct Deliverable {
+ pub id: Uuid,
+ pub job_id: Uuid,
+ pub milestone_index: i32,
+ pub submitted_by: String,
+ pub label: String,
+ pub kind: String,
+ pub url: String,
+ pub file_hash: Option,
+ pub created_at: DateTime,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct SubmitDeliverableRequest {
+ pub submitted_by: String,
+ pub label: String,
+ pub kind: String,
+ pub url: String,
+ pub file_hash: Option,
+}
+
// ββ Dispute βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
@@ -110,6 +144,60 @@ pub struct Verdict {
pub created_at: DateTime,
}
+// ββ Profile βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
+pub struct UserProfileRecord {
+ pub address: String,
+ pub display_name: Option,
+ pub headline: String,
+ pub bio: String,
+ pub portfolio_links: serde_json::Value,
+ pub updated_at: DateTime,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct ProfileMetrics {
+ pub total_jobs: i64,
+ pub completed_jobs: i64,
+ pub active_jobs: i64,
+ pub disputed_jobs: i64,
+ pub verified_volume_usdc: i64,
+ pub completion_rate: f64,
+ pub dispute_rate: f64,
+}
+
+#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
+pub struct ProfileJobLedgerEntry {
+ pub job_id: Uuid,
+ pub title: String,
+ pub budget_usdc: i64,
+ pub role: String,
+ pub counterparty: String,
+ pub status: String,
+ pub completed_at: DateTime,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct PublicProfile {
+ pub address: String,
+ pub display_name: Option,
+ pub headline: String,
+ pub bio: String,
+ pub portfolio_links: Vec,
+ pub updated_at: DateTime,
+ pub metrics: ProfileMetrics,
+ pub history: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct UpdateProfileRequest {
+ pub display_name: Option,
+ pub headline: String,
+ pub bio: String,
+ pub portfolio_links: Vec,
+}
+
// ββ Appeal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/// 1000 USDC expressed in stroops (7-decimal micro-USDC).
diff --git a/backend/src/routes/bids.rs b/backend/src/routes/bids.rs
index ac2cff3a..66641d87 100644
--- a/backend/src/routes/bids.rs
+++ b/backend/src/routes/bids.rs
@@ -7,7 +7,7 @@ use uuid::Uuid;
use crate::{
db::AppState,
error::{AppError, Result},
- models::{Bid, CreateBidRequest},
+ models::{AcceptBidRequest, Bid, CreateBidRequest, Job},
};
pub async fn list_bids(
@@ -53,3 +53,62 @@ pub async fn create_bid(
.await?;
Ok(Json(bid))
}
+
+pub async fn accept_bid(
+ State(state): State,
+ Path((job_id, bid_id)): Path<(Uuid, Uuid)>,
+ Json(req): Json,
+) -> Result> {
+ let client_address: Option = sqlx::query_scalar(
+ "SELECT client_address FROM jobs WHERE id = $1 AND status = 'open'",
+ )
+ .bind(job_id)
+ .fetch_optional(&state.pool)
+ .await?;
+
+ match client_address.as_deref() {
+ Some(address) if address == req.client_address => {}
+ Some(_) => {
+ return Err(AppError::BadRequest(
+ "only the job owner can accept a bid".into(),
+ ))
+ }
+ None => {
+ return Err(AppError::BadRequest(
+ "job is not open for bid acceptance".into(),
+ ))
+ }
+ }
+
+ let freelancer_address: String = sqlx::query_scalar(
+ r#"SELECT freelancer_address
+ FROM bids
+ WHERE id = $1 AND job_id = $2"#,
+ )
+ .bind(bid_id)
+ .bind(job_id)
+ .fetch_optional(&state.pool)
+ .await?
+ .ok_or_else(|| AppError::NotFound(format!("bid {bid_id} not found for job {job_id}")))?;
+
+ sqlx::query("UPDATE bids SET status = CASE WHEN id = $1 THEN 'accepted' ELSE 'rejected' END WHERE job_id = $2")
+ .bind(bid_id)
+ .bind(job_id)
+ .execute(&state.pool)
+ .await?;
+
+ let job = sqlx::query_as::<_, Job>(
+ r#"UPDATE jobs
+ SET freelancer_address = $1, status = 'awaiting_funding'
+ WHERE id = $2
+ RETURNING id, title, description, budget_usdc, milestones, client_address,
+ freelancer_address, status, metadata_hash, on_chain_job_id,
+ created_at, updated_at"#,
+ )
+ .bind(freelancer_address)
+ .bind(job_id)
+ .fetch_one(&state.pool)
+ .await?;
+
+ Ok(Json(job))
+}
diff --git a/backend/src/routes/deliverables.rs b/backend/src/routes/deliverables.rs
new file mode 100644
index 00000000..c6e35c11
--- /dev/null
+++ b/backend/src/routes/deliverables.rs
@@ -0,0 +1,90 @@
+use axum::{
+ extract::{Path, State},
+ Json,
+};
+use uuid::Uuid;
+
+use crate::{
+ db::AppState,
+ error::{AppError, Result},
+ models::{Deliverable, SubmitDeliverableRequest},
+};
+
+pub async fn list_deliverables(
+ State(state): State,
+ Path(job_id): Path,
+) -> Result>> {
+ let deliverables = sqlx::query_as::<_, Deliverable>(
+ r#"SELECT id, job_id, milestone_index, submitted_by, label, kind, url, file_hash, created_at
+ FROM deliverables
+ WHERE job_id = $1
+ ORDER BY milestone_index ASC, created_at DESC"#,
+ )
+ .bind(job_id)
+ .fetch_all(&state.pool)
+ .await?;
+
+ Ok(Json(deliverables))
+}
+
+pub async fn submit_deliverable(
+ State(state): State,
+ Path(job_id): Path,
+ Json(req): Json,
+) -> Result> {
+ let (status, freelancer_address): (String, Option) = sqlx::query_as(
+ r#"SELECT status, freelancer_address
+ FROM jobs
+ WHERE id = $1"#,
+ )
+ .bind(job_id)
+ .fetch_optional(&state.pool)
+ .await?
+ .ok_or_else(|| AppError::NotFound(format!("job {job_id} not found")))?;
+
+ if !matches!(status.as_str(), "funded" | "in_progress") {
+ return Err(AppError::BadRequest(format!(
+ "deliverables can only be submitted for funded jobs, not '{status}'"
+ )));
+ }
+
+ if freelancer_address.as_deref() != Some(req.submitted_by.as_str()) {
+ return Err(AppError::BadRequest(
+ "only the assigned freelancer can submit deliverables".into(),
+ ));
+ }
+
+ let milestone_index: i32 = sqlx::query_scalar(
+ r#"SELECT index
+ FROM milestones
+ WHERE job_id = $1 AND status = 'pending'
+ ORDER BY index ASC
+ LIMIT 1"#,
+ )
+ .bind(job_id)
+ .fetch_optional(&state.pool)
+ .await?
+ .ok_or_else(|| AppError::BadRequest("all milestones have already been delivered".into()))?;
+
+ let deliverable = sqlx::query_as::<_, Deliverable>(
+ r#"INSERT INTO deliverables (job_id, milestone_index, submitted_by, label, kind, url, file_hash)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING id, job_id, milestone_index, submitted_by, label, kind, url, file_hash, created_at"#,
+ )
+ .bind(job_id)
+ .bind(milestone_index)
+ .bind(req.submitted_by)
+ .bind(req.label)
+ .bind(req.kind)
+ .bind(req.url)
+ .bind(req.file_hash)
+ .fetch_one(&state.pool)
+ .await?;
+
+ sqlx::query("UPDATE jobs SET status = 'deliverable_submitted' WHERE id = $1")
+ .bind(job_id)
+ .execute(&state.pool)
+ .await?;
+
+ Ok(Json(deliverable))
+}
diff --git a/backend/src/routes/disputes.rs b/backend/src/routes/disputes.rs
index bf57d7ca..d694a548 100644
--- a/backend/src/routes/disputes.rs
+++ b/backend/src/routes/disputes.rs
@@ -15,7 +15,7 @@ use crate::{
pub fn router() -> Router {
Router::new()
.route("/:id", get(get_dispute))
- .route("/:id/evidence", post(evidence::submit_evidence))
+ .route("/:id/evidence", get(evidence::list_evidence).post(evidence::submit_evidence))
.route("/:id/verdict", get(crate::routes::verdicts::get_verdict))
.route("/:id/appeal", post(appeals::create_appeal))
}
@@ -33,7 +33,7 @@ pub async fn open_dispute_for_job(
.await?;
match status.as_deref() {
- Some("in_progress") | Some("deliverable_submitted") => {}
+ Some("funded") | Some("in_progress") | Some("deliverable_submitted") => {}
Some(s) => return Err(AppError::BadRequest(format!("cannot dispute job in status '{s}'"))),
None => return Err(AppError::NotFound(format!("job {job_id} not found"))),
}
@@ -72,3 +72,22 @@ async fn get_dispute(
.ok_or_else(|| AppError::NotFound(format!("dispute {dispute_id} not found")))?;
Ok(Json(dispute))
}
+
+pub async fn get_job_dispute(
+ State(state): State,
+ Path(job_id): Path,
+) -> Result> {
+ let dispute = sqlx::query_as::<_, Dispute>(
+ r#"SELECT id, job_id, opened_by, status, created_at
+ FROM disputes
+ WHERE job_id = $1
+ ORDER BY created_at DESC
+ LIMIT 1"#,
+ )
+ .bind(job_id)
+ .fetch_optional(&state.pool)
+ .await?
+ .ok_or_else(|| AppError::NotFound(format!("job {job_id} has no dispute")))?;
+
+ Ok(Json(dispute))
+}
diff --git a/backend/src/routes/evidence.rs b/backend/src/routes/evidence.rs
index a04ec954..e39f9706 100644
--- a/backend/src/routes/evidence.rs
+++ b/backend/src/routes/evidence.rs
@@ -10,6 +10,23 @@ use crate::{
models::{Evidence, SubmitEvidenceRequest},
};
+pub async fn list_evidence(
+ State(state): State,
+ Path(dispute_id): Path,
+) -> Result>> {
+ let evidence = sqlx::query_as::<_, Evidence>(
+ r#"SELECT id, dispute_id, submitted_by, content, file_hash, created_at
+ FROM evidence
+ WHERE dispute_id = $1
+ ORDER BY created_at ASC"#,
+ )
+ .bind(dispute_id)
+ .fetch_all(&state.pool)
+ .await?;
+
+ Ok(Json(evidence))
+}
+
pub async fn submit_evidence(
State(state): State,
Path(dispute_id): Path,
diff --git a/backend/src/routes/jobs.rs b/backend/src/routes/jobs.rs
index b0b847c5..6b50bf8a 100644
--- a/backend/src/routes/jobs.rs
+++ b/backend/src/routes/jobs.rs
@@ -8,17 +8,21 @@ use uuid::Uuid;
use crate::{
db::AppState,
error::{AppError, Result},
- models::{CreateJobRequest, Job},
- routes::{bids, milestones},
+ models::{CreateJobRequest, Job, MarkJobFundedRequest},
+ routes::{bids, deliverables, milestones},
};
pub fn router() -> Router {
Router::new()
.route("/", get(list_jobs).post(create_job))
.route("/:id", get(get_job))
+ .route("/:id/fund", post(mark_job_funded))
.route("/:id/bids", get(bids::list_bids).post(bids::create_bid))
+ .route("/:id/bids/:bid_id/accept", post(bids::accept_bid))
+ .route("/:id/deliverables", get(deliverables::list_deliverables).post(deliverables::submit_deliverable))
+ .route("/:id/dispute", get(crate::routes::disputes::get_job_dispute).post(crate::routes::disputes::open_dispute_for_job))
+ .route("/:id/milestones", get(milestones::list_milestones))
.route("/:id/milestones/:mid/release", post(milestones::release_milestone))
- .route("/:id/dispute", post(crate::routes::disputes::open_dispute_for_job))
}
async fn list_jobs(State(state): State) -> Result>> {
@@ -57,6 +61,15 @@ async fn create_job(
if req.title.is_empty() {
return Err(AppError::BadRequest("title is required".into()));
}
+ if req.milestones < 1 {
+ return Err(AppError::BadRequest("milestones must be at least 1".into()));
+ }
+ if req.budget_usdc <= 0 {
+ return Err(AppError::BadRequest("budget must be greater than zero".into()));
+ }
+
+ let mut tx = state.pool.begin().await?;
+
let job = sqlx::query_as::<_, Job>(
r#"INSERT INTO jobs (title, description, budget_usdc, milestones, client_address, status)
VALUES ($1, $2, $3, $4, $5, 'open')
@@ -69,7 +82,73 @@ async fn create_job(
.bind(req.budget_usdc)
.bind(req.milestones)
.bind(req.client_address)
+ .fetch_one(&mut *tx)
+ .await?;
+
+ let per_milestone = job.budget_usdc / i64::from(job.milestones);
+ let remainder = job.budget_usdc % i64::from(job.milestones);
+
+ for index in 0..job.milestones {
+ let amount_usdc = if index == job.milestones - 1 {
+ per_milestone + remainder
+ } else {
+ per_milestone
+ };
+
+ sqlx::query(
+ r#"INSERT INTO milestones (job_id, index, title, amount_usdc, status)
+ VALUES ($1, $2, $3, $4, 'pending')"#,
+ )
+ .bind(job.id)
+ .bind(index + 1)
+ .bind(format!("Milestone {}", index + 1))
+ .bind(amount_usdc)
+ .execute(&mut *tx)
+ .await?;
+ }
+
+ tx.commit().await?;
+ Ok(Json(job))
+}
+
+async fn mark_job_funded(
+ State(state): State,
+ Path(job_id): Path,
+ Json(req): Json,
+) -> Result> {
+ let (client_address, freelancer_address, status): (String, Option, String) =
+ sqlx::query_as(
+ r#"SELECT client_address, freelancer_address, status
+ FROM jobs WHERE id = $1"#,
+ )
+ .bind(job_id)
+ .fetch_optional(&state.pool)
+ .await?
+ .ok_or_else(|| AppError::NotFound(format!("job {job_id} not found")))?;
+
+ if client_address != req.client_address {
+ return Err(AppError::BadRequest("only the client can mark a job as funded".into()));
+ }
+ if freelancer_address.is_none() {
+ return Err(AppError::BadRequest("job must have an accepted freelancer first".into()));
+ }
+ if !matches!(status.as_str(), "awaiting_funding" | "funded" | "in_progress") {
+ return Err(AppError::BadRequest(format!(
+ "job status '{status}' cannot transition to funded"
+ )));
+ }
+
+ let job = sqlx::query_as::<_, Job>(
+ r#"UPDATE jobs
+ SET status = 'funded'
+ WHERE id = $1
+ RETURNING id, title, description, budget_usdc, milestones, client_address,
+ freelancer_address, status, metadata_hash, on_chain_job_id,
+ created_at, updated_at"#
+ )
+ .bind(job_id)
.fetch_one(&state.pool)
.await?;
+
Ok(Json(job))
}
diff --git a/backend/src/routes/milestones.rs b/backend/src/routes/milestones.rs
index d046619e..29525387 100644
--- a/backend/src/routes/milestones.rs
+++ b/backend/src/routes/milestones.rs
@@ -10,6 +10,23 @@ use crate::{
models::Milestone,
};
+pub async fn list_milestones(
+ State(state): State,
+ Path(job_id): Path,
+) -> Result>> {
+ let milestones = sqlx::query_as::<_, Milestone>(
+ r#"SELECT id, job_id, index, title, amount_usdc, status, tx_hash, released_at
+ FROM milestones
+ WHERE job_id = $1
+ ORDER BY index ASC"#,
+ )
+ .bind(job_id)
+ .fetch_all(&state.pool)
+ .await?;
+
+ Ok(Json(milestones))
+}
+
pub async fn release_milestone(
State(state): State,
Path((job_id, milestone_id)): Path<(Uuid, Uuid)>,
@@ -29,6 +46,24 @@ pub async fn release_milestone(
return Err(AppError::BadRequest("milestone already released".into()));
}
+ let deliverable_exists: bool = sqlx::query_scalar(
+ r#"SELECT EXISTS(
+ SELECT 1
+ FROM deliverables
+ WHERE job_id = $1 AND milestone_index = $2
+ )"#,
+ )
+ .bind(job_id)
+ .bind(milestone.index)
+ .fetch_one(&state.pool)
+ .await?;
+
+ if !deliverable_exists {
+ return Err(AppError::BadRequest(
+ "a milestone deliverable must be submitted before release".into(),
+ ));
+ }
+
// TODO: call Soroban escrow contract via stellar.rs service
// services::stellar::release_milestone(&job_id.to_string(), milestone.index).await?;
let tx_hash: Option = None; // Placeholder for tx_hash from stellar.rs service
@@ -43,5 +78,26 @@ pub async fn release_milestone(
.fetch_one(&state.pool)
.await?;
+ let remaining_pending: i64 = sqlx::query_scalar(
+ r#"SELECT COUNT(*)
+ FROM milestones
+ WHERE job_id = $1 AND status = 'pending'"#,
+ )
+ .bind(job_id)
+ .fetch_one(&state.pool)
+ .await?;
+
+ let next_status = if remaining_pending == 0 {
+ "completed"
+ } else {
+ "funded"
+ };
+
+ sqlx::query("UPDATE jobs SET status = $1 WHERE id = $2")
+ .bind(next_status)
+ .bind(job_id)
+ .execute(&state.pool)
+ .await?;
+
Ok(Json(updated))
}
diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs
index 26a203cd..6de1a1f3 100644
--- a/backend/src/routes/mod.rs
+++ b/backend/src/routes/mod.rs
@@ -1,5 +1,6 @@
pub mod appeals;
pub mod bids;
+pub mod deliverables;
pub mod disputes;
pub mod evidence;
pub mod health;
diff --git a/backend/src/routes/users.rs b/backend/src/routes/users.rs
index 9ef7df2f..d3e18fca 100644
--- a/backend/src/routes/users.rs
+++ b/backend/src/routes/users.rs
@@ -1,12 +1,191 @@
-use axum::{routing::get, Router};
+use axum::{
+ extract::{Path, State},
+ http::HeaderMap,
+ routing::get,
+ Json, Router,
+};
+use chrono::Utc;
-use crate::db::AppState;
+use crate::{
+ db::AppState,
+ error::{AppError, Result},
+ models::{
+ ProfileJobLedgerEntry, ProfileMetrics, PublicProfile, UpdateProfileRequest, UserProfileRecord,
+ },
+};
pub fn router() -> Router {
- Router::new().route("/", get(list_users))
+ Router::new()
+ .route("/", get(list_users))
+ .route("/:address/profile", get(get_profile).put(upsert_profile))
}
-/// GET /api/v1/users β stub; returns empty list until auth/profile system is built.
-async fn list_users() -> axum::Json> {
- axum::Json(vec![])
+async fn list_users(State(state): State) -> Result>> {
+ let users = sqlx::query_scalar::<_, String>(
+ r#"SELECT DISTINCT address
+ FROM profiles
+ ORDER BY address ASC"#,
+ )
+ .fetch_all(&state.pool)
+ .await?;
+
+ Ok(Json(users))
+}
+
+async fn get_profile(
+ State(state): State,
+ Path(address): Path,
+) -> Result> {
+ let profile = sqlx::query_as::<_, UserProfileRecord>(
+ r#"SELECT address, display_name, headline, bio, portfolio_links, updated_at
+ FROM profiles
+ WHERE address = $1"#,
+ )
+ .bind(&address)
+ .fetch_optional(&state.pool)
+ .await?;
+
+ let history = sqlx::query_as::<_, ProfileJobLedgerEntry>(
+ r#"SELECT
+ id AS job_id,
+ title,
+ budget_usdc,
+ CASE
+ WHEN client_address = $1 THEN 'client'
+ ELSE 'freelancer'
+ END AS role,
+ CASE
+ WHEN client_address = $1 THEN COALESCE(freelancer_address, 'unassigned')
+ ELSE client_address
+ END AS counterparty,
+ status,
+ updated_at AS completed_at
+ FROM jobs
+ WHERE (client_address = $1 OR freelancer_address = $1)
+ AND status = 'completed'
+ ORDER BY updated_at DESC
+ LIMIT 24"#,
+ )
+ .bind(&address)
+ .fetch_all(&state.pool)
+ .await?;
+
+ let (total_jobs, completed_jobs, active_jobs, disputed_jobs, verified_volume_usdc): (
+ i64,
+ i64,
+ i64,
+ i64,
+ i64,
+ ) = sqlx::query_as(
+ r#"SELECT
+ COUNT(*)::bigint AS total_jobs,
+ COUNT(*) FILTER (WHERE status = 'completed')::bigint AS completed_jobs,
+ COUNT(*) FILTER (
+ WHERE status IN ('awaiting_funding', 'funded', 'in_progress', 'deliverable_submitted')
+ )::bigint AS active_jobs,
+ COUNT(*) FILTER (WHERE status = 'disputed')::bigint AS disputed_jobs,
+ COALESCE(SUM(budget_usdc) FILTER (WHERE status = 'completed'), 0)::bigint AS verified_volume_usdc
+ FROM jobs
+ WHERE client_address = $1 OR freelancer_address = $1"#,
+ )
+ .bind(&address)
+ .fetch_one(&state.pool)
+ .await?;
+
+ let completion_rate = if total_jobs == 0 {
+ 0.0
+ } else {
+ completed_jobs as f64 / total_jobs as f64
+ };
+ let dispute_rate = if total_jobs == 0 {
+ 0.0
+ } else {
+ disputed_jobs as f64 / total_jobs as f64
+ };
+
+ let metrics = ProfileMetrics {
+ total_jobs,
+ completed_jobs,
+ active_jobs,
+ disputed_jobs,
+ verified_volume_usdc,
+ completion_rate,
+ dispute_rate,
+ };
+
+ let portfolio_links = profile
+ .as_ref()
+ .and_then(|row| row.portfolio_links.as_array().cloned())
+ .unwrap_or_default()
+ .into_iter()
+ .filter_map(|value| value.as_str().map(ToOwned::to_owned))
+ .collect();
+
+ let response = PublicProfile {
+ address: address.clone(),
+ display_name: profile.as_ref().and_then(|row| row.display_name.clone()),
+ headline: profile
+ .as_ref()
+ .map(|row| row.headline.clone())
+ .unwrap_or_default(),
+ bio: profile
+ .as_ref()
+ .map(|row| row.bio.clone())
+ .unwrap_or_default(),
+ portfolio_links,
+ updated_at: profile
+ .as_ref()
+ .map(|row| row.updated_at)
+ .unwrap_or_else(Utc::now),
+ metrics,
+ history,
+ };
+
+ Ok(Json(response))
+}
+
+async fn upsert_profile(
+ State(state): State,
+ Path(address): Path,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> Result> {
+ let actor = headers
+ .get("x-wallet-address")
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or_default();
+
+ if actor != address {
+ return Err(AppError::BadRequest(
+ "only the wallet owner can update this profile".into(),
+ ));
+ }
+
+ let portfolio_links: Vec = req
+ .portfolio_links
+ .into_iter()
+ .map(|link| link.trim().to_owned())
+ .filter(|link| !link.is_empty())
+ .take(6)
+ .collect();
+
+ sqlx::query(
+ r#"INSERT INTO profiles (address, display_name, headline, bio, portfolio_links)
+ VALUES ($1, $2, $3, $4, $5::jsonb)
+ ON CONFLICT (address)
+ DO UPDATE SET
+ display_name = EXCLUDED.display_name,
+ headline = EXCLUDED.headline,
+ bio = EXCLUDED.bio,
+ portfolio_links = EXCLUDED.portfolio_links"#,
+ )
+ .bind(&address)
+ .bind(req.display_name)
+ .bind(req.headline)
+ .bind(req.bio)
+ .bind(serde_json::to_string(&portfolio_links).map_err(anyhow::Error::from)?)
+ .execute(&state.pool)
+ .await?;
+
+ get_profile(State(state), Path(address)).await
}
diff --git a/backend/src/services/ipfs.rs b/backend/src/services/ipfs.rs
index ce15bc4f..51cdf553 100644
--- a/backend/src/services/ipfs.rs
+++ b/backend/src/services/ipfs.rs
@@ -52,7 +52,7 @@ pub async fn pin_to_ipfs(
// 2. MIME allowlist
let base_mime = mime_type.split(';').next().unwrap_or("").trim();
if !ALLOWED_MIME_TYPES.contains(&base_mime) {
- bail!("file type '{}' is not permitted", base_mime);
+ bail!("file type '{base_mime}' is not permitted");
}
let jwt = std::env::var("PINATA_JWT")
diff --git a/backend/src/services/stellar.rs b/backend/src/services/stellar.rs
index 9ceaed65..14a5a6b4 100644
--- a/backend/src/services/stellar.rs
+++ b/backend/src/services/stellar.rs
@@ -295,7 +295,7 @@ impl StellarService {
other => bail!("unexpected getTransaction status: {other}"),
}
}
- bail!("transaction {hash} not confirmed after {} polls", MAX_POLL_ATTEMPTS)
+ bail!("transaction {hash} not confirmed after {MAX_POLL_ATTEMPTS} polls")
}
async fn rpc_call(&self, method: &str, params: serde_json::Value) -> Result {
diff --git a/contracts/reputation/Cargo.toml b/contracts/reputation/Cargo.toml
index 6acdb8a9..cf14a32d 100644
--- a/contracts/reputation/Cargo.toml
+++ b/contracts/reputation/Cargo.toml
@@ -11,13 +11,3 @@ soroban-sdk = { version = "21.0.0", features = ["alloc"] }
[dev-dependencies]
soroban-sdk = { version = "21.0.0", features = ["testutils"] }
-
-[profile.release]
-opt-level = "z"
-overflow-checks = true
-debug = 0
-strip = "symbols"
-debug-assertions = false
-panic = "abort"
-codegen-units = 1
-lto = true
diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs
index cb6434c9..784797f8 100644
--- a/contracts/reputation/src/lib.rs
+++ b/contracts/reputation/src/lib.rs
@@ -1,6 +1,6 @@
#![no_std]
-use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Bytes, IntoVal};
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Bytes, Env, IntoVal, Symbol, Vec};
// Types matching Job Registry contract's public types for cross-contract decoding
#[contracttype]
@@ -168,6 +168,24 @@ impl ReputationContract {
reviews: 0,
})
}
+
+ /// Frontend-friendly aggregate metrics for public profile pages.
+ /// Returns: [score_bps, total_jobs, total_points, reviews]
+ pub fn get_public_metrics(env: Env, address: Address, role_name: Symbol) -> Vec {
+ let role = if role_name == Symbol::new(&env, "client") {
+ Role::Client
+ } else {
+ Role::Freelancer
+ };
+ let rep = Self::get_score(env.clone(), address, role);
+
+ let mut metrics = Vec::new(&env);
+ metrics.push_back(rep.score as i128);
+ metrics.push_back(rep.total_jobs as i128);
+ metrics.push_back(rep.total_points as i128);
+ metrics.push_back(rep.reviews as i128);
+ metrics
+ }
}
impl ReputationContract {
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 00000000..a23f8221
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,4 @@
+[toolchain]
+channel = "1.88.0"
+components = ["clippy", "rustfmt"]
+targets = ["wasm32-unknown-unknown"]
diff --git a/tests/e2e/gig-lifecycle.spec.ts b/tests/e2e/gig-lifecycle.spec.ts
index 27eafb94..88cb8c98 100644
--- a/tests/e2e/gig-lifecycle.spec.ts
+++ b/tests/e2e/gig-lifecycle.spec.ts
@@ -1,10 +1,12 @@
import { test, expect } from "@playwright/test";
-test("full gig lifecycle: post, bid, accept, fund, deliver, release", async ({ page }) => {
- // Mock Backend API
+test("full gig lifecycle: post, bid, accept, fund, deliver, release", async ({
+ page,
+}) => {
const mockJobId = "550e8400-e29b-41d4-a716-446655440000";
const mockBidId = "b1d00000-0000-0000-0000-000000000000";
+ // --- Job routes ---
await page.route("**/api/v1/jobs", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
@@ -23,6 +25,8 @@ test("full gig lifecycle: post, bid, accept, fund, deliver, release", async ({ p
}
});
+ // Mutable job state so status updates are reflected across re-fetches
+ let jobStatus = "open";
await page.route(`**/api/v1/jobs/${mockJobId}`, async (route) => {
await route.fulfill({
status: 200,
@@ -30,15 +34,20 @@ test("full gig lifecycle: post, bid, accept, fund, deliver, release", async ({ p
body: JSON.stringify({
id: mockJobId,
title: "Build a Soroban Smart Contract",
- description: "Implement a simple escrow contract for a freelance platform.",
- status: "open",
+ description:
+ "Implement a simple escrow contract for a freelance platform.",
+ status: jobStatus,
budget_usdc: 50000000000,
milestones: 2,
client_address: "GD...CLIENT",
+ freelancer_address:
+ jobStatus !== "open" ? "GD...FREELANCER" : undefined,
+ on_chain_job_id: jobStatus === "funded" ? 1 : undefined,
}),
});
});
+ // --- Bid routes ---
await page.route(`**/api/v1/jobs/${mockJobId}/bids`, async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
@@ -61,7 +70,8 @@ test("full gig lifecycle: post, bid, accept, fund, deliver, release", async ({ p
id: mockBidId,
job_id: mockJobId,
freelancer_address: "GD...FREELANCER",
- proposal: "I have extensive experience with Soroban and Rust. I can finish this in 3 days.",
+ proposal:
+ "I have extensive experience with Soroban and Rust. I can finish this in 3 days.",
status: "pending",
},
]),
@@ -69,6 +79,30 @@ test("full gig lifecycle: post, bid, accept, fund, deliver, release", async ({ p
}
});
+ // Accept bid β was missing, causing real backend hit and redirect to never fire
+ await page.route(
+ `**/api/v1/jobs/${mockJobId}/bids/${mockBidId}/accept`,
+ async (route) => {
+ jobStatus = "awaiting_funding";
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ id: mockJobId,
+ title: "Build a Soroban Smart Contract",
+ description:
+ "Implement a simple escrow contract for a freelance platform.",
+ status: "awaiting_funding",
+ budget_usdc: 50000000000,
+ milestones: 2,
+ client_address: "GD...CLIENT",
+ freelancer_address: "GD...FREELANCER",
+ }),
+ });
+ },
+ );
+
+ // --- Stellar RPC ---
await page.route("https://soroban-testnet.stellar.org", async (route) => {
const postData = route.request().postDataJSON();
const method = postData.method;
@@ -81,7 +115,7 @@ test("full gig lifecycle: post, bid, accept, fund, deliver, release", async ({ p
results: [{ auth: [], xdr: "AAAAAgAAAAE=" }],
latestLedger: 1234,
minResourceFee: "100",
- transactionData: "AAAAAAAAAAA="
+ transactionData: "AAAAAAAAAAA=",
};
} else if (method === "sendTransaction") {
result = { status: "PENDING", hash: "FAKE_TX_HASH", latestLedger: 1234 };
@@ -99,59 +133,57 @@ test("full gig lifecycle: post, bid, accept, fund, deliver, release", async ({ p
// 1. Client posts a job
await page.goto("/jobs/new");
await page.fill("#job-title", "Build a Soroban Smart Contract");
- await page.fill("#job-description", "Implement a simple escrow contract for a freelance platform.");
+ await page.fill(
+ "#job-description",
+ "Implement a simple escrow contract for a freelance platform.",
+ );
await page.fill("#job-budget", "5000");
await page.fill("#job-milestones", "2");
await page.click("#submit-job");
- // Should redirect to job details page
await expect(page).toHaveURL(`/jobs/${mockJobId}`);
- await expect(page.getByRole("heading", { name: "Build a Soroban Smart Contract" })).toBeVisible();
+ await expect(
+ page
+ .getByRole("heading", { name: "Build a Soroban Smart Contract" })
+ .first(),
+ ).toBeVisible();
await expect(page.getByText(/OPEN/i)).toBeVisible();
// 2. Freelancer submits a bid
- await page.fill("#bid-proposal", "I have extensive experience with Soroban and Rust. I can finish this in 3 days.");
+ await page.fill(
+ "#bid-proposal",
+ "I have extensive experience with Soroban and Rust. I can finish this in 3 days.",
+ );
await page.click("#submit-bid");
- // Bid should appear in the list
await expect(page.getByText("Bids (1)")).toBeVisible();
- await expect(page.getByRole("paragraph").filter({ hasText: "I have extensive experience with Soroban and Rust" })).toBeVisible();
+ await expect(
+ page
+ .getByRole("paragraph")
+ .filter({ hasText: "I have extensive experience with Soroban and Rust" }),
+ ).toBeVisible();
// 3. Client accepts the bid
await page.click("button:has-text('Accept Bid')");
- // Should redirect to funding page
await expect(page).toHaveURL(`/jobs/${mockJobId}/fund`);
- await expect(page.getByRole("heading", { name: "Fund Escrow" })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Fund Escrow" }),
+ ).toBeVisible();
// 4. Client deposits escrow
await page.check("input[type='checkbox']");
-
- // Update mock for status change to funded
- await page.route(`**/api/v1/jobs/${mockJobId}`, async (route) => {
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify({
- id: mockJobId,
- title: "Build a Soroban Smart Contract",
- status: "funded",
- budget_usdc: 50000000000,
- milestones: 2,
- client_address: "GD...CLIENT",
- freelancer_address: "GD...FREELANCER",
- on_chain_job_id: 1,
- }),
- });
- });
+
+ jobStatus = "funded";
await page.click("button:has-text('Deposit $5,100.00 into Escrow')");
await page.click("button:has-text('Confirm & Sign')");
- // Wait for "Escrow Funded!" success state
- await expect(page.getByRole("heading", { name: "Escrow Funded!" })).toBeVisible({ timeout: 10000 });
-
- // 5. Verify transition back to Job details with FUNDED status
+ await expect(
+ page.getByRole("heading", { name: "Escrow Funded!", level: 2 }),
+ ).toBeVisible({ timeout: 10000 });
+
+ // 5. Back to job details with FUNDED status
await page.click("button:has-text('Go to Job')");
await expect(page).toHaveURL(`/jobs/${mockJobId}`);
await expect(page.getByText(/FUNDED/i)).toBeVisible();
diff --git a/tests/e2e/platform.spec.ts b/tests/e2e/platform.spec.ts
index 8f48c133..94f53093 100644
--- a/tests/e2e/platform.spec.ts
+++ b/tests/e2e/platform.spec.ts
@@ -4,7 +4,9 @@ import { test, expect } from "@playwright/test";
test("job board loads", async ({ page }) => {
await page.goto("/jobs");
- await expect(page.getByRole("heading", { name: /jobs/i })).toBeVisible();
+ // The jobs page SiteShell uses eyebrow="Marketplace" and a long title β
+ // match the stable eyebrow label instead of a heading that doesn't exist.
+ await expect(page.getByText(/Marketplace/i)).toBeVisible();
});
test("post a job navigates to job board", async ({ page }) => {