diff --git a/apps/web/app/jobs/[id]/page.tsx b/apps/web/app/jobs/[id]/page.tsx index c4acdbea..1c845359 100644 --- a/apps/web/app/jobs/[id]/page.tsx +++ b/apps/web/app/jobs/[id]/page.tsx @@ -2,27 +2,18 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { - CheckCircle2, FileUp, - Gavel, - LoaderCircle, ShieldAlert, - Wallet, } from "lucide-react"; import { BidList } from "@/components/jobs/bid-list"; -import { MilestoneTracker } from "@/components/jobs/milestone-tracker"; -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 { SaveJobButton } from "@/components/jobs/save-job-button"; 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 { api } from "@/lib/api"; -import { releaseFunds, openDispute, getEscrowContractId } from "@/lib/contracts"; +import { releaseFunds, getEscrowContractId } from "@/lib/contracts"; import { formatDateTime, formatUsdc, @@ -30,17 +21,18 @@ import { } from "@/lib/format"; import { connectWallet, getConnectedWalletAddress } from "@/lib/stellar"; -import { ActivityLogList } from "@/components/activity-log"; import { TransactionPipeline } from "@/components/blockchain/transaction-pipeline"; +import { MilestoneTracker } from "@/components/jobs/milestone-tracker"; +import { SubmitBidErrorBoundary } from "@/components/jobs/submit-bid-error-boundary"; +import { SubmitBidModal } from "@/components/jobs/submit-bid-modal"; import { useAcceptBid } from "@/hooks/use-accept-bid"; export default function JobDetailsPage() { const { id } = useParams<{ id: string }>(); - const router = useRouter(); const workspace = useLiveJobWorkspace(id); - const { accept, transaction: acceptTransaction } = useAcceptBid(); + const { transaction: acceptTransaction } = useAcceptBid(); // useLiveJobWorkspace provides data and a `refresh()` helper const [viewerAddress, setViewerAddress] = useState(null); @@ -59,33 +51,6 @@ export default function JobDetailsPage() { setViewerAddress(connected); return connected; } - - async function handleAcceptBid(bidId: string) { - if (!workspace.job) return; - const bid = workspace.bids.find((item) => item.id === bidId); - if (!bid) return; - - setBusyAction(`accept-${bidId}`); - try { - const result = await accept({ - jobId: id, - onChainJobId: BigInt(workspace.job.on_chain_job_id ?? 0), - bidId, - freelancerAddress: bid.freelancer_address, - }); - - if (!result) { - throw new Error("Unable to confirm bid acceptance."); - } - - await workspace.refresh(); - router.push(`/jobs/${result.acceptedJob.id}/fund`); - } catch { - alert("Failed to accept bid"); - } finally { - setBusyAction(null); - } - } async function handleSubmitDeliverable(event: React.FormEvent) { event.preventDefault(); @@ -128,45 +93,6 @@ export default function JobDetailsPage() { } } - 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}`); - } catch { - alert("Failed to open dispute"); - } finally { - setBusyAction(null); - } - } - if (workspace.loading && !workspace.job) { return ( milestone.status === "pending", - ); const viewerBid = viewerAddress ? workspace.bids.find( (bid) => @@ -229,7 +152,9 @@ export default function JobDetailsPage() { {job.status} - +
+ +

{job.description} @@ -359,18 +284,12 @@ export default function JobDetailsPage() { {acceptTransaction.step !== "idle" ? (

@@ -468,210 +387,19 @@ export default function JobDetailsPage() { - - ) : 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 - -
- )) - )} -
+ ) : ( +
+ +

Deliverables are locked during dispute resolution

+
+ )}
) : null} - -
); } - diff --git a/apps/web/app/jobs/page.tsx b/apps/web/app/jobs/page.tsx index 038b92b6..76408bb1 100644 --- a/apps/web/app/jobs/page.tsx +++ b/apps/web/app/jobs/page.tsx @@ -8,9 +8,7 @@ import { DollarSign, Layers, Plus, - Shield, Sparkles, - TrendingUp, Users, Zap, } from "lucide-react"; diff --git a/apps/web/components/activity-log.tsx b/apps/web/components/activity-log.tsx index 716dac4b..b2083c88 100644 --- a/apps/web/components/activity-log.tsx +++ b/apps/web/components/activity-log.tsx @@ -2,7 +2,8 @@ import React, { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { apiActivity, ActivityLog } from "@/lib/api"; +import { apiActivity } from "@/lib/api"; +import type { ActivityLog } from "@/lib/api"; import { CheckCircle2, Clock, AlertCircle, Info, LucideIcon } from "lucide-react"; import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; diff --git a/apps/web/components/jobs/__tests__/save-job-button.test.tsx b/apps/web/components/jobs/__tests__/save-job-button.test.tsx new file mode 100644 index 00000000..51dafe21 --- /dev/null +++ b/apps/web/components/jobs/__tests__/save-job-button.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { SaveJobButton } from "../save-job-button"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { useWalletSession } from "@/hooks/use-wallet-session"; + +vi.mock("@/lib/api", () => ({ + api: { + users: { + savedJobs: vi.fn(), + }, + jobs: { + save: vi.fn(), + unsave: vi.fn(), + }, + }, +})); + +vi.mock("@/hooks/use-wallet-session", () => ({ + useWalletSession: vi.fn(), +})); + +// Mock ResizeObserver for Popover +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +// Mock pointer events for Radix UI +if (typeof window !== 'undefined') { + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + window.HTMLElement.prototype.releasePointerCapture = vi.fn(); +} + +describe("SaveJobButton", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.clearAllMocks(); + }); + + const renderComponent = () => { + return render( + + + + ); + }; + + it("renders disabled button when wallet is not connected", () => { + (useWalletSession as jest.Mock).mockReturnValue({ address: null }); + (api.users.savedJobs as jest.Mock).mockResolvedValue([]); + renderComponent(); + + const button = screen.getByRole("button", { name: /save job/i }); + expect(button).toBeDisabled(); + }); + + it("opens popover and saves job with note", async () => { + (useWalletSession as jest.Mock).mockReturnValue({ address: "G123" }); + (api.users.savedJobs as jest.Mock).mockResolvedValue([]); + (api.jobs.save as jest.Mock).mockResolvedValue({ id: "1", note: "test" }); + + renderComponent(); + + const button = await screen.findByRole("button", { name: /save job/i }); + fireEvent.click(button); + + const textarea = await screen.findByPlaceholderText(/Great match/i); + fireEvent.change(textarea, { target: { value: "test note" } }); + + const saveBtn = screen.getByRole("button", { name: "Save" }); + fireEvent.click(saveBtn); + + await waitFor(() => { + expect(api.jobs.save).toHaveBeenCalledWith("123", "G123", { note: "test note" }); + }); + }); + + it("validates long notes", async () => { + (useWalletSession as jest.Mock).mockReturnValue({ address: "G123" }); + (api.users.savedJobs as jest.Mock).mockResolvedValue([]); + + renderComponent(); + + const button = await screen.findByRole("button", { name: /save job/i }); + fireEvent.click(button); + + const textarea = await screen.findByPlaceholderText(/Great match/i); + const longText = "a".repeat(256); + fireEvent.change(textarea, { target: { value: longText } }); + + const saveBtn = screen.getByRole("button", { name: "Save" }); + fireEvent.click(saveBtn); + + expect(await screen.findByText(/Note must be under 255 characters/i)).toBeInTheDocument(); + }); + + it("unsaves a job if already saved", async () => { + (useWalletSession as jest.Mock).mockReturnValue({ address: "G123" }); + (api.users.savedJobs as jest.Mock).mockResolvedValue([{ job_id: "123" }]); + (api.jobs.unsave as jest.Mock).mockResolvedValue({}); + + renderComponent(); + + const button = await screen.findByRole("button", { name: /saved/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(api.jobs.unsave).toHaveBeenCalledWith("123", "G123"); + }); + }); +}); diff --git a/apps/web/components/jobs/accept-bid-flow.tsx b/apps/web/components/jobs/accept-bid-flow.tsx new file mode 100644 index 00000000..c4b3e1de --- /dev/null +++ b/apps/web/components/jobs/accept-bid-flow.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api, type Bid, type Job } from "@/lib/api"; +import { AcceptBidModal } from "./accept-bid-modal"; +import { toast } from "sonner"; + +interface AcceptBidFlowProps { + job: Job; + bids: Bid[]; + isClientOwner: boolean; + onSuccess?: () => void; + children: (props: { + handleAcceptClick: (bidId: string) => void; + acceptingBidId: string | null; + }) => React.ReactNode; +} + +export function AcceptBidFlow({ + job, + bids, + isClientOwner, + onSuccess, + children, +}: AcceptBidFlowProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [selectedBidId, setSelectedBidId] = useState(null); + + const selectedBid = bids.find((b) => b.id === selectedBidId) || null; + + const acceptMutation = useMutation({ + mutationFn: async (bidId: string) => { + return api.bids.accept(job.id, bidId, { + client_address: job.client_address, + }); + }, + onSuccess: (updatedJob) => { + toast.success("Bid accepted successfully!"); + queryClient.invalidateQueries({ queryKey: ["job", job.id] }); + queryClient.invalidateQueries({ queryKey: ["bids", job.id] }); + + setSelectedBidId(null); + onSuccess?.(); + + // Redirect to funding page as per backend logic + router.push(`/jobs/${updatedJob.id}/fund`); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to accept bid. Please try again."); + setSelectedBidId(null); + }, + }); + + const handleAcceptClick = (bidId: string) => { + if (!isClientOwner) { + toast.error("Only the job owner can accept bids."); + return; + } + setSelectedBidId(bidId); + }; + + const handleConfirm = () => { + if (selectedBidId) { + acceptMutation.mutate(selectedBidId); + } + }; + + return ( + <> + {children({ + handleAcceptClick, + acceptingBidId: acceptMutation.isPending ? selectedBidId : null, + })} + + !acceptMutation.isPending && setSelectedBidId(null)} + onConfirm={handleConfirm} + bid={selectedBid} + job={job} + isPending={acceptMutation.isPending} + /> + + ); +} \ No newline at end of file diff --git a/apps/web/components/jobs/accept-bid-modal.tsx b/apps/web/components/jobs/accept-bid-modal.tsx new file mode 100644 index 00000000..77295458 --- /dev/null +++ b/apps/web/components/jobs/accept-bid-modal.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { LoaderCircle } from "lucide-react"; +import { type Bid, type Job } from "@/lib/api"; + +interface AcceptBidModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + bid: Bid | null; + job: Job; + isPending: boolean; +} + +export function AcceptBidModal({ + isOpen, + onClose, + onConfirm, + bid, + job, + isPending, +}: AcceptBidModalProps) { + if (!isOpen || !bid) return null; + + return ( +
+
event.stopPropagation()} + > +
+
+

