From ac2b397f34b8c5ff0a77592b5bae65d74179d6ab Mon Sep 17 00:00:00 2001 From: tecch-wiz Date: Wed, 24 Jun 2026 21:02:33 +0100 Subject: [PATCH] feat: add contract details page (#118) - Create reusable ProfileCard component for client/freelancer info - Add ContractMilestoneList with status-aware rendering - Add ContractEscrowSummary showing escrow amount, progress, and address - Add GET /api/contracts/[id] route returning contract, profiles, escrow, and milestones - Implement responsive layout with loading, empty, and error states --- app/api/contracts/[id]/route.ts | 152 ++++++++++++++ app/dashboard/contracts/[id]/page.tsx | 193 ++++++++++++++++++ .../dashboard/contract-escrow-summary.tsx | 119 +++++++++++ .../dashboard/contract-milestone-list.tsx | 97 +++++++++ components/dashboard/profile-card.tsx | 98 +++++++++ 5 files changed, 659 insertions(+) create mode 100644 app/api/contracts/[id]/route.ts create mode 100644 app/dashboard/contracts/[id]/page.tsx create mode 100644 components/dashboard/contract-escrow-summary.tsx create mode 100644 components/dashboard/contract-milestone-list.tsx create mode 100644 components/dashboard/profile-card.tsx diff --git a/app/api/contracts/[id]/route.ts b/app/api/contracts/[id]/route.ts new file mode 100644 index 0000000..c4c1fab --- /dev/null +++ b/app/api/contracts/[id]/route.ts @@ -0,0 +1,152 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { sql } from '@/lib/db' +import { getUserById } from '@/lib/contracts/store' + +export const GET = withAuth(async (request: NextRequest, auth) => { + try { + const url = new URL(request.url) + const id = url.pathname.split('/').pop() + + if (!id || isNaN(Number(id))) { + return NextResponse.json({ error: 'Invalid contract ID', code: 'INVALID_CONTRACT_ID' }, { status: 400 }) + } + + const contractId = Number(id) + const walletAddress = auth.walletAddress + + // Fetch contract + const contractRows = await sql` + SELECT c.*, j.title as job_title + FROM contracts c + JOIN jobs j ON j.id = c.job_id + WHERE c.id = ${contractId} + LIMIT 1 + ` + + if (contractRows.length === 0) { + return NextResponse.json({ error: 'Contract not found', code: 'CONTRACT_NOT_FOUND' }, { status: 404 }) + } + + const contract = contractRows[0] + + // Verify access: client or freelancer + const clientRows = await sql` + SELECT id FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 + ` + const clientRow = clientRows[0] + + if (!clientRow || (clientRow.id !== contract.client_id && clientRow.id !== contract.freelancer_id)) { + return NextResponse.json({ error: 'Access denied', code: 'ACCESS_DENIED' }, { status: 403 }) + } + + // Fetch client + const client = await getUserById(contract.client_id) + + // Fetch freelancer + const freelancer = await getUserById(contract.freelancer_id) + + // Fetch milestones linked to this contract + const milestoneRows = await sql` + SELECT id, title, description, amount, currency, due_date, status, sort_order + FROM milestones + WHERE contract_id = ${contractId} + ORDER BY sort_order ASC, due_date ASC + ` + const contractMilestones = milestoneRows.map((m: { + id: number + title: string + description: string | null + amount: string + currency: string + due_date: string | null + status: string + sort_order: number + }) => ({ + id: String(m.id), + title: m.title, + description: m.description, + amount: m.amount, + currency: m.currency, + due_date: m.due_date, + status: m.status, + sort_order: m.sort_order, + })) + + // Fetch escrow info if available + const escrowRows = await sql` + SELECT escrow_address, escrow_status, funded_at, funding_tx_hash, total_amount as escrow_total_amount, + funded_amount, released_amount, progress_percent, network_passphrase + FROM jobs + WHERE id = ${contract.job_id} + LIMIT 1 + ` + const escrowRaw = escrowRows[0] as { + escrow_address: string | null + escrow_status: string | null + funded_at: string | null + funding_tx_hash: string | null + escrow_total_amount: string + funded_amount: number + released_amount: number + progress_percent: number + network_passphrase: string | null + } | undefined + + const escrow = escrowRaw ? { + escrow_address: escrowRaw.escrow_address, + escrow_status: escrowRaw.escrow_status ?? 'draft', + total_amount: escrowRaw.escrow_total_amount, + funded_amount: escrowRaw.funded_amount, + released_amount: escrowRaw.released_amount, + progress_percent: escrowRaw.progress_percent, + network_passphrase: escrowRaw.network_passphrase, + funded_at: escrowRaw.funded_at, + funding_tx_hash: escrowRaw.funding_tx_hash, + } : null + + const response = { + contract: { + id: String(contract.id), + job_id: String(contract.job_id), + job_title: contract.job_title, + status: contract.status, + total_amount: contract.total_amount, + currency: contract.currency, + terms: contract.terms, + contract_address: contract.contract_address, + created_at: contract.created_at, + updated_at: contract.updated_at, + client: client + ? { + display_name: (client as unknown as { display_name?: string | null }).display_name ?? null, + username: (client as unknown as { username?: string }).username ?? 'unknown', + avatar_url: (client as unknown as { avatar_url?: string | null }).avatar_url ?? null, + wallet_address: client.wallet_address, + avg_rating: (client as unknown as { avg_rating?: number }).avg_rating, + total_reviews: (client as unknown as { total_reviews?: number }).total_reviews, + } + : null, + freelancer: freelancer + ? { + display_name: (freelancer as unknown as { display_name?: string | null }).display_name ?? null, + username: (freelancer as unknown as { username?: string }).username ?? 'unknown', + avatar_url: (freelancer as unknown as { avatar_url?: string | null }).avatar_url ?? null, + wallet_address: freelancer.wallet_address, + avg_rating: (freelancer as unknown as { avg_rating?: number }).avg_rating, + total_reviews: (freelancer as unknown as { total_reviews?: number }).total_reviews, + } + : null, + escrow, + milestones: contractMilestones, + }, + } + + return NextResponse.json(response) + } catch (error) { + console.error('[GET /api/contracts/[id]]', error) + return NextResponse.json({ error: 'Failed to load contract', code: 'SERVER_ERROR' }, { status: 500 }) + } +}) \ No newline at end of file diff --git a/app/dashboard/contracts/[id]/page.tsx b/app/dashboard/contracts/[id]/page.tsx new file mode 100644 index 0000000..42b758f --- /dev/null +++ b/app/dashboard/contracts/[id]/page.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, Loader2, AlertCircle, Star, ShieldCheck, Wallet, User } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { ProfileCard } from "@/components/dashboard/profile-card"; +import { ContractMilestoneList, type ContractMilestone } from "@/components/dashboard/contract-milestone-list"; +import { ContractEscrowSummary } from "@/components/dashboard/contract-escrow-summary"; +import { EscrowStatusTracker, type EscrowStage } from "@/components/dashboard/escrow-status-tracker"; + +interface ProfileInfo { + display_name: string | null; + username: string; + avatar_url: string | null; + wallet_address: string | null; + avg_rating?: number; + total_reviews?: number; +} + +interface ContractDetail { + id: string; + job_id: string; + status: string; + total_amount: string; + currency: string; + terms: string | null; + contract_address: string | null; + created_at: string; + updated_at: string; + client?: ProfileInfo | null; + freelancer?: ProfileInfo | null; + escrow?: { + escrow_address: string | null; + escrow_status: string; + total_amount: string; + funded_amount: number; + released_amount: number; + progress_percent: number; + network_passphrase: string | null; + } | null; + milestones: ContractMilestone[]; +} + +const contractStatusConfig: Record = { + pending: { label: "Pending", color: "bg-muted", textColor: "text-muted-foreground" }, + active: { label: "Active", color: "bg-secondary/20", textColor: "text-secondary" }, + paused: { label: "Paused", color: "bg-amber-500/20", textColor: "text-amber-500" }, + completed: { label: "Completed", color: "bg-accent/20", textColor: "text-accent" }, + cancelled: { label: "Cancelled", color: "bg-muted", textColor: "text-muted-foreground" }, + disputed: { label: "Disputed", color: "bg-destructive/20", textColor: "text-destructive" }, +}; + +const escrowStageMap: Record = { + draft: "Funded", open: "Funded", in_progress: "In Progress", + completed: "Released", disputed: "In Progress", +}; + +function getAuthHeaders(): Record { + const token = + typeof window !== "undefined" + ? localStorage.getItem("tc_dev_access_token") + : null; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export default function ContractDetailPage() { + const { id } = useParams<{ id: string }>(); + const [contract, setContract] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + (async () => { + try { + const res = await fetch(`/api/contracts/${id}`, { + headers: getAuthHeaders(), + credentials: "include", + }); + if (!res.ok) { + setError("Contract not found or you don't have access."); + return; + } + const data = await res.json(); + setContract(data.contract); + } catch { + setError("Failed to load contract."); + } finally { + setLoading(false); + } + })(); + }, [id]); + + if (loading) { + return ( +
+ + Loading contract… +
+ ); + } + + if (error || !contract) { + return ( +
+ +

{error ?? "Contract not found."}

+ + + +
+ ); + } + + const statusCfg = contractStatusConfig[contract.status] ?? contractStatusConfig.pending; + const escrowStage = escrowStageMap[contract.status] ?? "Funded"; + const clientBadge = ( + Owner + ); + const freelancerBadge = contract.freelancer ? ( + Assigned + ) : ( + Hiring + ); + + return ( +
+
+ {/* Header */} +
+
+ + + +
+

Contract #{contract.id}

+

Job ID: {contract.job_id}

+
+
+ + {statusCfg.label} + +
+ + {/* Parties */} +
+ + +
+ + {/* Contract Info & Escrow */} +
+ +

+ + Contract Details +

+
+
+

Total Amount

+

+ {contract.total_amount} {contract.currency} +

+
+ {contract.contract_address && ( +
+

Contract Address

+

{contract.contract_address}

+
+ )} + {contract.terms && ( +
+

Terms

+

{contract.terms}

+
+ )} +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/components/dashboard/contract-escrow-summary.tsx b/components/dashboard/contract-escrow-summary.tsx new file mode 100644 index 0000000..264972e --- /dev/null +++ b/components/dashboard/contract-escrow-summary.tsx @@ -0,0 +1,119 @@ +import Link from "next/link"; +import { ArrowLeft, ShieldCheck, Wallet } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { + EscrowStatusTracker, + type EscrowStage, +} from "@/components/dashboard/escrow-status-tracker"; + +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; + network_passphrase: string | null; +} + +export function ContractEscrowSummary({ + escrow, + currency, + onBack, +}: { + escrow: EscrowInfo | null; + currency: string; + onBack?: () => void; +}) { + const escrowStage: EscrowStage = + (escrow?.escrow_status as EscrowStage | undefined) ?? "Funded"; + + return ( +
+
+ {onBack && ( + + )} +

Escrow Details

+
+ + + + {escrow && ( + +
+
+

+ + + Escrow:{" "} + + {escrow.escrow_status.replace("_", " ")} + + +

+ {escrow.escrow_address && ( +

+ + Address: {escrow.escrow_address} +

+ )} + {escrow.network_passphrase && ( +

+ Network: {escrow.network_passphrase} +

+ )} +
+
+ + Total Locked + + + {escrow.total_amount} {currency} + +
+
+ +
+
+

Funded

+

+ {escrow.funded_amount.toLocaleString()} {currency} +

+
+
+

Released

+

+ {escrow.released_amount.toLocaleString()} {currency} +

+
+
+ + {escrow.funding_tx_hash && ( +

+ Funding Tx: {escrow.funding_tx_hash} +

+ )} + + {escrow.funded_at && ( +

+ Funded at: {new Date(escrow.funded_at).toLocaleString()} +

+ )} +
+ )} + + {!escrow && ( + + No escrow information available for this contract. + + )} +
+ ); +} \ No newline at end of file diff --git a/components/dashboard/contract-milestone-list.tsx b/components/dashboard/contract-milestone-list.tsx new file mode 100644 index 0000000..a26c9c5 --- /dev/null +++ b/components/dashboard/contract-milestone-list.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { CheckCircle2, Clock, AlertCircle, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +export interface ContractMilestone { + id: string; + title: string; + description: string | null; + amount: string; + currency: string; + due_date: string | null; + status: string; + sort_order: number; +} + +const milestoneStatusConfig: Record< + string, + { label: string; color: string; icon: typeof CheckCircle2 } +> = { + pending: { label: "Pending", color: "text-muted-foreground", icon: AlertCircle }, + in_progress: { label: "In Progress", color: "text-secondary", icon: Clock }, + submitted: { label: "Submitted", color: "text-amber-500", icon: CheckCircle2 }, + approved: { label: "Approved", color: "text-accent", icon: CheckCircle2 }, + rejected: { label: "Rejected", color: "text-destructive", icon: AlertCircle }, + paid: { label: "Paid", color: "text-primary", icon: CheckCircle2 }, +}; + +export function ContractMilestoneList({ milestones, isLoading }: { milestones: ContractMilestone[]; isLoading?: boolean }) { + if (isLoading) { + return ( +
+ + Loading milestones... +
+ ); + } + + if (milestones.length === 0) { + return ( +

+ No milestones for this contract. +

+ ); + } + + return ( +
+ {milestones.map((m, i) => { + const config = + milestoneStatusConfig[m.status] ?? milestoneStatusConfig.pending; + const Icon = config.icon; + + return ( +
+
+
+ {i + 1} +
+
+

{m.title}

+ {m.description && ( +

+ {m.description} +

+ )} + {m.due_date && ( +

+ Due {new Date(m.due_date).toLocaleDateString()} +

+ )} +
+
+
+
+

+ ${parseFloat(m.amount).toLocaleString()} {m.currency} +

+

+ + {config.label} +

+
+ +
+
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/components/dashboard/profile-card.tsx b/components/dashboard/profile-card.tsx new file mode 100644 index 0000000..aee8e8c --- /dev/null +++ b/components/dashboard/profile-card.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Star, User, Wallet } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; + +export interface ProfileInfo { + display_name: string | null; + username: string; + avatar_url: string | null; + wallet_address: string | null; + avg_rating?: number; + total_reviews?: number; +} + +interface ProfileCardProps { + type: "client" | "freelancer"; + profile: ProfileInfo | null; + badge?: React.ReactNode; + emptyMessage?: string; +} + +export function ProfileCard({ + type, + profile, + badge, + emptyMessage = "No profile information available", +}: ProfileCardProps) { + const Icon = type === "client" ? User : User; + const roleLabel = type === "client" ? "Client" : "Freelancer"; + + return ( + +
+ + + {roleLabel} + + {badge} +
+ + {profile ? ( + <> +
+ {profile.avatar_url ? ( + {profile.display_name + ) : ( +
+ +
+ )} + +
+

+ {profile.display_name || "Anonymous User"} +

+

+ @{profile.username} +

+
+
+ + {type === "freelancer" && + profile.avg_rating !== undefined && + profile.total_reviews !== undefined && ( +
+ + + {Number(profile.avg_rating).toFixed(1)} + + + ({profile.total_reviews} reviews) + +
+ )} + + {profile.wallet_address && ( +
+ + + {profile.wallet_address} + +
+ )} + + ) : ( +
+ +

{emptyMessage}

+
+ )} +
+ ); +} \ No newline at end of file