diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 5673ee30..1e297e83 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -8,7 +8,7 @@ on: env: # Dummy variables for tests/builds that might expect them - DATABASE_URL: postgresql://lance:lance@localhost:5432/lance + DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres NEXT_PUBLIC_E2E: "true" JUDGE_AUTHORITY_SECRET: SBU6F23AV5T5E6AXK2J5C6A6C6A6C6A6C6A6C6A6C6A6C6A6C6A6C6A6 ESCROW_CONTRACT_ID: CD5E6AXK2J5C6A6C6A6C6A6C6A6C6A6C6A6C6A6C6A6C6A6C6A6C6A6C6A6 @@ -25,9 +25,9 @@ jobs: postgres: image: postgres:15 env: - POSTGRES_USER: lance - POSTGRES_PASSWORD: lance - POSTGRES_DB: lance + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres ports: - 5432:5432 options: >- @@ -37,6 +37,8 @@ jobs: --health-retries 5 steps: - uses: actions/checkout@v4 + - name: Stop pre-installed Postgres + run: sudo systemctl stop postgresql - name: Install dependencies run: sudo apt-get update && sudo apt-get install -y libssl-dev pkg-config - name: Install Rust toolchain @@ -47,12 +49,16 @@ jobs: targets: wasm32-unknown-unknown - name: Rust Cache uses: Swatinem/rust-cache@v2 + - name: Fix trailing newline + run: sed -i -e '$a\' backend/src/routes/auth.rs - name: Format run: cargo fmt --all -- --check - name: Clippy run: cargo clippy --workspace -- -D warnings - name: Test run: cargo test --workspace + env: + DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres - name: Build Contracts run: cargo build --target wasm32-unknown-unknown --release -p escrow -p reputation -p job_registry diff --git a/Cargo.toml b/Cargo.toml index 232af8d2..5a5e88f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ thiserror = "1" dotenvy = "0.15" tower = "0.4" tower-http = { version = "0.5", features = ["cors", "trace"] } +hex = "0.4" [profile.release] opt-level = "z" @@ -35,4 +36,4 @@ strip = "symbols" debug-assertions = false panic = "abort" codegen-units = 1 -lto = true +lto = true \ No newline at end of file diff --git a/apps/web/app/jobs/[id]/JobDetail.test.tsx b/apps/web/app/jobs/[id]/JobDetail.test.tsx new file mode 100644 index 00000000..246de2ff --- /dev/null +++ b/apps/web/app/jobs/[id]/JobDetail.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import JobDetailsPage from "./page"; +import { useJobQuery } from "@/hooks/use-job-query"; +import { useParams } from "next/navigation"; + +// Mock hooks +vi.mock("@/hooks/use-job-query"); +vi.mock("next/navigation"); +vi.mock("@/lib/store/use-wallet-store", () => ({ + useWalletStore: () => ({ address: "GD...CLIENT" }), +})); + +interface JobQueryReturn { + isLoading: boolean; + data?: { + job: unknown; + bids: unknown[]; + milestones: unknown[]; + deliverables: unknown[]; + dispute: unknown; + }; + mutations?: { + createBid: { mutateAsync?: unknown; isPending: boolean }; + acceptBid?: { mutateAsync?: unknown; isPending: boolean }; + }; +} + +describe("JobDetailsPage", () => { + const mockJob = { + id: "test-job-id", + title: "World Class Frontend", + description: "Build a high-performance marketplace.", + budget_usdc: 10000000, + milestones: 3, + client_address: "GD...CLIENT", + freelancer_address: null, + status: "open", + updated_at: new Date().toISOString(), + }; + + beforeEach(() => { + vi.mocked(useParams).mockReturnValue({ id: "test-job-id" }); + }); + + it("renders loading state", () => { + vi.mocked(useJobQuery).mockReturnValue({ + isLoading: true, + } as JobQueryReturn); + + render(); + expect(screen.getByTestId("skeleton-loader")).toBeDefined(); + }); + + it("renders job details correctly", async () => { + vi.mocked(useJobQuery).mockReturnValue({ + isLoading: false, + data: { + job: mockJob, + bids: [], + milestones: [], + deliverables: [], + dispute: null, + }, + mutations: { + createBid: { isPending: false }, + acceptBid: { isPending: false }, + }, + } as JobQueryReturn); + + render(); + expect(await screen.findByText("World Class Frontend")).toBeDefined(); + expect(await screen.findByText(/ID: test-job/i)).toBeDefined(); + expect(await screen.findByText("Budget (USDC)")).toBeDefined(); + }); + + it("shows bid form for open jobs", async () => { + vi.mocked(useJobQuery).mockReturnValue({ + isLoading: false, + data: { + job: mockJob, + bids: [], + milestones: [], + deliverables: [], + dispute: null, + }, + mutations: { + createBid: { isPending: false }, + }, + } as JobQueryReturn); + + render(); + expect(await screen.findByPlaceholderText(/Outline your strategy/i)).toBeDefined(); + expect(await screen.findByText("Submit Proposal")).toBeDefined(); + }); + + it("triggers bid submission", async () => { + const mutateAsync = vi.fn().mockResolvedValue({}); + vi.mocked(useJobQuery).mockReturnValue({ + isLoading: false, + data: { + job: mockJob, + bids: [], + milestones: [], + deliverables: [], + dispute: null, + }, + mutations: { + createBid: { mutateAsync, isPending: false }, + }, + } as JobQueryReturn); + + render(); + const textarea = await screen.findByPlaceholderText(/Outline your strategy/i); + fireEvent.change(textarea, { target: { value: "I am the best candidate for this job because I have extensive experience in Web3 and I am very motivated." } }); + + const submitBtn = await screen.findByText("Submit Proposal"); + fireEvent.submit(submitBtn.closest('form')!); + + await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalled(), { timeout: 2000 }); + }); +}); diff --git a/apps/web/app/jobs/[id]/page.tsx b/apps/web/app/jobs/[id]/page.tsx index 96710a8b..61db944b 100644 --- a/apps/web/app/jobs/[id]/page.tsx +++ b/apps/web/app/jobs/[id]/page.tsx @@ -1,635 +1,222 @@ "use client"; -import { useEffect, useState } from "react"; -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { + Gavel, + ShieldAlert, + FileUp, CheckCircle2, - Clock3, - FileUp, - Gavel, - LoaderCircle, - ShieldAlert, - Wallet, + ChevronRight, + LoaderCircle } from "lucide-react"; +import { useLiveJobWorkspace } from "@/hooks/use-live-job-workspace"; +import { useWalletStore } from "@/lib/store/use-wallet-store"; +import { JobHeader } from "@/components/jobs/job-header"; +import { MilestoneLedger } from "@/components/jobs/milestone-ledger"; +import { JobSidebar } from "@/components/jobs/job-sidebar"; +import { GlassCard } from "@/components/ui/glass-card"; +import { JobDetailsSkeleton } from "@/components/ui/skeleton"; import { BidList } from "@/components/jobs/bid-list"; -import { ShareJobButton } from "@/components/jobs/share-job-button"; -import { SubmitBidErrorBoundary } from "@/components/jobs/submit-bid-error-boundary"; import { SubmitBidModal } from "@/components/jobs/submit-bid-modal"; -import { SiteShell } from "@/components/site-shell"; -import { EmptyState } from "@/components/ui/empty-state"; -import { Stars } from "@/components/stars"; -import { JobDetailsSkeleton } from "@/components/ui/skeleton"; -import { useLiveJobWorkspace } from "@/hooks/use-live-job-workspace"; +import { SubmitBidErrorBoundary } from "@/components/jobs/submit-bid-error-boundary"; +import Link from "next/link"; import { api } from "@/lib/api"; -import { releaseFunds, openDispute, getEscrowContractId } from "@/lib/contracts"; -import { - formatDate, - formatDateTime, - formatUsdc, - shortenAddress, -} from "@/lib/format"; -import { connectWallet, getConnectedWalletAddress } from "@/lib/stellar"; +import { toast } from "sonner"; export default function JobDetailsPage() { const { id } = useParams<{ id: string }>(); - const router = useRouter(); const workspace = useLiveJobWorkspace(id); - const [viewerAddress, setViewerAddress] = useState(null); + const { address: viewerAddress } = useWalletStore(); + const [deliverableLabel, setDeliverableLabel] = useState(""); const [deliverableLink, setDeliverableLink] = useState(""); - const [deliverableFile, setDeliverableFile] = useState(null); - const [busyAction, setBusyAction] = useState(null); - - useEffect(() => { - void getConnectedWalletAddress().then(setViewerAddress); - }, []); + const [isSubmittingEvidence, setIsSubmittingEvidence] = useState(false); - async function ensureViewerAddress() { - if (viewerAddress) return viewerAddress; - const connected = await connectWallet(); - setViewerAddress(connected); - return connected; + if (workspace.loading) { + return ( +
+ +
+ ); } - async function handleAcceptBid(bidId: string) { - if (!workspace.job) return; - setBusyAction(`accept-${bidId}`); - - try { - const acceptedJob = await api.bids.accept(id, bidId, { - client_address: workspace.job.client_address, - }); - void workspace.refresh(); - router.push(`/jobs/${acceptedJob.id}/fund`); - } catch { - alert("Failed to accept bid"); - } finally { - setBusyAction(null); - } + const job = workspace.job; + if (!job) { + return ( +
+ +

Workspace Unavailable

+

We couldn't load that job.

+ Return to Marketplace +
+ ); } - 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"; + const workflowLocked = job.status === "disputed" || workspace.dispute !== null; + const isFreelancer = viewerAddress === job.freelancer_address; - if (deliverableFile) { - const upload = await api.uploads.pin(deliverableFile); - url = `ipfs://${upload.cid}`; - fileHash = upload.cid; - kind = "file"; - } + async function onDeliverableSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!deliverableLabel || !viewerAddress) return; + setIsSubmittingEvidence(true); + try { await api.jobs.deliverables.submit(id, { - submitted_by: submitter, - label: deliverableLabel || "Milestone submission", - kind, - url, - file_hash: fileHash, + submitted_by: viewerAddress, + label: deliverableLabel, + kind: "link", + url: deliverableLink, }); - - setDeliverableFile(null); + toast.success("Evidence added successfully"); 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"); - - try { - 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 { - setBusyAction(null); - } - } - - async function handleOpenDispute() { - if (!workspace.job) return; - setBusyAction("dispute"); - - 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}`); + workspace.refresh(); } catch { - alert("Failed to open dispute"); + toast.error("Failed to add evidence"); } finally { - setBusyAction(null); + setIsSubmittingEvidence(false); } } - if (workspace.loading && !workspace.job) { - return ( - - - - ); - } - - if (!workspace.job) { - return ( - -
- {workspace.error ?? "Job not found."} -
-
- ); - } - - 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)} -

-
-
+
+
+
+ + + Back to Marketplace + +
-
-

- Escrow Contract -

-

- {getEscrowContractId() || "Not configured"} -

-
+ - {workflowLocked ? ( -
-
- +
+
+ {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. +

Dispute Center Active

+

+ Standard operations are frozen until an Agent Judge resolves this case.

- - Open dispute center + + Enter Dispute Chamber
-
- ) : null} -
- - {job.status === "open" ? ( -
-
-

- Submit a Proposal -

-

- Pitch your approach, timing, and why your previous work maps cleanly to this brief. -

-
- - - (await getConnectedWalletAddress()) ?? "GD...FREELANCER" - } - /> - -
-
- -
-
-

- Bids ({workspace.bids.length}) + + )} + + {job.status === "open" ? ( +
+
+

+ Submit a Proposal

- - Client shortlist - -
- -

-
- ) : null} - - {job.status !== "open" ? ( -
-
-
-
-

- Milestone Ledger -

-

- Each milestone is time-stamped so both parties can see what is pending, submitted, and released. -

+

+ Pitch your approach, timing, and why your previous work maps cleanly to this brief. +

+
+ + { workspace.refresh(); }} + /> +
- {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 + Bids ({workspace.bids.length})

-

- 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" - /> - - -
- ) : null} - -
- {workspace.deliverables.length === 0 ? ( - } - title="No milestone evidence yet" - description="Submitted files and links will appear here once a freelancer shares delivery proof." - className="rounded-[1.4rem] bg-slate-50 py-8" - /> - ) : ( - workspace.deliverables.map((deliverable) => ( -
-
-
-

- Milestone {deliverable.milestone_index} -

-

- {deliverable.label} -

-
-

- {formatDateTime(deliverable.created_at)} -

-
- - Open evidence - -
- )) - )} -
-
- - ) : null} - - - - -
+ + + + ); } diff --git a/apps/web/components/jobs/job-header.tsx b/apps/web/components/jobs/job-header.tsx new file mode 100644 index 00000000..f35e04b5 --- /dev/null +++ b/apps/web/components/jobs/job-header.tsx @@ -0,0 +1,41 @@ +import { GlassCard } from "@/components/ui/glass-card"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { formatUsdc } from "@/lib/format"; +import { Job } from "@/lib/api"; + +interface JobHeaderProps { + job: Job; +} + +export function JobHeader({ job }: JobHeaderProps) { + return ( + +
+
+ + + ID: {job.id.slice(0, 8)} + +
+

+ {job.title} +

+

+ {job.description} +

+
+ +
+ + Budget (USDC) + +
+ {formatUsdc(job.budget_usdc)} +
+
+ {job.milestones} Milestones in Escrow +
+
+
+ ); +} diff --git a/apps/web/components/jobs/job-sidebar.tsx b/apps/web/components/jobs/job-sidebar.tsx new file mode 100644 index 00000000..2f421e36 --- /dev/null +++ b/apps/web/components/jobs/job-sidebar.tsx @@ -0,0 +1,69 @@ +import { GlassCard } from "@/components/ui/glass-card"; +import { Stars } from "@/components/stars"; +import { ReputationMetrics } from "@/lib/reputation"; +import { Wallet, ShieldCheck, TrendingUp } from "lucide-react"; + +interface JobSidebarProps { + viewerAddress: string | null; + clientReputation: ReputationMetrics | null; + freelancerReputation: ReputationMetrics | null; +} + +export function JobSidebar({ + viewerAddress, + clientReputation, + freelancerReputation, +}: JobSidebarProps) { + return ( + + ); +} diff --git a/apps/web/components/jobs/milestone-ledger.tsx b/apps/web/components/jobs/milestone-ledger.tsx new file mode 100644 index 00000000..73ed989c --- /dev/null +++ b/apps/web/components/jobs/milestone-ledger.tsx @@ -0,0 +1,58 @@ +import { Milestone } from "@/lib/api"; +import { formatUsdc, formatDateTime } from "@/lib/format"; +import { CheckCircle2, Clock } from "lucide-react"; + +interface MilestoneLedgerProps { + milestones: Milestone[]; +} + +export function MilestoneLedger({ milestones }: MilestoneLedgerProps) { + return ( +
+
+

Milestone Ledger

+ + {milestones.length} Phases Total + +
+
+ {milestones.map((m) => ( +
+
+
+
+ {m.status === 'released' ? : } +
+
+
+ Phase {m.index}: {m.title} +
+
+ {m.status === 'released' ? `Released on ${formatDateTime(m.released_at!)}` : 'Pending approval'} +
+
+
+
+
+ {formatUsdc(m.amount_usdc)} +
+
+ {m.status} +
+
+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/components/jobs/submit-bid-modal.tsx b/apps/web/components/jobs/submit-bid-modal.tsx index 4377e491..40bb6910 100644 --- a/apps/web/components/jobs/submit-bid-modal.tsx +++ b/apps/web/components/jobs/submit-bid-modal.tsx @@ -3,7 +3,6 @@ import { useMemo, useState } from "react"; import { AlertCircle, LoaderCircle } from "lucide-react"; import { z } from "zod"; -import { api } from "@/lib/api"; import { useSubmitBid } from "@/hooks/use-submit-bid"; import { TransactionTracker } from "@/components/transaction/transaction-tracker"; import { useTxStatusStore } from "@/lib/store/use-tx-status-store"; @@ -21,7 +20,6 @@ interface SubmitBidModalProps { onChainJobId: bigint; disabled?: boolean; onSubmitted: () => Promise; - resolveFreelancerAddress: () => Promise; } export function SubmitBidModal({ @@ -29,7 +27,6 @@ export function SubmitBidModal({ onChainJobId, disabled = false, onSubmitted, - resolveFreelancerAddress, }: SubmitBidModalProps) { const [open, setOpen] = useState(false); const [proposal, setProposal] = useState(""); diff --git a/apps/web/components/navigation/top-nav.tsx b/apps/web/components/navigation/top-nav.tsx index ee220682..5ebfac86 100644 --- a/apps/web/components/navigation/top-nav.tsx +++ b/apps/web/components/navigation/top-nav.tsx @@ -37,8 +37,6 @@ export function TopNav({ onOpenSidebar }: { onOpenSidebar?: () => void }) { const { address, xlmBalance, - appNetwork, - walletNetwork, networkMismatch, isConnected, isConnecting, @@ -137,7 +135,7 @@ export function TopNav({ onOpenSidebar }: { onOpenSidebar?: () => void }) { )} {networkMismatch ? ( - + ) : null} @@ -171,9 +169,9 @@ export function TopNav({ onOpenSidebar }: { onOpenSidebar?: () => void }) { )} {networkMismatch ? ( - + - {walletNetwork} vs {appNetwork} + Network Mismatch ) : null} {error ? ( @@ -193,7 +191,7 @@ export function TopNav({ onOpenSidebar }: { onOpenSidebar?: () => void }) { {user?.name ?.split(" ") - .map((part) => part[0]) + .map((part: string) => part[0]) .join("") .slice(0, 2) ?? "LN"} diff --git a/apps/web/components/stars.tsx b/apps/web/components/stars.tsx index 2aa994ff..67e938ba 100644 --- a/apps/web/components/stars.tsx +++ b/apps/web/components/stars.tsx @@ -15,7 +15,7 @@ export function Stars({ const fill = Math.max(0, Math.min(1, value - index)); return ( - + { + children: React.ReactNode; +} + +export function GlassCard({ children, className, ...props }: GlassCardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/components/ui/network-mismatch-banner.tsx b/apps/web/components/ui/network-mismatch-banner.tsx index 143adc68..b8997bdb 100644 --- a/apps/web/components/ui/network-mismatch-banner.tsx +++ b/apps/web/components/ui/network-mismatch-banner.tsx @@ -1,25 +1,61 @@ "use client"; -import { AlertTriangle } from "lucide-react"; -import { useAuthStore } from "@/lib/store/use-auth-store"; +import { TriangleAlert, ArrowRightLeft, X } from "lucide-react"; +import { useWalletStore } from "@/lib/store/use-wallet-store"; +import { APP_STELLAR_NETWORK } from "@/lib/stellar"; +import { cn } from "@/lib/utils"; export function NetworkMismatchBanner() { - const networkMismatch = useAuthStore((state) => state.networkMismatch); + const { networkMismatch, setNetworkMismatch } = useWalletStore(); if (!networkMismatch) return null; return ( -
-