diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx new file mode 100644 index 0000000..6e0832e --- /dev/null +++ b/src/app/groups/[id]/page.tsx @@ -0,0 +1,630 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useParams, notFound } from "next/navigation"; +import { + Users, + DollarSign, + CreditCard, + TrendingUp, + Copy, + Plus, + Check, + ExternalLink, + User, + Coins, + Calendar, + Hash, + Circle, + ArrowUpRight, + Wallet, + Building2, +} from "lucide-react"; +import * as Tabs from "@radix-ui/react-tabs"; +import { formatDistanceToNow } from "date-fns"; +import { clsx } from "clsx"; +import { useState, useCallback } from "react"; +import { LoanCard } from "@/components/loans/loan-card"; +import { ProposalCard } from "@/components/governance/proposal-card"; +import { shortenAddress, formatAmount } from "@/lib/stellar"; +import { useWallet } from "@/hooks/use-wallet"; +import type { Group, Member, Contribution, Loan, Proposal } from "@/types"; + +/* ------------------------------------------------------------------ */ +/* Mock data */ +/* ------------------------------------------------------------------ */ + +const MOCK_GROUP: Group = { + id: "mock-1", + name: "Eko Savings Circle", + description: + "A community savings cooperative focused on supporting small business growth in Lagos. Members contribute 50 USDC monthly and can access low-interest loans.", + admin: "GBXUQK2Q3Z7N7XK5Y5V5Q5Z5X5Y5Z5X5Y5Z5X5Y5Z5", + members: [ + { address: "GABC123…DEF1", displayName: "Adaeze Okonkwo", totalContributed: 500, joinedAt: "2025-01-15T10:00:00Z", isActive: true, loanBalance: 0 }, + { address: "GDEF456…GHI2", displayName: "Chidi Okafor", totalContributed: 450, joinedAt: "2025-01-20T10:00:00Z", isActive: true, loanBalance: 200 }, + { address: "GGHI789…JKL3", displayName: "Efe Johnson", totalContributed: 400, joinedAt: "2025-02-05T10:00:00Z", isActive: true, loanBalance: 0 }, + { address: "GJKL012…MNO4", displayName: "Fatima Bello", totalContributed: 350, joinedAt: "2025-02-18T10:00:00Z", isActive: true, loanBalance: 150 }, + { address: "GMNO345…PQR5", displayName: "Gabriel Musa", totalContributed: 300, joinedAt: "2025-03-01T10:00:00Z", isActive: false, loanBalance: 0 }, + ], + totalContributions: 2000, + balance: 1800, + isActive: true, + createdAt: "2025-01-10T08:00:00Z", + rules: { + minContribution: 50, + contributionPeriodDays: 30, + maxLoanMultiplier: 3, + loanInterestBps: 500, + votingQuorum: 3, + votingPeriodDays: 7, + latePenaltyBps: 100, + }, + contractAddresses: { + treasury: "CDLZ6Y5Q5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5", + loan: "CDLZ6Y5Q5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5", + voting: "CDLZ6Y5Q5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5", + governance: "CDLZ6Y5Q5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5", + dividend: "CDLZ6Y5Q5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5Z5X5Y5", + }, +}; + +const MOCK_MEMBERS: Member[] = MOCK_GROUP.members as Member[]; + +const MOCK_CONTRIBUTIONS: Contribution[] = [ + { id: "c1", member: "GABC123…DEF1", amount: 50, period: 1, timestamp: "2025-02-01T10:00:00Z", txHash: "abc123def456" }, + { id: "c2", member: "GDEF456…GHI2", amount: 50, period: 1, timestamp: "2025-02-01T10:05:00Z", txHash: "def789ghi012" }, + { id: "c3", member: "GGHI789…JKL3", amount: 50, period: 1, timestamp: "2025-02-02T09:30:00Z", txHash: "ghi345jkl678" }, + { id: "c4", member: "GABC123…DEF1", amount: 50, period: 2, timestamp: "2025-03-01T10:00:00Z", txHash: "jkl901mno234" }, + { id: "c5", member: "GDEF456…GHI2", amount: 50, period: 2, timestamp: "2025-03-01T10:10:00Z", txHash: "mno567pqr890" }, + { id: "c6", member: "GJKL012…MNO4", amount: 50, period: 2, timestamp: "2025-03-02T11:00:00Z", txHash: "pqr123stu456" }, + { id: "c7", member: "GABC123…DEF1", amount: 100, period: 3, timestamp: "2025-04-01T10:00:00Z", txHash: "stu789vwx012" }, + { id: "c8", member: "GGHI789…JKL3", amount: 50, period: 3, timestamp: "2025-04-01T10:30:00Z", txHash: "vwx345yza678" }, +]; + +const MOCK_LOANS: Loan[] = [ + { id: 1, borrower: "GDEF456…GHI2", amount: 200, interestBps: 500, repaymentDue: "2025-06-01T10:00:00Z", amountRepaid: 50, status: "Approved", purpose: "School fees for children", requestedAt: "2025-03-15T10:00:00Z" }, + { id: 2, borrower: "GJKL012…MNO4", amount: 150, interestBps: 500, repaymentDue: "2025-07-15T10:00:00Z", amountRepaid: 0, status: "Pending", purpose: "Market stall restock", requestedAt: "2025-04-10T10:00:00Z" }, + { id: 3, borrower: "GABC123…DEF1", amount: 300, interestBps: 400, repaymentDue: "2025-05-01T10:00:00Z", amountRepaid: 300, status: "Repaid", purpose: "Emergency medical expense", requestedAt: "2025-02-20T10:00:00Z" }, + { id: 4, borrower: "GMNO345…PQR5", amount: 100, interestBps: 500, repaymentDue: "2025-08-01T10:00:00Z", amountRepaid: 0, status: "Defaulted", purpose: "Business expansion", requestedAt: "2025-04-01T10:00:00Z" }, +]; + +const MOCK_PROPOSALS: Proposal[] = [ + { id: 1, proposer: "GABC123…DEF1", type: "LoanApproval", title: "Approve 200 USDC loan for Chidi", description: "Chidi Okafor has requested a 200 USDC loan for school fees. Interest rate: 5%", votesFor: 3, votesAgainst: 1, quorum: 3, deadline: "2025-05-01T10:00:00Z", status: "Active", createdAt: "2025-04-20T10:00:00Z" }, + { id: 2, proposer: "GGHI789…JKL3", type: "UpdateRule", title: "Increase contribution period to 45 days", description: "Proposal to extend the contribution period from 30 to 45 days to accommodate members with irregular income.", votesFor: 4, votesAgainst: 0, quorum: 3, deadline: "2025-04-15T10:00:00Z", status: "Passed", createdAt: "2025-04-08T10:00:00Z" }, + { id: 3, proposer: "GDEF456…GHI2", type: "TreasurySpend", title: "Allocate 100 USDC for group event", description: "Proposal to spend 100 USDC from treasury for a group networking event.", votesFor: 2, votesAgainst: 2, quorum: 3, deadline: "2025-04-10T10:00:00Z", status: "Failed", createdAt: "2025-04-03T10:00:00Z" }, +]; + +/* ------------------------------------------------------------------ */ +/* Fetchers */ +/* ------------------------------------------------------------------ */ + +// TODO: connect to real API — replace mock data with GET /api/groups/:id +async function fetchGroup(id: string): Promise { + try { + const res = await fetch(`/api/groups/${encodeURIComponent(id)}`); + if (!res.ok) { + if (res.status === 404) throw new Error("NOT_FOUND"); + throw new Error("Failed to fetch group"); + } + const data: unknown = await res.json(); + if (!data || typeof data !== "object") throw new Error("Invalid response"); + return data as Group; + } catch (err) { + if (err instanceof Error && err.message === "NOT_FOUND") throw err; + // TODO: Remove mock fallback once API is available + return MOCK_GROUP; + } +} + +// TODO: connect to real API — replace mock data with GET /api/groups/:id/members +async function fetchMembers(id: string): Promise { + try { + const res = await fetch(`/api/groups/${encodeURIComponent(id)}/members`); + if (!res.ok) throw new Error("Failed to fetch members"); + return (await res.json()) as Member[]; + } catch { + return MOCK_MEMBERS; + } +} + +// TODO: connect to real API — replace mock data with GET /api/groups/:id/contributions +async function fetchContributions(id: string): Promise { + try { + const res = await fetch(`/api/groups/${encodeURIComponent(id)}/contributions`); + if (!res.ok) throw new Error("Failed to fetch contributions"); + return (await res.json()) as Contribution[]; + } catch { + return MOCK_CONTRIBUTIONS; + } +} + +// TODO: connect to real API — replace mock data with GET /api/groups/:id/loans +async function fetchLoans(id: string): Promise { + try { + const res = await fetch(`/api/groups/${encodeURIComponent(id)}/loans`); + if (!res.ok) throw new Error("Failed to fetch loans"); + return (await res.json()) as Loan[]; + } catch { + return MOCK_LOANS; + } +} + +// TODO: connect to real API — replace mock data with GET /api/groups/:id/proposals +async function fetchProposals(id: string): Promise { + try { + const res = await fetch(`/api/groups/${encodeURIComponent(id)}/proposals`); + if (!res.ok) throw new Error("Failed to fetch proposals"); + return (await res.json()) as Proposal[]; + } catch { + return MOCK_PROPOSALS; + } +} + +/* ------------------------------------------------------------------ */ +/* Stellar Expert link helper */ +/* ------------------------------------------------------------------ */ + +const STELLAR_EXPLORER = process.env.NEXT_PUBLIC_STELLAR_NETWORK === "MAINNET" + ? "https://stellar.expert/explorer/public" + : "https://stellar.expert/explorer/testnet"; + +function explorerTxLink(txHash: string): string { + return `${STELLAR_EXPLORER}/tx/${txHash}`; +} + +/* ------------------------------------------------------------------ */ +/* Copy button helper */ +/* ------------------------------------------------------------------ */ + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [text]); + + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Skeleton */ +/* ------------------------------------------------------------------ */ + +function PageSkeleton() { + return ( +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* 404 state */ +/* ------------------------------------------------------------------ */ + +function NotFoundState() { + return ( +
+ +

