diff --git a/apps/web/app/jobs/[id]/__tests__/page.test.tsx b/apps/web/app/jobs/[id]/__tests__/page.test.tsx new file mode 100644 index 00000000..c2028729 --- /dev/null +++ b/apps/web/app/jobs/[id]/__tests__/page.test.tsx @@ -0,0 +1,301 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { BrowserRouter } from "next/navigation"; +import JobDetailsPage from "../app/jobs/[id]/page"; + +vi.mock("@/lib/api", () => ({ + api: { + jobs: { + get: vi.fn(), + milestones: vi.fn(), + deliverables: { list: vi.fn() }, + dispute: { get: vi.fn(), open: vi.fn() }, + releaseMilestone: vi.fn(), + }, + bids: { list: vi.fn(), accept: vi.fn() }, + uploads: { pin: vi.fn() }, + }, +})); + +vi.mock("@/lib/stellar", () => ({ + connectWallet: vi.fn().mockResolvedValue("GABC123TESTADDRESS"), + getConnectedWalletAddress: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/lib/contracts", () => ({ + releaseFunds: vi.fn().mockResolvedValue("tx123"), + openDispute: vi.fn().mockResolvedValue("dispute123"), + getEscrowContractId: vi.fn().mockReturnValue("C1234567890"), +})); + +const mockJob = { + id: "job-123", + title: "Build a Soroban escrow system", + description: "We need a developer to build an escrow system on Soroban.", + budget_usdc: 500000000, + milestones: 3, + client_address: "GCXDEV5E2J4JTS3Q3C5JZV4P5C7E", + freelancer_address: undefined, + status: "open", + metadata_hash: undefined, + on_chain_job_id: 1, + created_at: "2026-04-01T00:00:00Z", + updated_at: "2026-04-01T00:00:00Z", +}; + +const mockMilestones = [ + { + id: "milestone-1", + job_id: "job-123", + index: 1, + title: "Milestone 1", + amount_usdc: 166666666, + status: "pending", + }, +]; + +const mockBids = [ + { + id: "bid-1", + job_id: "job-123", + freelancer_address: "GABC123", + proposal: "I can deliver this in two milestones.", + status: "pending", + created_at: "2026-04-01T00:00:00Z", + }, +]; + +const mockDeliverables = []; + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return ( + + {children} + + ); +} + +describe("JobDetailsPage", () => { + const { api } = require("@/lib/api"); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(api.jobs.get).mockResolvedValue(mockJob); + vi.mocked(api.jobs.milestones).mockResolvedValue(mockMilestones); + vi.mocked(api.bids.list).mockResolvedValue(mockBids); + vi.mocked(api.jobs.deliverables.list).mockResolvedValue(mockDeliverables); + vi.mocked(api.jobs.dispute.get).mockResolvedValue(null); + }); + + it("renders job title and description", async () => { + render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/Build a Soroban escrow system/i); + + expect(screen.getByText(/Build a Soroban escrow system/i)).toBeInTheDocument(); + expect( + screen.getByText(/We need a developer to build an escrow system/i), + ).toBeInTheDocument(); + }); + + it("renders job status badge", async () => { + render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/open/i); + + expect(screen.getByText(/open/i)).toBeInTheDocument(); + }); + + it("renders formatted budget", async () => { + render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/\$500\.00/i); + + expect(screen.getByText(/\$500\.00/)).toBeInTheDocument(); + }); + + it("renders client address", async () => { + render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/GCXDEV/i); + + expect(screen.getByText(/GCXDEV/)).toBeInTheDocument(); + }); + + it("renders milestones section when job is not open", async () => { + vi.mocked(api.jobs.get).mockResolvedValue({ + ...mockJob, + status: "in_progress", + }); + + render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/Milestone Ledger/i); + + expect(screen.getByText(/Milestone Ledger/i)).toBeInTheDocument(); + }); + + it("renders deliverables section when job is not open", async () => { + vi.mocked(api.jobs.get).mockResolvedValue({ + ...mockJob, + status: "in_progress", + }); + + render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/Deliverables/i); + + expect(screen.getByText(/Deliverables/i)).toBeInTheDocument(); + }); + + it("shows loading skeleton when job is loading", () => { + vi.mocked(api.jobs.get).mockImplementation( + () => new Promise(() => {}), + ); + + render( +
+ +
, + { wrapper }, + ); + + expect(screen.getByText(/Loading workspace/i)).toBeInTheDocument(); + }); + + it("shows error state when job is not found", async () => { + vi.mocked(api.jobs.get).mockRejectedValue(new Error("Job not found")); + + render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/Workspace unavailable/i); + + expect(screen.getByText(/Workspace unavailable/i)).toBeInTheDocument(); + }); + + it("renders Connected Viewer section", async () => { + render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/Connected Viewer/i); + + expect(screen.getByText(/Connected Viewer/i)).toBeInTheDocument(); + }); + + it("renders Activity pulse section", async () => { + render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/Activity pulse/i); + + expect(screen.getByText(/Activity pulse/i)).toBeInTheDocument(); + }); +}); + +describe("JobDetailsPage - Dark theme styling", () => { + it("uses zinc-950 background for main container", async () => { + const { container } = render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/Build a Soroban escrow system/i); + + const mainElement = container.querySelector(".min-h-screen"); + expect(mainElement?.className).toContain("bg-zinc-950"); + }); + + it("uses amber-500 for status indicators", async () => { + render( +
+ +
, + { wrapper }, + ); + + await screen.findByText(/Job Overview/i); + + expect(screen.getByText(/Job Overview/i)).toBeInTheDocument(); + }); +}); + +describe("JobDetailsPage - Zod validation", () => { + const { z } = require("zod"); + + const deliverableSchema = z.object({ + label: z.string().min(1, "Label is required"), + url: z.string().url("Must be a valid URL").optional().or(z.literal("")), + kind: z.enum(["link", "file"]), + file_hash: z.string().optional(), + }); + + it("validates deliverable form - rejects empty label", () => { + const parsed = deliverableSchema.safeParse({ + label: "", + url: "", + kind: "link", + }); + expect(parsed.success).toBe(false); + }); + + it("validates deliverable form - accepts valid input", () => { + const parsed = deliverableSchema.safeParse({ + label: "Initial delivery", + url: "https://github.com/user/repo", + kind: "link", + }); + expect(parsed.success).toBe(true); + }); +}); \ No newline at end of file diff --git a/apps/web/app/jobs/[id]/page.tsx b/apps/web/app/jobs/[id]/page.tsx index 07761762..cb284258 100644 --- a/apps/web/app/jobs/[id]/page.tsx +++ b/apps/web/app/jobs/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { @@ -13,12 +13,10 @@ import { Wallet, } from "lucide-react"; import { BidList } from "@/components/jobs/bid-list"; -import { SubmitBidErrorBoundary } from "@/components/jobs/submit-bid-error-boundary"; -import { SubmitBidModal } from "@/components/jobs/submit-bid-modal"; -import { SiteShell } from "@/components/site-shell"; +import { JobCardErrorBoundary } from "@/components/jobs/job-card-error-boundary"; import { Stars } from "@/components/stars"; import { JobDetailsSkeleton } from "@/components/ui/skeleton"; -import { useLiveJobWorkspace } from "@/hooks/use-live-job-workspace"; +import { useJob, useJobMilestones, useJobBids, useJobDeliverables, useCreateBid, useSubmitDeliverable, useReleaseMilestone } from "@/hooks/use-job"; import { api } from "@/lib/api"; import { releaseFunds, openDispute, getEscrowContractId } from "@/lib/contracts"; import { @@ -28,20 +26,38 @@ import { shortenAddress, } from "@/lib/format"; import { connectWallet, getConnectedWalletAddress } from "@/lib/stellar"; +import { z } from "zod"; + +const deliverableSchema = z.object({ + label: z.string().min(1, "Label is required"), + url: z.string().url("Must be a valid URL").optional().or(z.literal("")), + kind: z.enum(["link", "file"]), + file_hash: z.string().optional(), +}); + +type DeliverableFormData = z.infer; export default function JobDetailsPage() { const { id } = useParams<{ id: string }>(); const router = useRouter(); - const workspace = useLiveJobWorkspace(id); const [viewerAddress, setViewerAddress] = useState(null); const [deliverableLabel, setDeliverableLabel] = useState(""); const [deliverableLink, setDeliverableLink] = useState(""); const [deliverableFile, setDeliverableFile] = useState(null); + const [formErrors, setFormErrors] = useState>({}); const [busyAction, setBusyAction] = useState(null); - useEffect(() => { - void getConnectedWalletAddress().then(setViewerAddress); - }, []); + const { data: job, isLoading: jobLoading, error: jobError, refetch: refreshJob } = useJob(id); + const { data: milestones = [], isLoading: milestonesLoading } = useJobMilestones(id); + const { data: bids = [], isLoading: bidsLoading } = useJobBids(id); + const { data: deliverables = [], isLoading: deliverablesLoading } = useJobDeliverables(id); + + const createBidMutation = useCreateBid(); + const submitDeliverableMutation = useSubmitDeliverable(); + const releaseMilestoneMutation = useReleaseMilestone(); + + const loading = jobLoading || milestonesLoading; + const error = jobError instanceof Error ? jobError.message : null; async function ensureViewerAddress() { if (viewerAddress) return viewerAddress; @@ -51,14 +67,14 @@ export default function JobDetailsPage() { } async function handleAcceptBid(bidId: string) { - if (!workspace.job) return; + if (!job) return; setBusyAction(`accept-${bidId}`); try { const acceptedJob = await api.bids.accept(id, bidId, { - client_address: workspace.job.client_address, + client_address: job.client_address, }); - void workspace.refresh(); + void refreshJob(); router.push(`/jobs/${acceptedJob.id}/fund`); } catch { alert("Failed to accept bid"); @@ -69,12 +85,31 @@ export default function JobDetailsPage() { async function handleSubmitDeliverable(event: React.FormEvent) { event.preventDefault(); - if (!workspace.job) return; + if (!job) return; + + const data: DeliverableFormData = { + label: deliverableLabel || "Milestone submission", + url: deliverableLink, + kind: deliverableLink ? "link" : "file", + }; + + const parsed = deliverableSchema.safeParse(data); + if (!parsed.success) { + const errors: Record = {}; + for (const issue of parsed.error.issues) { + const path = issue.path.join("."); + errors[path] = issue.message; + } + setFormErrors(errors); + return; + } + + setFormErrors({}); setBusyAction("deliverable"); try { const submitter = - workspace.job.freelancer_address ?? + job.freelancer_address ?? (await ensureViewerAddress()) ?? "GD...FREELANCER"; @@ -100,7 +135,6 @@ export default function JobDetailsPage() { setDeliverableFile(null); setDeliverableLabel(""); setDeliverableLink(""); - await workspace.refresh(); } catch { alert("Failed to submit deliverable"); } finally { @@ -109,8 +143,8 @@ export default function JobDetailsPage() { } async function handleReleaseFunds() { - if (!workspace.job) return; - const nextMilestone = workspace.milestones.find( + if (!job) return; + const nextMilestone = milestones.find( (milestone) => milestone.status === "pending", ); if (!nextMilestone) return; @@ -119,11 +153,11 @@ export default function JobDetailsPage() { try { await releaseFunds( - BigInt(workspace.job.on_chain_job_id ?? 0), + BigInt(job.on_chain_job_id ?? 0), Math.max(0, nextMilestone.index - 1), ); await api.jobs.releaseMilestone(id, nextMilestone.id); - await workspace.refresh(); + void refreshJob(); } catch { alert("Failed to release milestone"); } finally { @@ -132,12 +166,12 @@ export default function JobDetailsPage() { } async function handleOpenDispute() { - if (!workspace.job) return; + if (!job) return; setBusyAction("dispute"); try { - const actor = (await ensureViewerAddress()) ?? workspace.job.client_address; - await openDispute(BigInt(workspace.job.on_chain_job_id ?? 0)); + const actor = (await ensureViewerAddress()) ?? job.client_address; + await openDispute(BigInt(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 { @@ -147,482 +181,487 @@ export default function JobDetailsPage() { } } - if (workspace.loading && !workspace.job) { + if (loading && !job) { return ( - - - +
+
+
+

+ Job Overview +

+

+ Loading workspace +

+

+ Fetching counterparties, milestones, deliverables, and dispute state. +

+
+ +
+
); } - if (!workspace.job) { + if (!job) { return ( - -
- {workspace.error ?? "Job not found."} +
+
+
+

Workspace unavailable

+

{error ?? "Job not found."}

+
- +
); } - const job = workspace.job; - const nextMilestone = workspace.milestones.find( + const nextMilestone = milestones.find( (milestone) => milestone.status === "pending", ); - const workflowLocked = job.status === "disputed" || workspace.dispute !== null; + const workflowLocked = job.status === "disputed"; return ( - -
-
-
-
-
-

- Status -

-
-

- {job.title} -

- - {job.status} - +
+
+
+

+ Job Overview +

+

+ {job.title} +

+

+ A shared contract workspace for bids, deliverables, approvals, and escalation. +

+
+ +
+
+
+
+
+

+ Status +

+
+ + {job.status} + +
+

+ {job.description} +

+
+
+

+ Contract Value +

+

+ {formatUsdc(job.budget_usdc)} +

+

+ {job.milestones} milestone approvals +

-

- {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"} -

+
+
+

+ Client +

+

+ {shortenAddress(job.client_address)} +

+
+
+

+ Freelancer +

+

+ {job.freelancer_address + ? shortenAddress(job.freelancer_address) + : "Not assigned"} +

+
+
+

+ Updated +

+

+ {formatDateTime(job.updated_at)} +

+
-
-

- Updated + +

+

+ Escrow Contract

-

- {formatDateTime(job.updated_at)} +

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

-
-
-

- Escrow Contract -

-

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

-
- - {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 - + {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. -

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

- 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. +

+
+ Use the sidebar to connect and submit your bid.
- {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 ({bids.length})

-

- Freelancers can pin files to IPFS or share links, then the client gets a dedicated approval moment. -

+ + Client shortlist +
- -

- - {!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} + +
+
+ ) : null} -
- {workspace.deliverables.length === 0 ? ( -
- No milestone evidence has been submitted yet. + {job.status !== "open" ? ( +
+
+
+
+

+ Milestone Ledger +

+

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

- ) : ( - workspace.deliverables.map((deliverable) => ( -
+ ) : null} +
+ +
+ {milestones.map((milestone) => ( +
-
+
-

- Milestone {deliverable.milestone_index} +

+ Milestone {milestone.index}

-

- {deliverable.label} +

+ {milestone.title} +

+
+
+

+ {formatUsdc(milestone.amount_usdc)} +

+

+ {milestone.status}

-

- {formatDateTime(deliverable.created_at)} -

- - Open evidence - - - )) - )} -
-
-
- ) : null} -
+ {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. +

+
+ +
-
- -
-

- Counterparty trust -

-
-
-

- Client reputation -

-
- - - {workspace.clientReputation?.averageStars.toFixed(1) ?? "2.5"} - -
-

- {workspace.clientReputation?.totalJobs ?? 0} completed jobs -

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

- Client control room -

-

- Awaiting Client Approval -

-

- Approve the latest submitted milestone, or escalate to a dispute if the evidence does not satisfy the brief. -

-
- - + Open funding review + +
+ ) : null} + + {job.status !== "open" && job.status !== "awaiting_funding" ? ( +
+

+ Client control room +

+

+ Awaiting Client Approval +

+

+ Approve the latest submitted milestone, or escalate to a dispute if the evidence does not satisfy the brief. +

+
+ + +
+
+ ) : null} + +
+

+ Activity pulse +

+
+
+ Next milestone + + {nextMilestone ? `#${nextMilestone.index}` : "Complete"} + +
+
+ Last update + + + {formatDate(job.updated_at)} + +
- ) : null} - -
-

- Activity pulse -

-
-
- Next milestone - - {nextMilestone ? `#${nextMilestone.index}` : "Complete"} - -
-
- Last update - - - {formatDate(job.updated_at)} - -
-
-
- - -
+ + + + ); -} +} \ No newline at end of file diff --git a/apps/web/app/jobs/page.tsx b/apps/web/app/jobs/page.tsx index 6881e0b5..49fca65b 100644 --- a/apps/web/app/jobs/page.tsx +++ b/apps/web/app/jobs/page.tsx @@ -1,12 +1,10 @@ "use client"; -import Link from "next/link"; -import { ArrowUpRight, Clock3, Search, SlidersHorizontal } from "lucide-react"; +import { Search, SlidersHorizontal } from "lucide-react"; import { SiteShell } from "@/components/site-shell"; -import { Stars } from "@/components/stars"; -import { JobCardSkeleton } from "@/components/ui/skeleton"; +import { JobCard, JobCardSkeleton } from "@/components/jobs/job-card"; +import { JobCardErrorBoundary } from "@/components/jobs/job-card-error-boundary"; import { useJobBoard } from "@/hooks/use-job-board"; -import { formatDate, formatUsdc, shortenAddress } from "@/lib/format"; const sortOptions = [ { id: "chronological", label: "Newest" }, @@ -24,19 +22,19 @@ export default function JobsPage() { title="Find open work with clean trust signals before you even open the brief." description="The board hydrates open jobs from the backend, layers in client reputation from Soroban, and keeps filtering responsive enough to scan dozens of listings without friction." > -
+
-