Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
306 changes: 17 additions & 289 deletions apps/web/app/jobs/[id]/page.tsx

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions apps/web/app/jobs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ import {
DollarSign,
Layers,
Plus,
Shield,
Sparkles,
TrendingUp,
Users,
Zap,
} from "lucide-react";
Expand Down
3 changes: 2 additions & 1 deletion apps/web/components/activity-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
118 changes: 118 additions & 0 deletions apps/web/components/jobs/__tests__/save-job-button.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={queryClient}>
<SaveJobButton jobId="123" />
</QueryClientProvider>
);
};

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");
});
});
});
88 changes: 88 additions & 0 deletions apps/web/components/jobs/accept-bid-flow.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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,
})}

<AcceptBidModal
isOpen={!!selectedBidId && !acceptMutation.isSuccess}
onClose={() => !acceptMutation.isPending && setSelectedBidId(null)}
onConfirm={handleConfirm}
bid={selectedBid}
job={job}
isPending={acceptMutation.isPending}
/>
</>
);
}
102 changes: 102 additions & 0 deletions apps/web/components/jobs/accept-bid-modal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-zinc-950/80 p-4 backdrop-blur-sm sm:items-center"
role="presentation"
onClick={onClose}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="accept-bid-title"
aria-describedby="accept-bid-description"
className="w-full max-w-2xl rounded-[1.75rem] border border-white/10 bg-zinc-950/95 p-6 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-zinc-500">
Review & Accept
</p>
<h3 id="accept-bid-title" className="mt-2 text-2xl font-semibold text-zinc-50">
Accept Freelancer Bid
</h3>
<p id="accept-bid-description" className="mt-2 max-w-2xl text-sm text-zinc-300">
Review the freelancer&apos;s proposal and budget. Once accepted, you&apos;ll need to fund the job.
</p>
</div>
</div>

<div className="mt-6 space-y-4">
<div className="rounded-2xl border border-zinc-800 bg-black/30 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-zinc-100">Freelancer Address</span>
<span className="font-mono text-xs text-zinc-400">{bid.freelancer_address}</span>
</div>

<div className="mb-3">
<span className="text-sm font-medium text-zinc-100">Proposal</span>
<p className="mt-2 text-sm text-zinc-300 leading-relaxed">{bid.proposal}</p>
</div>

<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-100">Job Budget</span>
<span className="text-lg font-semibold text-emerald-400">
${(job.budget_usdc / 10_000_000).toLocaleString()} USDC
</span>
</div>
</div>

<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="rounded-xl border border-zinc-700 px-4 py-2 text-sm font-semibold text-zinc-200 transition duration-150 hover:border-zinc-500 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-200 active:translate-y-px disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isPending}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-zinc-950 transition duration-150 hover:bg-emerald-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-60"
>
{isPending ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
Accepting...
</>
) : (
"Confirm & Accept"
)}
</button>
</div>
</div>
</section>
</div>
);
}
Loading
Loading