diff --git a/apps/web/app/jobs/page.tsx b/apps/web/app/jobs/page.tsx
index e1a4546d..17aecf17 100644
--- a/apps/web/app/jobs/page.tsx
+++ b/apps/web/app/jobs/page.tsx
@@ -1,17 +1,7 @@
"use client";
import Link from "next/link";
-import {
- ArrowUpRight,
- Briefcase,
- Clock3,
- DollarSign,
- Layers,
- Plus,
- Sparkles,
- Users,
- Zap,
-} from "lucide-react";
+import { ArrowUpRight, Bookmark, Clock3, Search, SlidersHorizontal } from "lucide-react";
import { ShareJobButton } from "@/components/jobs/share-job-button";
import { JobFilters } from "@/components/jobs/job-filters";
import { Stars } from "@/components/stars";
@@ -19,8 +9,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { JobCardSkeleton } from "@/components/ui/skeleton";
import { useJobBoard } from "@/hooks/use-job-board";
import { formatDate, formatUsdc, shortenAddress } from "@/lib/format";
-import { cn } from "@/lib/utils";
-import type { BoardJob } from "@/hooks/use-job-board";
+import { useSavedJobsStore } from "@/lib/store/use-saved-jobs-store";
// ─── Status config ───────────────────────────────────────────────────────────
@@ -231,21 +220,9 @@ function JobCard({ job }: { job: BoardJob }) {
// ─── Page ────────────────────────────────────────────────────────────────────
export default function JobsPage() {
- const {
- paginatedJobs,
- loading,
- error,
- query,
- activeTag,
- sortBy,
- availableTags,
- minBudget,
- maxBudget,
- filterStatus,
- actions,
- } = useJobBoard();
-
- const totalOpen = paginatedJobs.length;
+ const { jobs, loading, error, query, activeTag, sortBy, availableTags, actions } =
+ useJobBoard();
+ const { toggleSaveJob, isSaved } = useSavedJobsStore();
function resetFilters() {
actions.setQuery("");
@@ -349,6 +326,123 @@ export default function JobsPage() {
))}
) : (
+
+ {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 ? (
}
diff --git a/apps/web/app/jobs/saved/page.tsx b/apps/web/app/jobs/saved/page.tsx
new file mode 100644
index 00000000..abe71a0a
--- /dev/null
+++ b/apps/web/app/jobs/saved/page.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { SiteShell } from "@/components/site-shell";
+import { JobCard } from "@/components/jobs/job-card";
+import { useSavedJobsStore } from "@/lib/store/use-saved-jobs-store";
+import { Bookmark, Search } from "lucide-react";
+import { useState } from "react";
+
+export default function SavedJobsPage() {
+ const { savedJobs } = useSavedJobsStore();
+ const [query, setQuery] = useState("");
+
+ const filteredJobs = savedJobs.filter((job) =>
+ [job.title, job.description, ...job.tags]
+ .join(" ")
+ .toLowerCase()
+ .includes(query.toLowerCase())
+ );
+
+ return (
+
+
+
+
+ {filteredJobs.length > 0 ? (
+
+ {filteredJobs.map((job) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+
+ No saved jobs yet
+
+
+ Browse the job registry and click the bookmark icon to keep track of interesting opportunities.
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/components/jobs/job-card.test.tsx b/apps/web/components/jobs/job-card.test.tsx
new file mode 100644
index 00000000..82ebaacc
--- /dev/null
+++ b/apps/web/components/jobs/job-card.test.tsx
@@ -0,0 +1,76 @@
+/** @vitest-environment jsdom */
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { JobCard } from "./job-card";
+import { useSavedJobsStore } from "@/lib/store/use-saved-jobs-store";
+import type { BoardJob } from "@/hooks/use-job-board";
+
+// Mock the store
+vi.mock("@/lib/store/use-saved-jobs-store", () => ({
+ useSavedJobsStore: vi.fn(),
+}));
+
+// Mock the Stars component
+vi.mock("@/components/stars", () => ({
+ Stars: () => ,
+}));
+
+type SavedJobsStoreState = ReturnType;
+
+const mockJob: BoardJob = {
+ id: "job-1",
+ title: "Test Job",
+ description: "Test Description",
+ budget_usdc: 1000 * 10_000_000,
+ milestones: 3,
+ client_address: "GXXXXX",
+ status: "open",
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ tags: ["react", "stellar"],
+ deadlineAt: new Date().toISOString(),
+ clientReputation: {
+ scoreBps: 8000,
+ totalJobs: 10,
+ totalPoints: 100,
+ reviews: 5,
+ starRating: 4.5,
+ averageStars: 4.5,
+ },
+};
+
+describe("JobCard", () => {
+ it("renders job details correctly", () => {
+ const mockedUseSavedJobsStore = vi.mocked(useSavedJobsStore);
+ mockedUseSavedJobsStore.mockReturnValue({
+ savedJobIds: [],
+ savedJobs: [],
+ toggleSaveJob: vi.fn(),
+ isSaved: vi.fn().mockReturnValue(false),
+ } satisfies SavedJobsStoreState);
+
+ render();
+
+ expect(screen.getByText("Test Job")).toBeDefined();
+ expect(screen.getByText("1,000 USDC")).toBeDefined();
+ expect(screen.getByText("react")).toBeDefined();
+ });
+
+ it("calls toggleSaveJob when bookmark button is clicked", () => {
+ const toggleSaveJob = vi.fn();
+ const mockedUseSavedJobsStore = vi.mocked(useSavedJobsStore);
+ mockedUseSavedJobsStore.mockReturnValue({
+ savedJobIds: [],
+ savedJobs: [],
+ toggleSaveJob,
+ isSaved: vi.fn().mockReturnValue(false),
+ } satisfies SavedJobsStoreState);
+
+ render();
+
+ const bookmarkButton = screen.getByLabelText("Save job");
+ fireEvent.click(bookmarkButton);
+
+ expect(toggleSaveJob).toHaveBeenCalledWith(mockJob);
+ });
+});
diff --git a/apps/web/components/jobs/job-card.tsx b/apps/web/components/jobs/job-card.tsx
index 78e30aba..89fdc730 100644
--- a/apps/web/components/jobs/job-card.tsx
+++ b/apps/web/components/jobs/job-card.tsx
@@ -1,253 +1,129 @@
"use client";
import Link from "next/link";
-import { ArrowUpRight, Clock3, Star, Users } from "lucide-react";
-import { cn } from "@/lib/utils";
+import { ArrowUpRight, Bookmark, Clock3 } from "lucide-react";
+import { Stars } from "@/components/stars";
import { formatDate, formatUsdc, shortenAddress } from "@/lib/format";
+import type { BoardJob } from "@/hooks/use-job-board";
+import { useSavedJobsStore } from "@/lib/store/use-saved-jobs-store";
+import { cn } from "@/lib/utils";
-export interface JobCardProps {
- job: JobCardData;
-}
-
-export interface JobCardData {
- id: string;
- title: string;
- description: string;
- budget_usdc: number;
- milestones: number;
- client_address: string;
- tags: string[];
- deadlineAt: string;
- clientReputation: ReputationMetrics;
- status: "open" | "in_progress" | "completed" | "disputed";
- created_at?: string;
-}
-
-interface ReputationMetrics {
- scoreBps: number;
- totalJobs: number;
- starRating: number;
- averageStars: number;
-}
-
-type JobStatusConfig = {
- label: string;
- color: {
- bg: string;
- text: string;
- border: string;
- };
-};
-
-const STATUS_CONFIG: Record = {
- open: {
- label: "Open",
- color: {
- bg: "bg-emerald-500/10",
- text: "text-emerald-500",
- border: "border-emerald-500/30",
- },
- },
- in_progress: {
- label: "In Progress",
- color: {
- bg: "bg-amber-500/10",
- text: "text-amber-500",
- border: "border-amber-500/30",
- },
- },
- completed: {
- label: "Completed",
- color: {
- bg: "bg-blue-500/10",
- text: "text-blue-500",
- border: "border-blue-500/30",
- },
- },
- disputed: {
- label: "Disputed",
- color: {
- bg: "bg-red-500/10",
- text: "text-red-500",
- border: "border-red-500/30",
- },
- },
-};
-
-function StatusBadge({ status }: { status: string }) {
- const config = STATUS_CONFIG[status] || STATUS_CONFIG.open;
-
- return (
-
- {config.label}
-
- );
-}
-
-function StarRating({ rating }: { rating: number }) {
- const fullStars = Math.floor(rating);
- const hasHalfStar = rating % 1 >= 0.5;
-
- return (
-
- {Array.from({ length: 5 }, (_, i) => {
- const isFilled = i < fullStars || (i === fullStars && hasHalfStar);
- return (
-
- );
- })}
-
- );
+interface JobCardProps {
+ job: BoardJob;
}
export function JobCard({ job }: JobCardProps) {
+ const { toggleSaveJob, isSaved } = useSavedJobsStore();
+ const saved = isSaved(job.id);
+
return (
-
+