Skip to content
Open
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
152 changes: 122 additions & 30 deletions apps/web/app/jobs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,15 @@
"use client";

import Link from "next/link";
import {
ArrowUpRight,
Briefcase,
Clock3,
DollarSign,
Layers,
Plus,
Shield,
Sparkles,
TrendingUp,
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";
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 ───────────────────────────────────────────────────────────

Expand Down Expand Up @@ -282,21 +269,9 @@ function StatsBar({ total, filtered }: { total: number; filtered: number }) {
// ─── 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("");
Expand Down Expand Up @@ -424,6 +399,123 @@ export default function JobsPage() {
))}
</div>
) : (
<div className="grid gap-5 lg:grid-cols-2">
{jobs.map((job) => (
<div key={job.id} className="group relative" data-testid="job-card">
<Link
href={`/jobs/${job.id}`}
className="block rounded-[1.75rem] border border-slate-200 bg-white/85 p-6 shadow-[0_20px_60px_-45px_rgba(15,23,42,0.55)] transition hover:-translate-y-1 hover:border-amber-300"
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-amber-700">
{job.status}
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-slate-950">
{job.title}
</h2>
</div>
<div className="flex items-center gap-2">
<ShareJobButton
path={`/jobs/${job.id}`}
title={job.title}
className="border-slate-200 bg-white/95"
/>
<ArrowUpRight className="h-5 w-5 text-slate-400 transition group-hover:text-slate-950" />
</div>
</div>

<p className="mt-4 line-clamp-3 text-sm leading-6 text-slate-600">
{job.description}
</p>

<div className="mt-5 flex flex-wrap gap-2">
{job.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-600"
>
{tag}
</span>
))}
</div>

<div className="mt-6 grid gap-4 rounded-[1.4rem] border border-slate-200 bg-slate-50 p-4 sm:grid-cols-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">
Budget
</p>
<p className="mt-2 text-lg font-semibold text-slate-950">
{formatUsdc(job.budget_usdc)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">
Deadline
</p>
<p className="mt-2 inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<Clock3 className="h-4 w-4 text-amber-600" />
{formatDate(job.deadlineAt)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">
Milestones
</p>
<p className="mt-2 text-sm font-medium text-slate-700">
{job.milestones} tracked approvals
</p>
</div>
</div>

<div className="mt-5 flex items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">
Client
</p>
<p className="mt-2 text-sm font-medium text-slate-700">
{shortenAddress(job.client_address)}
</p>
</div>
<div className="text-right">
<div className="inline-flex items-center gap-2 rounded-full bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-900">
<Stars value={job.clientReputation.starRating} />
{job.clientReputation.averageStars.toFixed(1)}
</div>
<p className="mt-2 text-xs text-slate-500">
{job.clientReputation.totalJobs} completed jobs on-chain
</p>
</div>
</div>
</Link>

<button
aria-label={isSaved(job.id) ? "Unsave job" : "Save job"}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleSaveJob(job);
}}
className={[
"absolute right-12 top-6 flex h-10 w-10 items-center justify-center rounded-full border shadow-sm transition hover:scale-110 active:scale-95",
isSaved(job.id)
? "border-amber-500 bg-amber-50 text-amber-600"
: "border-slate-200 bg-white text-slate-400 hover:text-slate-600",
].join(" ")}
>
<Bookmark
className={[
"h-5 w-5",
isSaved(job.id) ? "fill-current" : "",
].join(" ")}
/>
</button>
</div>
))}
</div>
)}

{!loading && jobs.length === 0 ? (
<EmptyState
tone="dark"
icon={<Briefcase className="h-5 w-5" aria-hidden="true" />}
Expand Down
61 changes: 61 additions & 0 deletions apps/web/app/jobs/saved/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SiteShell
eyebrow="My Collection"
title="Saved Opportunities"
description="Keep track of high-signal briefs you want to bid on later. These jobs are stored locally for fast retrieval and persistence across sessions."
>
<section className="rounded-[2rem] border border-border/60 glass-surface p-5 shadow-[0_25px_80px_-50px_rgba(15,23,42,0.55)] sm:p-6">
<label className="flex items-center gap-3 rounded-2xl border border-border/40 bg-background/40 px-4 py-3">
<Search className="h-4 w-4 text-slate-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search your saved jobs..."
className="w-full bg-transparent text-sm text-slate-900 outline-none placeholder:text-slate-400 dark:text-zinc-50"
/>
</label>
</section>

<section className="mt-8">
{filteredJobs.length > 0 ? (
<div className="grid gap-5 lg:grid-cols-2">
{filteredJobs.map((job) => (
<JobCard key={job.id} job={job} />
))}
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-[2rem] border border-dashed border-slate-300 bg-white/70 py-24 text-center dark:border-white/10 dark:bg-zinc-900/40">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 text-slate-400 dark:bg-zinc-800 dark:text-zinc-500">
<Bookmark className="h-8 w-8" />
</div>
<h3 className="mt-6 text-xl font-semibold text-slate-900 dark:text-zinc-100">
No saved jobs yet
</h3>
<p className="mt-2 max-w-sm text-sm text-slate-500 dark:text-zinc-400">
Browse the job registry and click the bookmark icon to keep track of interesting opportunities.
</p>
</div>
)}
</section>
</SiteShell>
);
}
76 changes: 76 additions & 0 deletions apps/web/components/jobs/job-card.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="stars-mock" />,
}));

type SavedJobsStoreState = ReturnType<typeof useSavedJobsStore>;

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(<JobCard job={mockJob} />);

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(<JobCard job={mockJob} />);

const bookmarkButton = screen.getByLabelText("Save job");
fireEvent.click(bookmarkButton);

expect(toggleSaveJob).toHaveBeenCalledWith(mockJob);
});
});
Loading
Loading