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
152 changes: 152 additions & 0 deletions app/api/contracts/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
193 changes: 193 additions & 0 deletions app/dashboard/contracts/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { label: string; color: string; textColor: string }> = {
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<string, EscrowStage> = {
draft: "Funded", open: "Funded", in_progress: "In Progress",
completed: "Released", disputed: "In Progress",
};

function getAuthHeaders(): Record<string, string> {
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<ContractDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center min-h-[60vh] text-muted-foreground gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Loading contract…
</div>
);
}

if (error || !contract) {
return (
<div className="p-8 flex flex-col items-center justify-center min-h-[60vh] gap-4">
<AlertCircle className="h-10 w-10 text-destructive" />
<p className="text-muted-foreground">{error ?? "Contract not found."}</p>
<Link href="/dashboard/contracts">
<Button variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Contracts
</Button>
</Link>
</div>
);
}

const statusCfg = contractStatusConfig[contract.status] ?? contractStatusConfig.pending;
const escrowStage = escrowStageMap[contract.status] ?? "Funded";
const clientBadge = (
<Badge variant="outline" className="text-[10px] text-accent border-accent/20 bg-accent/5">Owner</Badge>
);
const freelancerBadge = contract.freelancer ? (
<Badge variant="outline" className="text-[10px] text-primary border-primary/20 bg-primary/5">Assigned</Badge>
) : (
<Badge variant="outline" className="text-[10px] text-yellow-500 border-yellow-500/20 bg-yellow-500/5">Hiring</Badge>
);

return (
<div className="p-8">
<div className="space-y-8">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<Link href="/dashboard/contracts">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">Contract #{contract.id}</h1>
<p className="text-muted-foreground mt-2">Job ID: {contract.job_id}</p>
</div>
</div>
<Badge className={`${statusCfg.color} ${statusCfg.textColor} border-0`}>
{statusCfg.label}
</Badge>
</div>

{/* Parties */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<ProfileCard type="client" profile={contract.client ?? null} badge={clientBadge} />
<ProfileCard type="freelancer" profile={contract.freelancer ?? null} badge={freelancerBadge} emptyMessage="No freelancer assigned" />
</div>

{/* Contract Info & Escrow */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="p-6 bg-card/50 border-border/40 backdrop-blur-sm rounded-xl space-y-3 md:col-span-1">
<h3 className="text-lg font-semibold flex items-center gap-2 text-primary">
<Wallet className="h-5 w-5 text-accent shrink-0" />
Contract Details
</h3>
<div className="space-y-2 text-sm">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-1">Total Amount</p>
<p className="text-2xl font-bold">
{contract.total_amount} {contract.currency}
</p>
</div>
{contract.contract_address && (
<div className="pt-2 border-t border-border/20">
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-1">Contract Address</p>
<p className="font-mono text-xs break-all">{contract.contract_address}</p>
</div>
)}
{contract.terms && (
<div className="pt-2 border-t border-border/20">
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-1">Terms</p>
<p className="text-sm line-clamp-3">{contract.terms}</p>
</div>
)}
</div>
</Card>
<ContractEscrowSummary escrow={contract.escrow} currency={contract.currency} />
</div>
</div>
</div>
);
}
Loading
Loading