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 + ) : ( +
+ +
+ )} + +
+

+ {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 + ) : ( +
+ +
+ )} + +
+

+ {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 (