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
43 changes: 43 additions & 0 deletions app/api/proposals/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { readAccessToken, verifyAccessToken } from "@/lib/auth/session";
import { db as sql } from "@/lib/proposals/db";
import {
listProposalsForClient,
listProposalsForFreelancer,
} from "@/lib/proposals/service";

async function resolveUser(request: NextRequest) {
const token = readAccessToken(request);
if (!token) return null;
const verified = verifyAccessToken(token);
if (!verified) return null;

const rows = (await sql`
SELECT id, role FROM users
WHERE wallet_address = ${verified.walletAddress}
LIMIT 1
`) as { id: string; role: "freelancer" | "client" | "admin" }[];

return rows[0] ?? null;
}

export async function GET(request: NextRequest) {
const user = await resolveUser(request);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

if (user.role === "client" || user.role === "admin") {
const projectId = request.nextUrl.searchParams.get("projectId");
if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 });

const proposals = await listProposalsForClient(user.id, projectId, {
status: (request.nextUrl.searchParams.get("status") as any) ?? "all",
sortBy: (request.nextUrl.searchParams.get("sortBy") as any) ?? "recent",
sortDir: (request.nextUrl.searchParams.get("sortDir") as any) ?? "desc",
search: request.nextUrl.searchParams.get("search") ?? undefined,
});
return NextResponse.json({ proposals });
}

const proposals = await listProposalsForFreelancer(user.id);
return NextResponse.json({ proposals });
}
43 changes: 43 additions & 0 deletions app/api/proposals/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { readAccessToken, verifyAccessToken } from "@/lib/auth/session";
import { db as sql } from "@/lib/proposals/db";
import {
listProposalsForClient,
listProposalsForFreelancer,
} from "@/lib/proposals/service";

async function resolveUser(request: NextRequest) {
const token = readAccessToken(request);
if (!token) return null;
const verified = verifyAccessToken(token);
if (!verified) return null;

const rows = (await sql`
SELECT id, role FROM users
WHERE wallet_address = ${verified.walletAddress}
LIMIT 1
`) as { id: string; role: "freelancer" | "client" | "admin" }[];

return rows[0] ?? null;
}

export async function GET(request: NextRequest) {
const user = await resolveUser(request);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

if (user.role === "client" || user.role === "admin") {
const projectId = request.nextUrl.searchParams.get("projectId");
if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 });

const proposals = await listProposalsForClient(user.id, projectId, {
status: (request.nextUrl.searchParams.get("status") as any) ?? "all",
sortBy: (request.nextUrl.searchParams.get("sortBy") as any) ?? "recent",
sortDir: (request.nextUrl.searchParams.get("sortDir") as any) ?? "desc",
search: request.nextUrl.searchParams.get("search") ?? undefined,
});
return NextResponse.json({ proposals });
}

const proposals = await listProposalsForFreelancer(user.id);
return NextResponse.json({ proposals });
}
48 changes: 48 additions & 0 deletions app/dashboard/proposals/[projectId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { notFound, redirect } from "next/navigation";
import { cookies } from "next/headers";
import { db as sql } from "@/lib/proposals/db";
import { verifyAccessToken } from "@/lib/auth/session";
import { ACCESS_TOKEN_COOKIE } from "@/lib/auth/constants";
import { ProposalList } from "@/components/proposals";

export default async function ProposalsPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await params;

const jar = await cookies();
const token = jar.get(ACCESS_TOKEN_COOKIE)?.value;
if (!token) redirect("/login");

const verified = verifyAccessToken(token);
if (!verified) redirect("/login");

const users = (await sql`
SELECT id, role FROM users
WHERE wallet_address = ${verified.walletAddress}
LIMIT 1
`) as { id: string; role: string }[];

const user = users[0];
if (!user) notFound();

const isClient = user.role === "client" || user.role === "admin";
const isFreelancer = user.role === "freelancer";
if (!isClient && !isFreelancer) notFound();

return (
<div className="container mx-auto max-w-5xl py-8">
<header className="mb-6">
<h1 className="text-2xl font-semibold">Proposals</h1>
<p className="text-sm text-muted-foreground">
{isClient
? "Review, filter, and manage freelancer proposals."
: "Your submitted proposals and their status."}
</p>
</header>
<ProposalList projectId={projectId} readOnly={!isClient} />
</div>
);
}
7 changes: 7 additions & 0 deletions components/proposals/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export * from "./proposal-list";
export * from "./proposal-card";
export * from "./proposal-filters";
export * from "./proposal-status-badge";
export * from "./proposal-confirm-dialog";
export * from "./proposal-skeleton";
export * from "./proposal-empty-state";
103 changes: 103 additions & 0 deletions components/proposals/proposal-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ProposalStatusBadge } from "./proposal-status-badge";
import type { Proposal } from "@/lib/proposals/types";

