From ec115cb5558871e6499cbff279d894fac4d4f787 Mon Sep 17 00:00:00 2001 From: lamioIsNew Date: Wed, 29 Jul 2026 14:55:08 +0800 Subject: [PATCH] feat: add group detail page at /groups/[id] with stats, tabs, and live treasury (#9) --- src/app/groups/[id]/page.tsx | 199 +++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/app/groups/[id]/page.tsx diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx new file mode 100644 index 0000000..9f73187 --- /dev/null +++ b/src/app/groups/[id]/page.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useParams, notFound } from 'next/navigation'; +import { useState } from 'react'; +import { Copy, Users, CreditCard, DollarSign, TrendingUp } from 'lucide-react'; +import type { Group, Member, Contribution, Loan, Proposal } from '@/types'; + +async function fetchGroup(id: string): Promise { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/groups/${id}`); + if (!res.ok) return null; + return res.json(); +} + +async function fetchGroupMembers(id: string): Promise { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/groups/${id}/members`); + if (!res.ok) return []; + return res.json(); +} + +async function fetchTreasuryBalance(group: Group): Promise { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/groups/${group.id}/treasury`); + if (res.ok) { + const data = await res.json(); + return data.balance ?? group.balance; + } + } catch {} + return group.balance; +} + +const TABS = ['Members', 'Contributions', 'Loans', 'Governance'] as const; +type Tab = (typeof TABS)[number]; + +export default function GroupDetailPage() { + const params = useParams<{ id: string }>(); + const [activeTab, setActiveTab] = useState('Members'); + const [copied, setCopied] = useState(false); + + const { data: group, isLoading, isError } = useQuery({ + queryKey: ['group', params.id], + queryFn: () => fetchGroup(params.id), + }); + + const { data: members = [] } = useQuery({ + queryKey: ['group-members', params.id], + queryFn: () => fetchGroupMembers(params.id), + enabled: !!group, + }); + + const { data: treasuryBalance } = useQuery({ + queryKey: ['treasury', params.id], + queryFn: () => fetchTreasuryBalance(group!), + enabled: !!group, + }); + + if (isLoading) { + return ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ ); + } + + if (isError || !group) { + notFound(); + } + + const copyAddress = () => { + navigator.clipboard.writeText(group.admin); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const stats = [ + { label: 'Treasury', value: `$${(treasuryBalance ?? group.balance).toLocaleString()}`, icon: DollarSign, color: 'text-green-600', bg: 'bg-green-50' }, + { label: 'Members', value: group.members?.length ?? 0, icon: Users, color: 'text-blue-600', bg: 'bg-blue-50' }, + { label: 'Contributions', value: `$${group.totalContributions.toLocaleString()}`, icon: TrendingUp, color: 'text-purple-600', bg: 'bg-purple-50' }, + { label: 'Status', value: group.isActive ? 'Active' : 'Inactive', icon: CreditCard, color: group.isActive ? 'text-green-600' : 'text-red-600', bg: group.isActive ? 'bg-green-50' : 'bg-red-50' }, + ]; + + const tabContent: Record = { + Members: ( +
+ + + + + + + + + + + {members.map((m) => ( + + + + + + + ))} + +
AddressDisplay NameContributedJoined
+ {m.address.slice(0, 6)}...{m.address.slice(-4)} + {m.displayName || '—'}${m.totalContributed.toLocaleString()}{new Date(m.joinedAt).toLocaleDateString()}
+
+ ), + Contributions: ( +
+ + + + + + + + + + + + +
MemberAmountPeriodTransaction
No contributions yet
+
+ ), + Loans: ( +
No active loans
+ ), + Governance: ( +
No active proposals
+ ), + }; + + return ( +
+ {/* Header */} +
+
+
+
+

{group.name}

+ + {group.isActive ? 'Active' : 'Inactive'} + +
+

{group.description}

+
+ Admin: + {group.admin.slice(0, 10)}...{group.admin.slice(-6)} + +
+
+ +
+
+ + {/* Stats Row */} +
+ {stats.map((stat) => ( +
+
+ +
+

+ {typeof stat.value === 'number' ? stat.value.toLocaleString() : stat.value} +

+

{stat.label}

+
+ ))} +
+ + {/* Tabs */} +
+
+ {TABS.map((tab) => ( + + ))} +
+
{tabContent[activeTab]}
+
+
+ ); +}