From 74dda814920997acfe101188b22b1505b83875a1 Mon Sep 17 00:00:00 2001
From: theFirstCodeManiac
Date: Fri, 29 May 2026 09:21:15 +0100
Subject: [PATCH] feat: add project details page
---
app/api/projects/[id]/route.ts | 93 +++++++++-
app/dashboard/projects/[id]/page.tsx | 242 ++++++++++++++++++++++++++-
2 files changed, 319 insertions(+), 16 deletions(-)
diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts
index 3076fdc..c69943f 100644
--- a/app/api/projects/[id]/route.ts
+++ b/app/api/projects/[id]/route.ts
@@ -12,12 +12,42 @@ export const GET = withAuth(async (request: NextRequest, auth) => {
`
if (!userRows.length) return NextResponse.json({ error: 'User not found' }, { status: 404 })
- const projectRows = await sql<{
- id: string; title: string; description: string; status: string
- budget_max: string | null; currency: string; deadline: string | null; created_at: string
- }[]>`
- SELECT id, title, description, status, budget_max, currency, deadline, created_at
- FROM projects WHERE id = ${id} AND client_id = ${userRows[0].id} LIMIT 1
+ const projectRows = await sql`
+ SELECT
+ p.id,
+ p.client_id,
+ p.title,
+ p.description,
+ p.status,
+ p.budget_max,
+ p.currency,
+ p.deadline,
+ p.created_at,
+ c.id AS contract_id,
+ c.total_amount AS contract_total_amount,
+ c.escrow_address AS contract_escrow_address,
+ c.escrow_status AS contract_escrow_status,
+ c.status AS contract_status,
+ c.funded_at AS contract_funded_at,
+ c.funding_tx_hash AS contract_funding_tx_hash,
+ -- client info
+ u_client.display_name AS client_display_name,
+ u_client.username AS client_username,
+ u_client.avatar_url AS client_avatar_url,
+ u_client.wallet_address AS client_wallet_address,
+ -- freelancer info
+ u_freelancer.display_name AS freelancer_display_name,
+ u_freelancer.username AS freelancer_username,
+ u_freelancer.avatar_url AS freelancer_avatar_url,
+ u_freelancer.wallet_address AS freelancer_wallet_address,
+ u_freelancer.avg_rating AS freelancer_avg_rating,
+ u_freelancer.total_reviews AS freelancer_total_reviews
+ FROM projects p
+ LEFT JOIN contracts c ON c.project_id = p.id
+ LEFT JOIN users u_client ON p.client_id = u_client.id
+ LEFT JOIN users u_freelancer ON c.freelancer_id = u_freelancer.id
+ WHERE p.id = ${id} AND (p.client_id = ${userRows[0].id} OR c.freelancer_id = ${userRows[0].id})
+ LIMIT 1
`
if (!projectRows.length) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
@@ -29,5 +59,54 @@ export const GET = withAuth(async (request: NextRequest, auth) => {
FROM milestones WHERE project_id = ${id} ORDER BY sort_order ASC, created_at ASC
`
- return NextResponse.json({ project: projectRows[0], milestones })
+ const project = projectRows[0]
+ const hasEscrow = !!project.contract_id
+ const escrowStatus = project.contract_escrow_status
+ const escrowTotal = project.contract_total_amount ? parseFloat(project.contract_total_amount) : 0
+
+ let escrowFundedAmount = 0
+ if (escrowStatus === 'funded' || escrowStatus === 'partially_released' || escrowStatus === 'fully_released') {
+ escrowFundedAmount = escrowTotal
+ }
+
+ const escrowReleasedAmount = milestones
+ .filter(m => m.status === 'paid')
+ .reduce((sum, m) => sum + parseFloat(m.amount), 0)
+
+ const responseProject = {
+ id: project.id,
+ title: project.title,
+ description: project.description,
+ status: project.status,
+ budget_max: project.budget_max,
+ currency: project.currency,
+ deadline: project.deadline,
+ created_at: project.created_at,
+ client: {
+ display_name: project.client_display_name,
+ username: project.client_username,
+ avatar_url: project.client_avatar_url,
+ wallet_address: project.client_wallet_address
+ },
+ freelancer: project.freelancer_wallet_address ? {
+ display_name: project.freelancer_display_name,
+ username: project.freelancer_username,
+ avatar_url: project.freelancer_avatar_url,
+ wallet_address: project.freelancer_wallet_address,
+ avg_rating: project.freelancer_avg_rating ? parseFloat(project.freelancer_avg_rating) : 0,
+ total_reviews: project.freelancer_total_reviews ? parseInt(project.freelancer_total_reviews) : 0
+ } : null,
+ escrow: hasEscrow ? {
+ escrow_address: project.contract_escrow_address,
+ escrow_status: project.contract_escrow_status,
+ funded_at: project.contract_funded_at,
+ funding_tx_hash: project.contract_funding_tx_hash,
+ total_amount: project.contract_total_amount,
+ funded_amount: escrowFundedAmount,
+ released_amount: escrowReleasedAmount,
+ progress_percent: escrowFundedAmount > 0 ? Math.round((escrowReleasedAmount / escrowFundedAmount) * 100) : 0
+ } : null
+ }
+
+ return NextResponse.json({ project: responseProject, milestones })
})
diff --git a/app/dashboard/projects/[id]/page.tsx b/app/dashboard/projects/[id]/page.tsx
index 632d482..3d0193b 100644
--- a/app/dashboard/projects/[id]/page.tsx
+++ b/app/dashboard/projects/[id]/page.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
- ArrowLeft, CheckCircle2, Download, Loader2, AlertCircle,
+ ArrowLeft, CheckCircle2, Download, Loader2, AlertCircle, User, Wallet, Star, ShieldCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -28,6 +28,26 @@ interface Milestone {
sort_order: number;
}
+interface UserInfo {
+ display_name: string | null;
+ username: string;
+ avatar_url: string | null;
+ wallet_address: string | null;
+ avg_rating?: number;
+ total_reviews?: number;
+}
+
+interface EscrowInfo {
+ escrow_address: string | null;
+ escrow_status: string;
+ funded_at: string | null;
+ funding_tx_hash: string | null;
+ total_amount: string;
+ funded_amount: number;
+ released_amount: number;
+ progress_percent: number;
+}
+
interface ProjectDetail {
id: string;
title: string;
@@ -37,6 +57,9 @@ interface ProjectDetail {
currency: string;
deadline: string | null;
created_at: string;
+ client?: UserInfo | null;
+ freelancer?: UserInfo | null;
+ escrow?: EscrowInfo | null;
}
const statusConfig: Record = {
@@ -127,13 +150,44 @@ export default function ProjectDetailPage() {
}
const budget = parseFloat(project.budget_max ?? "0");
+
+ // Sort milestones consistently by sort_order then due_date
+ const sortedMilestones = [...milestones].sort((a, b) => {
+ if (a.sort_order !== b.sort_order) {
+ return a.sort_order - b.sort_order;
+ }
+ const dateA = a.due_date ? new Date(a.due_date).getTime() : 0;
+ const dateB = b.due_date ? new Date(b.due_date).getTime() : 0;
+ return dateA - dateB;
+ });
+
const completedMilestones = milestones.filter(
(m) => m.status === "approved" || m.status === "paid"
).length;
- const progress =
- milestones.length > 0
- ? Math.round((completedMilestones / milestones.length) * 100)
- : 0;
+
+ // Compute milestone-based progress per amount (or count as fallback)
+ const totalMilestonesAmount = milestones.reduce((sum, m) => sum + parseFloat(m.amount || "0"), 0);
+ const completedMilestonesAmount = milestones
+ .filter((m) => m.status === "approved" || m.status === "paid")
+ .reduce((sum, m) => sum + parseFloat(m.amount || "0"), 0);
+
+ let milestoneProgress = 0;
+ if (totalMilestonesAmount > 0) {
+ milestoneProgress = Math.round((completedMilestonesAmount / totalMilestonesAmount) * 100);
+ } else if (milestones.length > 0) {
+ milestoneProgress = Math.round((completedMilestones / milestones.length) * 100);
+ }
+
+ // Escrow progress calculation
+ const escrow = project.escrow;
+ let progressPercent = milestoneProgress;
+ let isEscrowBased = false;
+
+ if (escrow && escrow.funded_amount > 0) {
+ progressPercent = escrow.progress_percent;
+ isEscrowBased = true;
+ }
+
const daysLeft = project.deadline
? Math.ceil((new Date(project.deadline).getTime() - now) / (1000 * 60 * 60 * 24))
: null;
@@ -170,9 +224,20 @@ export default function ProjectDetailPage() {
{budget > 0 ? `$${budget.toLocaleString()}` : "—"}
-
- Progress
- {progress}%
+
+
+
Progress
+
{progressPercent}%
+
+
+
+ {isEscrowBased ? "Escrow-secured" : "Milestone-weighted"}
+
Milestones
@@ -194,6 +259,164 @@ export default function ProjectDetailPage() {
+ {/* Escrow Details & Visualization Card */}
+ {escrow && (
+
+
+
+
+
+ Escrow: {escrow.escrow_status.replace('_', ' ')}
+
+ {escrow.escrow_address && (
+
+
+ Address: {escrow.escrow_address}
+
+ )}
+
+
+ Escrow Progress
+ {progressPercent}%
+
+
+
+ {/* Escrow Progress Bar */}
+
+
+
+ Funded: {parseFloat(escrow.total_amount).toLocaleString()} {project.currency}
+ Released: {escrow.released_amount.toLocaleString()} {project.currency} ({progressPercent}%)
+
+
+
+ {escrow.funding_tx_hash && (
+
+ Funding Tx: {escrow.funding_tx_hash}
+
+ )}
+
+ )}
+
+ {/* Parties (Client & Freelancer) Responsive Card Section */}
+
+ {/* Client Card */}
+
+
+
+
+ Client
+
+ Owner
+
+
+
+ {project.client?.avatar_url ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
+ {project.client?.display_name || "Anonymous Client"}
+
+
+ @{project.client?.username || "unknown"}
+
+
+
+
+ {project.client?.wallet_address && (
+
+
+
+ {project.client.wallet_address}
+
+
+ )}
+
+
+ {/* Freelancer Card */}
+
+
+
+
+ Freelancer
+
+ {project.freelancer ? (
+ Assigned
+ ) : (
+ Hiring
+ )}
+
+
+ {project.freelancer ? (
+ <>
+
+ {project.freelancer.avatar_url ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
+ {project.freelancer.display_name || "Anonymous Freelancer"}
+
+
+ @{project.freelancer.username}
+
+
+
+
+ {/* Ratings & Reviews */}
+ {project.freelancer.avg_rating !== undefined && project.freelancer.total_reviews !== undefined && (
+
+
+ {Number(project.freelancer.avg_rating).toFixed(1)}
+ ({project.freelancer.total_reviews} reviews)
+
+ )}
+
+ {project.freelancer.wallet_address && (
+
+
+
+ {project.freelancer.wallet_address}
+
+
+ )}
+ >
+ ) : (
+
+
+
Hiring in progress
+
+ No freelancer is currently assigned to this project.
+
+
+ )}
+
+
+
{/* Tabs */}
@@ -207,6 +430,7 @@ export default function ProjectDetailPage() {
Project Milestones
{completedMilestones} of {milestones.length} completed
+ {totalMilestonesAmount > 0 && ` (${milestoneProgress}% by value)`}
{project.status === "in_progress" && (
@@ -223,7 +447,7 @@ export default function ProjectDetailPage() {
) : (
- {milestones.map((m, i) => {
+ {sortedMilestones.map((m, i) => {
const mCfg = milestoneStatusConfig[m.status] ?? milestoneStatusConfig.pending;
return (