Skip to content

Commit ba7fc5a

Browse files
Merge pull request #135 from gelluisaac/feature
feat: add contract details page (#118)
2 parents ba14d8b + ac2b397 commit ba7fc5a

5 files changed

Lines changed: 659 additions & 0 deletions

File tree

app/api/contracts/[id]/route.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
export const dynamic = 'force-dynamic'
2+
3+
import { NextRequest, NextResponse } from 'next/server'
4+
import { withAuth } from '@/lib/auth/middleware'
5+
import { sql } from '@/lib/db'
6+
import { getUserById } from '@/lib/contracts/store'
7+
8+
export const GET = withAuth(async (request: NextRequest, auth) => {
9+
try {
10+
const url = new URL(request.url)
11+
const id = url.pathname.split('/').pop()
12+
13+
if (!id || isNaN(Number(id))) {
14+
return NextResponse.json({ error: 'Invalid contract ID', code: 'INVALID_CONTRACT_ID' }, { status: 400 })
15+
}
16+
17+
const contractId = Number(id)
18+
const walletAddress = auth.walletAddress
19+
20+
// Fetch contract
21+
const contractRows = await sql`
22+
SELECT c.*, j.title as job_title
23+
FROM contracts c
24+
JOIN jobs j ON j.id = c.job_id
25+
WHERE c.id = ${contractId}
26+
LIMIT 1
27+
`
28+
29+
if (contractRows.length === 0) {
30+
return NextResponse.json({ error: 'Contract not found', code: 'CONTRACT_NOT_FOUND' }, { status: 404 })
31+
}
32+
33+
const contract = contractRows[0]
34+
35+
// Verify access: client or freelancer
36+
const clientRows = await sql`
37+
SELECT id FROM users WHERE wallet_address = ${walletAddress} LIMIT 1
38+
`
39+
const clientRow = clientRows[0]
40+
41+
if (!clientRow || (clientRow.id !== contract.client_id && clientRow.id !== contract.freelancer_id)) {
42+
return NextResponse.json({ error: 'Access denied', code: 'ACCESS_DENIED' }, { status: 403 })
43+
}
44+
45+
// Fetch client
46+
const client = await getUserById(contract.client_id)
47+
48+
// Fetch freelancer
49+
const freelancer = await getUserById(contract.freelancer_id)
50+
51+
// Fetch milestones linked to this contract
52+
const milestoneRows = await sql`
53+
SELECT id, title, description, amount, currency, due_date, status, sort_order
54+
FROM milestones
55+
WHERE contract_id = ${contractId}
56+
ORDER BY sort_order ASC, due_date ASC
57+
`
58+
const contractMilestones = milestoneRows.map((m: {
59+
id: number
60+
title: string
61+
description: string | null
62+
amount: string
63+
currency: string
64+
due_date: string | null
65+
status: string
66+
sort_order: number
67+
}) => ({
68+
id: String(m.id),
69+
title: m.title,
70+
description: m.description,
71+
amount: m.amount,
72+
currency: m.currency,
73+
due_date: m.due_date,
74+
status: m.status,
75+
sort_order: m.sort_order,
76+
}))
77+
78+
// Fetch escrow info if available
79+
const escrowRows = await sql`
80+
SELECT escrow_address, escrow_status, funded_at, funding_tx_hash, total_amount as escrow_total_amount,
81+
funded_amount, released_amount, progress_percent, network_passphrase
82+
FROM jobs
83+
WHERE id = ${contract.job_id}
84+
LIMIT 1
85+
`
86+
const escrowRaw = escrowRows[0] as {
87+
escrow_address: string | null
88+
escrow_status: string | null
89+
funded_at: string | null
90+
funding_tx_hash: string | null
91+
escrow_total_amount: string
92+
funded_amount: number
93+
released_amount: number
94+
progress_percent: number
95+
network_passphrase: string | null
96+
} | undefined
97+
98+
const escrow = escrowRaw ? {
99+
escrow_address: escrowRaw.escrow_address,
100+
escrow_status: escrowRaw.escrow_status ?? 'draft',
101+
total_amount: escrowRaw.escrow_total_amount,
102+
funded_amount: escrowRaw.funded_amount,
103+
released_amount: escrowRaw.released_amount,
104+
progress_percent: escrowRaw.progress_percent,
105+
network_passphrase: escrowRaw.network_passphrase,
106+
funded_at: escrowRaw.funded_at,
107+
funding_tx_hash: escrowRaw.funding_tx_hash,
108+
} : null
109+
110+
const response = {
111+
contract: {
112+
id: String(contract.id),
113+
job_id: String(contract.job_id),
114+
job_title: contract.job_title,
115+
status: contract.status,
116+
total_amount: contract.total_amount,
117+
currency: contract.currency,
118+
terms: contract.terms,
119+
contract_address: contract.contract_address,
120+
created_at: contract.created_at,
121+
updated_at: contract.updated_at,
122+
client: client
123+
? {
124+
display_name: (client as unknown as { display_name?: string | null }).display_name ?? null,
125+
username: (client as unknown as { username?: string }).username ?? 'unknown',
126+
avatar_url: (client as unknown as { avatar_url?: string | null }).avatar_url ?? null,
127+
wallet_address: client.wallet_address,
128+
avg_rating: (client as unknown as { avg_rating?: number }).avg_rating,
129+
total_reviews: (client as unknown as { total_reviews?: number }).total_reviews,
130+
}
131+
: null,
132+
freelancer: freelancer
133+
? {
134+
display_name: (freelancer as unknown as { display_name?: string | null }).display_name ?? null,
135+
username: (freelancer as unknown as { username?: string }).username ?? 'unknown',
136+
avatar_url: (freelancer as unknown as { avatar_url?: string | null }).avatar_url ?? null,
137+
wallet_address: freelancer.wallet_address,
138+
avg_rating: (freelancer as unknown as { avg_rating?: number }).avg_rating,
139+
total_reviews: (freelancer as unknown as { total_reviews?: number }).total_reviews,
140+
}
141+
: null,
142+
escrow,
143+
milestones: contractMilestones,
144+
},
145+
}
146+
147+
return NextResponse.json(response)
148+
} catch (error) {
149+
console.error('[GET /api/contracts/[id]]', error)
150+
return NextResponse.json({ error: 'Failed to load contract', code: 'SERVER_ERROR' }, { status: 500 })
151+
}
152+
})
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useParams } from "next/navigation";
5+
import Link from "next/link";
6+
import { ArrowLeft, Loader2, AlertCircle, Star, ShieldCheck, Wallet, User } from "lucide-react";
7+
import { Button } from "@/components/ui/button";
8+
import { Badge } from "@/components/ui/badge";
9+
import { Card } from "@/components/ui/card";
10+
import { ProfileCard } from "@/components/dashboard/profile-card";
11+
import { ContractMilestoneList, type ContractMilestone } from "@/components/dashboard/contract-milestone-list";
12+
import { ContractEscrowSummary } from "@/components/dashboard/contract-escrow-summary";
13+
import { EscrowStatusTracker, type EscrowStage } from "@/components/dashboard/escrow-status-tracker";
14+
15+
interface ProfileInfo {
16+
display_name: string | null;
17+
username: string;
18+
avatar_url: string | null;
19+
wallet_address: string | null;
20+
avg_rating?: number;
21+
total_reviews?: number;
22+
}
23+
24+
interface ContractDetail {
25+
id: string;
26+
job_id: string;
27+
status: string;
28+
total_amount: string;
29+
currency: string;
30+
terms: string | null;
31+
contract_address: string | null;
32+
created_at: string;
33+
updated_at: string;
34+
client?: ProfileInfo | null;
35+
freelancer?: ProfileInfo | null;
36+
escrow?: {
37+
escrow_address: string | null;
38+
escrow_status: string;
39+
total_amount: string;
40+
funded_amount: number;
41+
released_amount: number;
42+
progress_percent: number;
43+
network_passphrase: string | null;
44+
} | null;
45+
milestones: ContractMilestone[];
46+
}
47+
48+
const contractStatusConfig: Record<string, { label: string; color: string; textColor: string }> = {
49+
pending: { label: "Pending", color: "bg-muted", textColor: "text-muted-foreground" },
50+
active: { label: "Active", color: "bg-secondary/20", textColor: "text-secondary" },
51+
paused: { label: "Paused", color: "bg-amber-500/20", textColor: "text-amber-500" },
52+
completed: { label: "Completed", color: "bg-accent/20", textColor: "text-accent" },
53+
cancelled: { label: "Cancelled", color: "bg-muted", textColor: "text-muted-foreground" },
54+
disputed: { label: "Disputed", color: "bg-destructive/20", textColor: "text-destructive" },
55+
};
56+
57+
const escrowStageMap: Record<string, EscrowStage> = {
58+
draft: "Funded", open: "Funded", in_progress: "In Progress",
59+
completed: "Released", disputed: "In Progress",
60+
};
61+
62+
function getAuthHeaders(): Record<string, string> {
63+
const token =
64+
typeof window !== "undefined"
65+
? localStorage.getItem("tc_dev_access_token")
66+
: null;
67+
return token ? { Authorization: `Bearer ${token}` } : {};
68+
}
69+
70+
export default function ContractDetailPage() {
71+
const { id } = useParams<{ id: string }>();
72+
const [contract, setContract] = useState<ContractDetail | null>(null);
73+
const [loading, setLoading] = useState(true);
74+
const [error, setError] = useState<string | null>(null);
75+
76+
useEffect(() => {
77+
if (!id) return;
78+
(async () => {
79+
try {
80+
const res = await fetch(`/api/contracts/${id}`, {
81+
headers: getAuthHeaders(),
82+
credentials: "include",
83+
});
84+
if (!res.ok) {
85+
setError("Contract not found or you don't have access.");
86+
return;
87+
}
88+
const data = await res.json();
89+
setContract(data.contract);
90+
} catch {
91+
setError("Failed to load contract.");
92+
} finally {
93+
setLoading(false);
94+
}
95+
})();
96+
}, [id]);
97+
98+
if (loading) {
99+
return (
100+
<div className="flex items-center justify-center min-h-[60vh] text-muted-foreground gap-2">
101+
<Loader2 className="h-5 w-5 animate-spin" />
102+
Loading contract…
103+
</div>
104+
);
105+
}
106+
107+
if (error || !contract) {
108+
return (
109+
<div className="p-8 flex flex-col items-center justify-center min-h-[60vh] gap-4">
110+
<AlertCircle className="h-10 w-10 text-destructive" />
111+
<p className="text-muted-foreground">{error ?? "Contract not found."}</p>
112+
<Link href="/dashboard/contracts">
113+
<Button variant="outline">
114+
<ArrowLeft className="h-4 w-4 mr-2" />
115+
Back to Contracts
116+
</Button>
117+
</Link>
118+
</div>
119+
);
120+
}
121+
122+
const statusCfg = contractStatusConfig[contract.status] ?? contractStatusConfig.pending;
123+
const escrowStage = escrowStageMap[contract.status] ?? "Funded";
124+
const clientBadge = (
125+
<Badge variant="outline" className="text-[10px] text-accent border-accent/20 bg-accent/5">Owner</Badge>
126+
);
127+
const freelancerBadge = contract.freelancer ? (
128+
<Badge variant="outline" className="text-[10px] text-primary border-primary/20 bg-primary/5">Assigned</Badge>
129+
) : (
130+
<Badge variant="outline" className="text-[10px] text-yellow-500 border-yellow-500/20 bg-yellow-500/5">Hiring</Badge>
131+
);
132+
133+
return (
134+
<div className="p-8">
135+
<div className="space-y-8">
136+
{/* Header */}
137+
<div className="flex items-start justify-between gap-4">
138+
<div className="flex items-start gap-4 flex-1">
139+
<Link href="/dashboard/contracts">
140+
<Button variant="ghost" size="icon">
141+
<ArrowLeft className="h-5 w-5" />
142+
</Button>
143+
</Link>
144+
<div>
145+
<h1 className="text-3xl font-bold">Contract #{contract.id}</h1>
146+
<p className="text-muted-foreground mt-2">Job ID: {contract.job_id}</p>
147+
</div>
148+
</div>
149+
<Badge className={`${statusCfg.color} ${statusCfg.textColor} border-0`}>
150+
{statusCfg.label}
151+
</Badge>
152+
</div>
153+
154+
{/* Parties */}
155+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
156+
<ProfileCard type="client" profile={contract.client ?? null} badge={clientBadge} />
157+
<ProfileCard type="freelancer" profile={contract.freelancer ?? null} badge={freelancerBadge} emptyMessage="No freelancer assigned" />
158+
</div>
159+
160+
{/* Contract Info & Escrow */}
161+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
162+
<Card className="p-6 bg-card/50 border-border/40 backdrop-blur-sm rounded-xl space-y-3 md:col-span-1">
163+
<h3 className="text-lg font-semibold flex items-center gap-2 text-primary">
164+
<Wallet className="h-5 w-5 text-accent shrink-0" />
165+
Contract Details
166+
</h3>
167+
<div className="space-y-2 text-sm">
168+
<div>
169+
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-1">Total Amount</p>
170+
<p className="text-2xl font-bold">
171+
{contract.total_amount} {contract.currency}
172+
</p>
173+
</div>
174+
{contract.contract_address && (
175+
<div className="pt-2 border-t border-border/20">
176+
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-1">Contract Address</p>
177+
<p className="font-mono text-xs break-all">{contract.contract_address}</p>
178+
</div>
179+
)}
180+
{contract.terms && (
181+
<div className="pt-2 border-t border-border/20">
182+
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-1">Terms</p>
183+
<p className="text-sm line-clamp-3">{contract.terms}</p>
184+
</div>
185+
)}
186+
</div>
187+
</Card>
188+
<ContractEscrowSummary escrow={contract.escrow} currency={contract.currency} />
189+
</div>
190+
</div>
191+
</div>
192+
);
193+
}

0 commit comments

Comments
 (0)