+ Review & Accept +

+

+ Accept Freelancer Bid +

+

+ Review the freelancer's proposal and budget. Once accepted, you'll need to fund the job. +

+
+
+ +
+
+
+ Freelancer Address + {bid.freelancer_address} +
+ +
+ Proposal +

{bid.proposal}

+
+ +
+ Job Budget + + ${(job.budget_usdc / 10_000_000).toLocaleString()} USDC + +
+
+ +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/components/jobs/bid-list.tsx b/apps/web/components/jobs/bid-list.tsx index 7896812e..ea52bdb6 100644 --- a/apps/web/components/jobs/bid-list.tsx +++ b/apps/web/components/jobs/bid-list.tsx @@ -2,13 +2,13 @@ import { useState } from "react"; import { CheckCircle2, Clock3, Loader2, UserCircle2 } from "lucide-react"; -import { type Bid } from "@/lib/api"; +import { type Bid, type Job } from "@/lib/api"; import { shortenAddress, formatDate } from "@/lib/format"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; -import { Stars } from "@/components/stars"; import { cn } from "@/lib/utils"; +import { AcceptBidFlow } from "./accept-bid-flow"; // ── Status helpers ────────────────────────────────────────────────────────── @@ -83,17 +83,15 @@ function EmptyBids() { // ── Main component ────────────────────────────────────────────────────────── interface BidListProps { + job: Job; bids: Bid[]; loading?: boolean; error?: string | null; isClientOwner?: boolean; - jobStatus?: string; - acceptingBidId?: string | null; - onAccept?: (bidId: string) => void; } /** - * BidList — Issue #132 + * BidList — Issue #132 & #135 * * Renders the list of bids on a job from the client's perspective. * - Shows loading skeletons while bids are being fetched @@ -102,15 +100,14 @@ interface BidListProps { * - Per-bid "Accept" action for the client owner on open jobs * - Status badges with semantic colour coding (Amber = pending, Emerald = accepted) * - Fully responsive with keyboard-accessible accept buttons + * - Integrated "Accept Bid" flow with confirmation modal */ export function BidList({ + job, bids, loading = false, error = null, isClientOwner = false, - jobStatus = "open", - acceptingBidId = null, - onAccept, }: BidListProps) { const [expandedId, setExpandedId] = useState(null); @@ -129,45 +126,30 @@ export function BidList({ if (bids.length === 0) return ; - const canAccept = isClientOwner && jobStatus === "open"; - - const sortedBids = [...bids].sort((left, right) => { - const leftScore = left.freelancerReputation?.scoreBps ?? 0; - const rightScore = right.freelancerReputation?.scoreBps ?? 0; - if (rightScore !== leftScore) return rightScore - leftScore; - return ( - new Date(left.created_at).getTime() - new Date(right.created_at).getTime() - ); - }); + const canAccept = isClientOwner && job.status === "open"; return ( -
- - - - - - - - - - - - {sortedBids.map((bid) => { + + {({ handleAcceptClick, acceptingBidId }) => ( +
    + {bids.map((bid) => { const isExpanded = expandedId === bid.id; const isAccepting = acceptingBidId === bid.id; const isAccepted = bid.status === "accepted"; return ( -
- - - - - - + + )} + + {isAccepted && ( +

+

+ )} + ); })} - -
FreelancerReputationProposalStatusAction
-
+ {/* Header row */} +
+
-
- {bid.freelancerReputation ? ( -
- - - {bid.freelancerReputation.averageStars.toFixed(1)} - · - {bid.freelancerReputation.totalJobs} jobs - -
- ) : ( - No reputation yet - )} -
-
- {bid.proposal} -
- {bid.proposal.length > 120 && ( - - )} -
-
+ +
-
-
- {canAccept && !isAccepted ? ( + + + {/* Proposal — collapsed to 2 lines, expandable */} +
+ {bid.proposal} +
+ + {bid.proposal.length > 120 && ( + + )} + + {/* Accept action */} + {canAccept && !isAccepted && ( +
- ) : isAccepted ? ( -

- Accepted -

- ) : ( - - )} -
-
+ + )} + ); } diff --git a/apps/web/components/jobs/milestone-tracker.tsx b/apps/web/components/jobs/milestone-tracker.tsx index 23c7bc58..c3f21825 100644 --- a/apps/web/components/jobs/milestone-tracker.tsx +++ b/apps/web/components/jobs/milestone-tracker.tsx @@ -417,12 +417,11 @@ export function MilestoneTracker({ busyMilestoneId = null, onRelease, }: MilestoneTrackerProps) { - const { releasedAmount, totalAmount } = useMemo(() => { + const { releasedAmount } = useMemo(() => { const released = milestones .filter((m) => m.status === "released") .reduce((sum, m) => sum + m.amount_usdc, 0); - const total = milestones.reduce((sum, m) => sum + m.amount_usdc, 0); - return { releasedAmount: released, totalAmount: total }; + return { releasedAmount: released }; }, [milestones]); const releasedCount = milestones.filter((m) => m.status === "released").length; diff --git a/apps/web/components/jobs/save-job-button.tsx b/apps/web/components/jobs/save-job-button.tsx new file mode 100644 index 00000000..20d93d5b --- /dev/null +++ b/apps/web/components/jobs/save-job-button.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bookmark, LoaderCircle, CheckCircle2 } from "lucide-react"; +import { z } from "zod"; +import * as Popover from "@radix-ui/react-popover"; +import { api } from "@/lib/api"; +import { useWalletSession } from "@/hooks/use-wallet-session"; +import { cn } from "@/lib/utils"; + +const saveJobSchema = z.object({ + note: z.string().max(255, "Note must be under 255 characters").optional(), +}); + +export function SaveJobButton({ jobId }: { jobId: string }) { + const { address } = useWalletSession(); + const queryClient = useQueryClient(); + const [isOpen, setIsOpen] = useState(false); + const [note, setNote] = useState(""); + const [validationError, setValidationError] = useState(null); + + const { data: savedJobs = [], isLoading: isLoadingSaved } = useQuery({ + queryKey: ["savedJobs", address], + queryFn: () => (address ? api.users.savedJobs(address) : Promise.resolve([])), + enabled: !!address, + }); + + const isSaved = savedJobs.some((job) => job.job_id === jobId); + + const saveMutation = useMutation({ + mutationFn: (body: { note?: string }) => { + if (!address) throw new Error("Wallet not connected"); + return api.jobs.save(jobId, address, body); + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey: ["savedJobs", address] }); + const previousSavedJobs = queryClient.getQueryData(["savedJobs", address]); + + queryClient.setQueryData(["savedJobs", address], (old: Array<{id: string, job_id: string, user_address: string, note?: string, created_at: string}> | undefined) => [ + ...(old || []), + { id: "optimistic", job_id: jobId, user_address: address, note: variables.note, created_at: new Date().toISOString() } + ]); + return { previousSavedJobs }; + }, + onError: (err, variables, context) => { + if (context?.previousSavedJobs) { + queryClient.setQueryData(["savedJobs", address], context.previousSavedJobs); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["savedJobs", address] }); + }, + }); + + const unsaveMutation = useMutation({ + mutationFn: () => { + if (!address) throw new Error("Wallet not connected"); + return api.jobs.unsave(jobId, address); + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ["savedJobs", address] }); + const previousSavedJobs = queryClient.getQueryData(["savedJobs", address]); + + queryClient.setQueryData(["savedJobs", address], (old: Array<{id: string, job_id: string, user_address: string, note?: string, created_at: string}> | undefined) => + (old || []).filter((j) => j.job_id !== jobId) + ); + return { previousSavedJobs }; + }, + onError: (err, variables, context) => { + if (context?.previousSavedJobs) { + queryClient.setQueryData(["savedJobs", address], context.previousSavedJobs); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["savedJobs", address] }); + }, + }); + + const handleToggle = () => { + if (!address) return; + if (isSaved) { + unsaveMutation.mutate(); + } else { + setIsOpen(true); + } + }; + + const handleSave = () => { + try { + const parsed = saveJobSchema.parse({ note }); + saveMutation.mutate(parsed, { + onSuccess: () => { + setIsOpen(false); + setNote(""); + setValidationError(null); + } + }); + } catch (e) { + if (e instanceof z.ZodError) { + setValidationError(e.issues[0].message); + } + } + }; + + const isLoading = isLoadingSaved || saveMutation.isPending || unsaveMutation.isPending; + + return ( + + + + + + {!isSaved && ( + + +
+
+

Save this job

+

Add an optional note for why you saved this.

+
+ +
+