Group Not Found

+

+ This group doesn't exist or may have been removed. Check the group ID or browse available groups. +

+ + + Browse Groups + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Empty state for tabs */ +/* ------------------------------------------------------------------ */ + +function TabEmptyState({ icon: Icon, message }: { icon: React.ElementType; message: string }) { + return ( +
+ +

{message}

+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Group Detail Page */ +/* ------------------------------------------------------------------ */ + +export default function GroupDetailPage() { + const params = useParams(); + const id = params?.id as string; + const { address } = useWallet(); + + const { + data: group, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["group", id], + queryFn: () => fetchGroup(id), + enabled: !!id, + retry: 1, + }); + + const { data: members = [] } = useQuery({ + queryKey: ["group-members", id], + queryFn: () => fetchMembers(id), + enabled: !!id, + }); + + const { data: contributions = [] } = useQuery({ + queryKey: ["group-contributions", id], + queryFn: () => fetchContributions(id), + enabled: !!id, + }); + + const { data: loans = [] } = useQuery({ + queryKey: ["group-loans", id], + queryFn: () => fetchLoans(id), + enabled: !!id, + }); + + const { data: proposals = [] } = useQuery({ + queryKey: ["group-proposals", id], + queryFn: () => fetchProposals(id), + enabled: !!id, + }); + + /* ---- 404 check ---- */ + if (isError && error instanceof Error && error.message === "NOT_FOUND") { + return ; + } + + if (isLoading || !group) { + return ( +
+
+ +
+
+ ); + } + + const isAdmin = address === group.admin; + const activeLoans = loans.filter((l) => l.status === "Approved" || l.status === "Pending").length; + const totalContributions = group.totalContributions; + + const memberName = (addr: string): string => { + const m = members.find((mem) => mem.address.startsWith(addr.slice(0, 12))); + return m?.displayName ?? shortenAddress(addr); + }; + + return ( +
+
+ {/* ---- Header ---- */} +
+
+
+
+

+ {group.name} +

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

{group.description}

+ +
+ {/* Admin address */} +
+ + Admin: + + {shortenAddress(group.admin, 6)} + + +
+ {/* Created */} +
+ + Created {formatDistanceToNow(new Date(group.createdAt), { addSuffix: true })} +
+ {/* Members count */} +
+ + {members.length} members +
+
+
+ + {/* Add Member button (admin only) */} + {isAdmin && ( + + )} +
+
+ + {/* ---- Stats Row ---- */} +
+
+
+
+ +
+

Treasury Balance

+
+

+ ${formatAmount(group.balance)} +

+ {/* TODO: Replace with live Soroban RPC balance */} +

USDC on Stellar

+
+ +
+
+
+ +
+

Total Members

+
+

{members.length}

+

+ {members.filter((m) => m.isActive).length} active +

+
+ +
+
+
+ +
+

Active Loans

+
+

{activeLoans}

+

+ {loans.length} total loans requested +

+
+ +
+
+
+ +
+

Total Contributions

+
+

+ ${formatAmount(totalContributions)} +

+

Lifetime total

+
+
+ + {/* ---- Tabs ---- */} + + + + + Members + + + + Contributions + + + + Loans + + + + Governance + + + + {/* ---- Members Tab ---- */} + + {members.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {members.map((member) => ( + + + + + + + + + ))} + +
MemberAddressContributedLoan BalanceJoinedStatus
+ {member.displayName || "Unnamed"} + + + {shortenAddress(member.address, 6)} + + + ${formatAmount(member.totalContributed)} + + {member.loanBalance > 0 ? ( + ${formatAmount(member.loanBalance)} + ) : ( + + )} + + {formatDistanceToNow(new Date(member.joinedAt), { addSuffix: true })} + + + + {member.isActive ? "Active" : "Inactive"} + +
+
+ )} +
+ + {/* ---- Contributions Tab ---- */} + + {contributions.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {contributions.map((c) => ( + + + + + + + + ))} + +
MemberAmountPeriodDateTx Hash
+ {memberName(c.member)} + + ${formatAmount(c.amount)} + + #{c.period} + + {formatDistanceToNow(new Date(c.timestamp), { addSuffix: true })} + + + + {c.txHash.slice(0, 8)}... + +
+
+ )} +
+ + {/* ---- Loans Tab ---- */} + + {loans.length === 0 ? ( + + ) : ( +
+ {loans.map((loan) => ( + + ))} +
+ )} +
+ + {/* ---- Governance Tab ---- */} + + {proposals.length === 0 ? ( + + ) : ( +
+ {proposals.map((proposal) => ( + + ))} +
+ )} +
+
+
+
+ ); +} \ No newline at end of file