diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx new file mode 100644 index 0000000..adadc9f --- /dev/null +++ b/src/app/groups/[id]/page.tsx @@ -0,0 +1,497 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useParams, notFound } from "next/navigation"; +import { + Copy, + Check, + Users, + CreditCard, + TrendingUp, + ExternalLink, + AlertCircle, + UserPlus, + Landmark, + FileText, + Vote, +} from "lucide-react"; +import { StatCard } from "@/components/dashboard/stat-card"; +import { LoanCard } from "@/components/loans/loan-card"; +import { ProposalCard } from "@/components/governance/proposal-card"; +import { shortenAddress, formatAmount } from "@/lib/stellar"; +import { + fetchGroup, + fetchGroupMembers, + fetchGroupContributions, + fetchGroupLoans, + fetchGroupProposals, +} from "@/lib/api"; +import { useWallet } from "@/hooks/use-wallet"; +import { clsx } from "clsx"; +import { formatDistanceToNow } from "date-fns"; + +// ─── Stellar chain balance ──────────────────────────────────────────────── + +/** Query the on-chain USDC balance of a contract address via Horizon. */ +async function fetchChainBalance( + contractAddress: string +): Promise { + if (!contractAddress) return null; + try { + const horizon = + process.env.NEXT_PUBLIC_HORIZON_URL || + "https://horizon-testnet.stellar.org"; + const res = await fetch( + `${horizon}/accounts/${encodeURIComponent(contractAddress)}` + ); + if (!res.ok) return null; + const data = await res.json(); + for (const bal of data.balances || []) { + if ( + bal.asset_type === "credit_alphanum4" && + bal.asset_code === "USDC" + ) { + return bal.balance; + } + // Also try native XLM as a last resort (should never happen for a treasury) + } + // If the contract address holds no USDC yet, return "0" + return "0"; + } catch { + return null; + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [text]); + + return ( + + ); +} + +const TABS = [ + { key: "members", label: "Members", icon: Users }, + { key: "contributions", label: "Contributions", icon: TrendingUp }, + { key: "loans", label: "Loans", icon: CreditCard }, + { key: "governance", label: "Governance", icon: Vote }, +] as const; + +// ─── Page ───────────────────────────────────────────────────────────────── + +export default function GroupDetailPage() { + const params = useParams<{ id: string }>(); + const id = params?.id ?? ""; + const { address } = useWallet(); + + const [activeTab, setActiveTab] = useState("members"); + + // ── Group ──────────────────────────────────────────────────────────── + const { + data: group, + isLoading: groupLoading, + error: groupError, + } = useQuery({ + queryKey: ["group", id], + queryFn: () => fetchGroup(id), + enabled: !!id, + }); + + // ── Chain balance ──────────────────────────────────────────────────── + const { data: chainBalance } = useQuery({ + queryKey: ["chain-balance", group?.contractAddresses?.treasury], + queryFn: () => + fetchChainBalance(group?.contractAddresses?.treasury ?? ""), + enabled: !!group?.contractAddresses?.treasury, + refetchInterval: 30_000, + }); + + // ── Members ────────────────────────────────────────────────────────── + const { data: members = [] } = useQuery({ + queryKey: ["group-members", id], + queryFn: () => fetchGroupMembers(id), + enabled: !!id, + refetchInterval: 15_000, + }); + + // ── Contributions ──────────────────────────────────────────────────── + const { data: contributions = [], isLoading: contribsLoading } = useQuery({ + queryKey: ["group-contributions", id], + queryFn: () => fetchGroupContributions(id), + enabled: !!id, + refetchInterval: 15_000, + }); + + // ── Loans ──────────────────────────────────────────────────────────── + const { data: loans = [], isLoading: loansLoading } = useQuery({ + queryKey: ["group-loans", id], + queryFn: () => fetchGroupLoans(id), + enabled: !!id, + refetchInterval: 15_000, + }); + + // ── Proposals ──────────────────────────────────────────────────────── + const { data: proposals = [], isLoading: proposalsLoading } = useQuery({ + queryKey: ["group-proposals", id], + queryFn: () => fetchGroupProposals(id), + enabled: !!id, + refetchInterval: 15_000, + }); + + // ── Derived ────────────────────────────────────────────────────────── + const isAdmin = !!address && !!group && address === group.admin; + const memberCount = members.length || group?.members?.length || 0; + const activeLoans = loans.filter((l) => l.status === "Approved").length; + const totalContributions = + contributions.reduce((sum, c) => sum + c.amount, 0) || + group?.totalContributions || + 0; + const displayBalance = chainBalance ?? group?.balance ?? 0; + + // ── Error / Loading ────────────────────────────────────────────────── + if (groupError) { + if ((groupError as Error).message === "NOT_FOUND") { + notFound(); + } + return ( +
+ +

Failed to load group

+

{(groupError as Error).message}