interface Props {
proposal: Proposal;
readOnly?: boolean;
onAccept?: (p: Proposal) => void;
onReject?: (p: Proposal) => void;
onViewProfile?: (p: Proposal) => void;
}

export function ProposalCard({
proposal,
readOnly,
onAccept,
onReject,
onViewProfile,
}: Props) {
const ISO = /^[A-Z]{3}$/;
const code = (proposal.currency || "").toUpperCase();
const budget = ISO.test(code)
? new Intl.NumberFormat("en-US", { style: "currency", currency: code }).format(proposal.proposedBudget)
: `${proposal.proposedBudget.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${code}`;

const initials = proposal.freelancerName
.split(" ")
.map((s) => s[0])
.slice(0, 2)
.join("")
.toUpperCase();

return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
{initials}
</div>
<div>
<p className="font-medium">{proposal.freelancerName}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{proposal.freelancerRating.toFixed(1)} / 5</span>
<span>·</span>
<span>{proposal.freelancerPastContracts} jobs</span>
</div>
</div>
</div>
<ProposalStatusBadge status={proposal.status} />
</CardHeader>
<CardContent className="space-y-3">
{proposal.freelancerSkills.length > 0 && (
<div className="flex flex-wrap gap-1">
{proposal.freelancerSkills.slice(0, 6).map((s) => (
<span key={s} className="rounded bg-muted px-2 py-0.5 text-xs">
{s}
</span>
))}
</div>
)}
<p className="line-clamp-2 text-sm text-muted-foreground">
{proposal.coverLetter}
</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-muted-foreground">Budget: </span>
<span className="font-medium">{budget}</span>
</div>
<div>
<span className="text-muted-foreground">Delivery: </span>
<span className="font-medium">{proposal.estimatedDuration || "-"}</span>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => onViewProfile?.(proposal)}
>
View profile
</Button>
{!readOnly && proposal.status === "pending" && (
<>
<Button size="sm" onClick={() => onAccept?.(proposal)}>
Accept
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onReject?.(proposal)}
>
Reject
</Button>
</>
)}
</div>
</CardContent>
</Card>
);
}
52 changes: 52 additions & 0 deletions components/proposals/proposal-confirm-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";

interface Props {
open: boolean;
action: "accept" | "reject" | null;
freelancerName?: string;
onCancel: () => void;
onConfirm: () => void;
}

export function ProposalConfirmDialog({
open,
action,
freelancerName,
onCancel,
onConfirm,
}: Props) {
const isAccept = action === "accept";
return (
<AlertDialog open={open} onOpenChange={(o) => !o && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isAccept ? "Accept proposal?" : "Reject proposal?"}
</AlertDialogTitle>
<AlertDialogDescription>
{isAccept
? `Accepting ${freelancerName ?? "this freelancer"}'s proposal will notify them and start the project.`
: `Rejecting ${freelancerName ?? "this freelancer"}'s proposal will notify them.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
{isAccept ? "Accept" : "Reject"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
18 changes: 18 additions & 0 deletions components/proposals/proposal-empty-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Card, CardContent } from "@/components/ui/card";

export function ProposalEmptyState({ filtered }: { filtered?: boolean }) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<p className="font-medium">
{filtered ? "No proposals match your filters" : "No proposals yet"}
</p>
<p className="text-sm text-muted-foreground">
{filtered
? "Try changing the status filter or search term."
: "Proposals will appear here once freelancers apply to your project."}
</p>
</CardContent>
</Card>
);
}
58 changes: 58 additions & 0 deletions components/proposals/proposal-filters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use client";

import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ProposalFilters as Filters } from "@/lib/proposals/types";

interface Props {
value: Filters;
onChange: (next: Filters) => void;
}

export function ProposalFiltersBar({ value, onChange }: Props) {
return (
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<Input
placeholder="Search freelancer..."
value={value.search ?? ""}
onChange={(e) => onChange({ ...value, search: e.target.value })}
className="md:max-w-xs"
/>
<Select
value={value.status ?? "all"}
onValueChange={(v) => onChange({ ...value, status: v as any })}
>
<SelectTrigger className="md:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="accepted">Accepted</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="updated">Updated</SelectItem>
</SelectContent>
</Select>
<Select
value={value.sortBy ?? "recent"}
onValueChange={(v) => onChange({ ...value, sortBy: v as any })}
>
<SelectTrigger className="md:w-44">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">Most recent</SelectItem>
<SelectItem value="budget">Budget</SelectItem>
<SelectItem value="delivery">Delivery time</SelectItem>
<SelectItem value="rating">Rating</SelectItem>
</SelectContent>
</Select>
</div>
);
}
Loading
Loading