+
+ ); + } + + if (groupLoading) { + return ( +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + if (!group) return null; + + // ── Render ─────────────────────────────────────────────────────────── + return ( +
+ {/* ── Header ──────────────────────────────────────────────────── */} +
+
+
+

+ {group.name} +

+ + {group.isActive ? "Active" : "Inactive"} + +
+ {group.description && ( +

+ {group.description} +

+ )} +
+ + Admin: {shortenAddress(group.admin)} + + +
+
+ + {isAdmin && ( + + )} +
+ + {/* ── Stats Row ────────────────────────────────────────────────── */} +
+ + + + +
+ + {/* ── Tab Bar ──────────────────────────────────────────────────── */} +
+ {TABS.map(({ key, label, icon: Icon }) => ( + + ))} +
+ + {/* ── Tab: Members ─────────────────────────────────────────────── */} + {activeTab === "members" && ( +
+
+ + + + + + + + + + + {members.length === 0 ? ( + + + + ) : ( + members.map((m, i) => ( + + + + + + + )) + )} + +
+ Address + + Display Name + + Total Contributed + + Joined +
+ No members yet. +
+ + {m.address} + + + {shortenAddress(m.address)} + + + {m.displayName || "—"} + + ${formatAmount(m.totalContributed)} + + {m.joinedAt + ? formatDistanceToNow(new Date(m.joinedAt), { + addSuffix: true, + }) + : "—"} +
+
+
+ )} + + {/* ── Tab: Contributions ────────────────────────────────────────── */} + {activeTab === "contributions" && ( +
+ {contribsLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+ + + + + + + + + + + {contributions.length === 0 ? ( + + + + ) : ( + contributions.map((c, i) => ( + + + + + + + )) + )} + +
+ Member + + Amount + + Period + + Tx Hash +
+ + No contributions recorded yet. +
+ + {c.member} + + + {shortenAddress(c.member)} + + + ${formatAmount(c.amount)} + + #{c.period} + + {c.txHash ? ( + + {shortenAddress(c.txHash, 3)} + + + ) : ( + + )} +
+
+ )} +
+ )} + + {/* ── Tab: Loans ────────────────────────────────────────────────── */} + {activeTab === "loans" && ( +
+ {loansLoading ? ( +
+ {[...Array(2)].map((_, i) => ( +
+ ))} +
+ ) : loans.length === 0 ? ( +
+ +

No loans for this group yet.

+
+ ) : ( +
+ {loans.map((loan) => ( + + ))} +
+ )} +
+ )} + + {/* ── Tab: Governance ───────────────────────────────────────────── */} + {activeTab === "governance" && ( +
+ {proposalsLoading ? ( +
+ {[...Array(2)].map((_, i) => ( +
+ ))} +
+ ) : proposals.length === 0 ? ( +
+ +

No governance proposals for this group yet.

+
+ ) : ( +
+ {proposals.map((proposal) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 38e454d..9284e41 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Group } from "@/types"; +import type { Group, Member, Contribution, Loan, Proposal } from "@/types"; export interface CreateGroupPayload { name: string; @@ -13,17 +13,16 @@ export interface CreateGroupPayload { }; } +const API = process.env.NEXT_PUBLIC_API_URL || ""; + export async function createGroup( payload: CreateGroupPayload ): Promise { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/groups`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); + const res = await fetch(`${API}/api/groups`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); if (!res.ok) { const text = await res.text().catch(() => "Unknown error"); @@ -32,3 +31,51 @@ export async function createGroup( return res.json(); } + +/** Fetch a single group by ID from the backend API. */ +export async function fetchGroup(id: string): Promise { + const res = await fetch(`${API}/api/groups/${encodeURIComponent(id)}`); + if (!res.ok) { + if (res.status === 404) throw new Error("NOT_FOUND"); + throw new Error(`Failed to fetch group (${res.status})`); + } + return res.json(); +} + +/** Fetch members of a group. */ +export async function fetchGroupMembers(id: string): Promise { + const res = await fetch( + `${API}/api/groups/${encodeURIComponent(id)}/members` + ); + if (!res.ok) return []; + return res.json(); +} + +/** Fetch contribution history for a group. */ +export async function fetchGroupContributions( + id: string +): Promise { + const res = await fetch( + `${API}/api/groups/${encodeURIComponent(id)}/contributions` + ); + if (!res.ok) return []; + return res.json(); +} + +/** Fetch loans associated with a group. */ +export async function fetchGroupLoans(id: string): Promise { + const res = await fetch( + `${API}/api/groups/${encodeURIComponent(id)}/loans` + ); + if (!res.ok) return []; + return res.json(); +} + +/** Fetch proposals associated with a group. */ +export async function fetchGroupProposals(id: string): Promise { + const res = await fetch( + `${API}/api/groups/${encodeURIComponent(id)}/proposals` + ); + if (!res.ok) return []; + return res.json(); +}