From 7debc48baae4dceaa419ec25ce68d879ef6cec59 Mon Sep 17 00:00:00 2001 From: nifanpinc Date: Sun, 15 Mar 2026 02:42:25 +0800 Subject: [PATCH 1/2] feat: add member dashboard page - Show all groups the connected user belongs to - Highlight groups needing contribution with amber cards - Display upcoming payout info per group - Show summary stats: total contributed, total received, action needed - Progress bars for active group rounds - Wallet connection prompt for unauthenticated users - Add Dashboard link to Navbar Closes #9 --- src/app/dashboard/page.tsx | 380 +++++++++++++++++++++++++++++++++++++ src/components/Navbar.tsx | 6 + 2 files changed, 386 insertions(+) create mode 100644 src/app/dashboard/page.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..165935d --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,380 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Navbar } from "@/components/Navbar"; +import { useWallet } from "@/app/providers"; +import { SavingsGroup, GroupStatus, formatAmount, getStatusLabel } from "@sorosave/sdk"; + +// Placeholder data — will be replaced with contract queries filtered by connected wallet +const PLACEHOLDER_GROUPS: SavingsGroup[] = [ + { + id: 1, + name: "Lagos Savings Circle", + admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contributionAmount: 1000000000n, + cycleLength: 604800, + maxMembers: 5, + members: ["GABCD...", "GEFGH...", "GIJKL..."], + payoutOrder: [], + currentRound: 0, + totalRounds: 0, + status: GroupStatus.Forming, + createdAt: 1700000000, + }, + { + id: 2, + name: "DeFi Builders Fund", + admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contributionAmount: 5000000000n, + cycleLength: 2592000, + maxMembers: 10, + members: ["GABCD...", "GEFGH...", "GIJKL...", "GMNOP...", "GQRST..."], + payoutOrder: ["GABCD...", "GEFGH...", "GIJKL...", "GMNOP...", "GQRST..."], + currentRound: 2, + totalRounds: 5, + status: GroupStatus.Active, + createdAt: 1699000000, + }, + { + id: 3, + name: "Community Savers", + admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contributionAmount: 2000000000n, + cycleLength: 1209600, + maxMembers: 8, + members: ["GABCD...", "GEFGH...", "GIJKL...", "GMNOP...", "GQRST...", "GUVWX...", "GYZA1...", "GBC23..."], + payoutOrder: ["GABCD...", "GEFGH...", "GIJKL...", "GMNOP...", "GQRST...", "GUVWX...", "GYZA1...", "GBC23..."], + currentRound: 6, + totalRounds: 8, + status: GroupStatus.Active, + createdAt: 1695000000, + }, +]; + +const statusColors: Record = { + Forming: "bg-blue-100 text-blue-800", + Active: "bg-green-100 text-green-800", + Completed: "bg-gray-100 text-gray-800", + Disputed: "bg-red-100 text-red-800", + Paused: "bg-yellow-100 text-yellow-800", +}; + +function formatCycleLength(seconds: number): string { + if (seconds >= 2592000) return `${Math.round(seconds / 2592000)} month(s)`; + if (seconds >= 604800) return `${Math.round(seconds / 604800)} week(s)`; + if (seconds >= 86400) return `${Math.round(seconds / 86400)} day(s)`; + return `${seconds} seconds`; +} + +function needsContribution(group: SavingsGroup): boolean { + return group.status === GroupStatus.Active && group.currentRound < group.totalRounds; +} + +function getNextPayoutInfo(group: SavingsGroup): string | null { + if (group.status !== GroupStatus.Active || group.payoutOrder.length === 0) return null; + const nextRecipientIndex = group.currentRound; + if (nextRecipientIndex >= group.payoutOrder.length) return null; + const recipient = group.payoutOrder[nextRecipientIndex]; + const truncated = `${recipient.slice(0, 6)}...${recipient.slice(-4)}`; + return `Round ${group.currentRound + 1}: ${truncated}`; +} + +export default function DashboardPage() { + const { address, isConnected } = useWallet(); + const [groups, setGroups] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // TODO: Replace with actual contract query filtered by connected wallet address + // e.g., sorosaveClient.getGroupsByMember(address) + const timer = setTimeout(() => { + setGroups(PLACEHOLDER_GROUPS); + setIsLoading(false); + }, 500); + return () => clearTimeout(timer); + }, [address]); + + // Calculate summary stats + const totalContributed = groups.reduce((sum, g) => { + if (g.status === GroupStatus.Active || g.status === GroupStatus.Completed) { + return sum + g.contributionAmount * BigInt(Math.min(g.currentRound, g.totalRounds)); + } + return sum; + }, 0n); + + const totalReceived = groups.reduce((sum, g) => { + // Simplified: assume user received payout once if currentRound > their position + if (g.status === GroupStatus.Active || g.status === GroupStatus.Completed) { + const potAmount = g.contributionAmount * BigInt(g.members.length); + // Placeholder: assume received 1 payout per completed group + if (g.status === GroupStatus.Completed) return sum + potAmount; + } + return sum; + }, 0n); + + const groupsNeedingAction = groups.filter(needsContribution); + const activeGroups = groups.filter((g) => g.status === GroupStatus.Active); + const formingGroups = groups.filter((g) => g.status === GroupStatus.Forming); + + if (!isConnected) { + return ( + <> + +
+
+
+ + + +
+

+ Connect Your Wallet +

+

+ Connect your Freighter wallet to view your savings groups, + contributions, and upcoming payouts. +

+
+
+ + ); + } + + return ( + <> + +
+ {/* Header */} +
+
+

My Dashboard

+

+ {address ? `${address.slice(0, 8)}...${address.slice(-6)}` : ""} +

+
+ + Browse Groups + +
+ + {/* Summary Cards */} +
+
+

My Groups

+

{groups.length}

+

+ {activeGroups.length} active · {formingGroups.length} forming +

+
+ +
+

Total Contributed

+

+ {formatAmount(totalContributed)} +

+

tokens

+
+ +
+

Total Received

+

+ {formatAmount(totalReceived)} +

+

tokens

+
+ +
+

Needs Contribution

+

+ {groupsNeedingAction.length} +

+

+ {groupsNeedingAction.length > 0 ? "action required" : "all caught up"} +

+
+
+ + {isLoading ? ( +
+
+

Loading your groups...

+
+ ) : groups.length === 0 ? ( +
+ + + +

+ No groups yet +

+

+ Join an existing savings group or create your own to get started. +

+
+ + Browse Groups + + + Create Group + +
+
+ ) : ( + <> + {/* Groups Needing Contribution */} + {groupsNeedingAction.length > 0 && ( +
+

+ + Needs Your Contribution +

+
+ {groupsNeedingAction.map((group) => ( + +
+
+

+ {group.name} +

+ + Action Required + +
+
+
+ Contribution + + {formatAmount(group.contributionAmount)} tokens + +
+
+ Round + + {group.currentRound} / {group.totalRounds} + +
+
+ Cycle + + {formatCycleLength(group.cycleLength)} + +
+
+
+ + ))} +
+
+ )} + + {/* All Groups */} +
+

+ All My Groups +

+
+ {groups.map((group) => { + const payoutInfo = getNextPayoutInfo(group); + return ( + +
+
+

+ {group.name} +

+ + {getStatusLabel(group.status)} + +
+ +
+
+ Contribution + + {formatAmount(group.contributionAmount)} tokens + +
+
+ Members + + {group.members.length} / {group.maxMembers} + +
+ {group.totalRounds > 0 && ( +
+ Progress + + {group.currentRound} / {group.totalRounds} rounds + +
+ )} + {group.totalRounds > 0 && ( +
+
+
+
+
+ )} + {payoutInfo && ( +
+ Next Payout + + {payoutInfo} + +
+ )} +
+
+ + ); + })} +
+
+ + )} +
+ + ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 2d673aa..0ef1764 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -13,6 +13,12 @@ export function Navbar() { SoroSave
+ + Dashboard + Date: Sun, 15 Mar 2026 02:55:07 +0800 Subject: [PATCH 2/2] refactor: simplify dashboard - reuse GroupCard, extract StatCard, remove redundancy (380->188 lines) --- src/app/dashboard/page.tsx | 294 +++++++------------------------------ 1 file changed, 51 insertions(+), 243 deletions(-) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 165935d..a103694 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -3,10 +3,11 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { Navbar } from "@/components/Navbar"; +import { GroupCard } from "@/components/GroupCard"; import { useWallet } from "@/app/providers"; -import { SavingsGroup, GroupStatus, formatAmount, getStatusLabel } from "@sorosave/sdk"; +import { SavingsGroup, GroupStatus, formatAmount } from "@sorosave/sdk"; -// Placeholder data — will be replaced with contract queries filtered by connected wallet +// Placeholder data - will be replaced with contract queries filtered by connected wallet const PLACEHOLDER_GROUPS: SavingsGroup[] = [ { id: 1, @@ -38,49 +39,22 @@ const PLACEHOLDER_GROUPS: SavingsGroup[] = [ status: GroupStatus.Active, createdAt: 1699000000, }, - { - id: 3, - name: "Community Savers", - admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", - token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", - contributionAmount: 2000000000n, - cycleLength: 1209600, - maxMembers: 8, - members: ["GABCD...", "GEFGH...", "GIJKL...", "GMNOP...", "GQRST...", "GUVWX...", "GYZA1...", "GBC23..."], - payoutOrder: ["GABCD...", "GEFGH...", "GIJKL...", "GMNOP...", "GQRST...", "GUVWX...", "GYZA1...", "GBC23..."], - currentRound: 6, - totalRounds: 8, - status: GroupStatus.Active, - createdAt: 1695000000, - }, ]; -const statusColors: Record = { - Forming: "bg-blue-100 text-blue-800", - Active: "bg-green-100 text-green-800", - Completed: "bg-gray-100 text-gray-800", - Disputed: "bg-red-100 text-red-800", - Paused: "bg-yellow-100 text-yellow-800", -}; - -function formatCycleLength(seconds: number): string { - if (seconds >= 2592000) return `${Math.round(seconds / 2592000)} month(s)`; - if (seconds >= 604800) return `${Math.round(seconds / 604800)} week(s)`; - if (seconds >= 86400) return `${Math.round(seconds / 86400)} day(s)`; - return `${seconds} seconds`; -} - function needsContribution(group: SavingsGroup): boolean { return group.status === GroupStatus.Active && group.currentRound < group.totalRounds; } -function getNextPayoutInfo(group: SavingsGroup): string | null { - if (group.status !== GroupStatus.Active || group.payoutOrder.length === 0) return null; - const nextRecipientIndex = group.currentRound; - if (nextRecipientIndex >= group.payoutOrder.length) return null; - const recipient = group.payoutOrder[nextRecipientIndex]; - const truncated = `${recipient.slice(0, 6)}...${recipient.slice(-4)}`; - return `Round ${group.currentRound + 1}: ${truncated}`; +function StatCard({ label, value, sub, color = "text-gray-900" }: { + label: string; value: string; sub?: string; color?: string; +}) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); } export default function DashboardPage() { @@ -89,8 +63,7 @@ export default function DashboardPage() { const [isLoading, setIsLoading] = useState(true); useEffect(() => { - // TODO: Replace with actual contract query filtered by connected wallet address - // e.g., sorosaveClient.getGroupsByMember(address) + // TODO: Replace with sorosaveClient.getGroupsByMember(address) const timer = setTimeout(() => { setGroups(PLACEHOLDER_GROUPS); setIsLoading(false); @@ -98,55 +71,30 @@ export default function DashboardPage() { return () => clearTimeout(timer); }, [address]); - // Calculate summary stats + const activeGroups = groups.filter((g) => g.status === GroupStatus.Active); + const actionGroups = groups.filter(needsContribution); + const totalContributed = groups.reduce((sum, g) => { - if (g.status === GroupStatus.Active || g.status === GroupStatus.Completed) { - return sum + g.contributionAmount * BigInt(Math.min(g.currentRound, g.totalRounds)); - } - return sum; + const rounds = BigInt(Math.min(g.currentRound, g.totalRounds)); + return sum + g.contributionAmount * rounds; }, 0n); const totalReceived = groups.reduce((sum, g) => { - // Simplified: assume user received payout once if currentRound > their position - if (g.status === GroupStatus.Active || g.status === GroupStatus.Completed) { - const potAmount = g.contributionAmount * BigInt(g.members.length); - // Placeholder: assume received 1 payout per completed group - if (g.status === GroupStatus.Completed) return sum + potAmount; - } - return sum; + if (g.payoutOrder.length === 0 || g.currentRound === 0) return sum; + // User receives pot once when it's their turn; simplified as 1 payout per active/completed group + const pot = g.contributionAmount * BigInt(g.members.length); + return g.currentRound > 0 ? sum + pot : sum; }, 0n); - const groupsNeedingAction = groups.filter(needsContribution); - const activeGroups = groups.filter((g) => g.status === GroupStatus.Active); - const formingGroups = groups.filter((g) => g.status === GroupStatus.Forming); - if (!isConnected) { return ( <>
-
- - - -
-

- Connect Your Wallet -

-

- Connect your Freighter wallet to view your savings groups, - contributions, and upcoming payouts. +

Connect Your Wallet

+

+ Connect your Freighter wallet to view your savings groups, contributions, and upcoming payouts.

@@ -158,7 +106,6 @@ export default function DashboardPage() { <>
- {/* Header */}

My Dashboard

@@ -174,41 +121,17 @@ export default function DashboardPage() {
- {/* Summary Cards */} + {/* Summary */}
-
-

My Groups

-

{groups.length}

-

- {activeGroups.length} active · {formingGroups.length} forming -

-
- -
-

Total Contributed

-

- {formatAmount(totalContributed)} -

-

tokens

-
- -
-

Total Received

-

- {formatAmount(totalReceived)} -

-

tokens

-
- -
-

Needs Contribution

-

- {groupsNeedingAction.length} -

-

- {groupsNeedingAction.length > 0 ? "action required" : "all caught up"} -

-
+ + + + 0 ? "action required" : "all caught up"} + color="text-amber-600" />
{isLoading ? ( @@ -218,158 +141,43 @@ export default function DashboardPage() {
) : groups.length === 0 ? (
- - - -

- No groups yet -

-

- Join an existing savings group or create your own to get started. -

+

No groups yet

+

Join or create a savings group to get started.

- + Browse Groups - + Create Group
) : ( <> - {/* Groups Needing Contribution */} - {groupsNeedingAction.length > 0 && ( + {/* Groups needing contribution */} + {actionGroups.length > 0 && (

Needs Your Contribution

- {groupsNeedingAction.map((group) => ( - -
-
-

- {group.name} -

- - Action Required - -
-
-
- Contribution - - {formatAmount(group.contributionAmount)} tokens - -
-
- Round - - {group.currentRound} / {group.totalRounds} - -
-
- Cycle - - {formatCycleLength(group.cycleLength)} - -
-
-
- + {actionGroups.map((g) => ( +
+ +
))}
)} - {/* All Groups */} + {/* All groups */}
-

- All My Groups -

+

All My Groups

- {groups.map((group) => { - const payoutInfo = getNextPayoutInfo(group); - return ( - -
-
-

- {group.name} -

- - {getStatusLabel(group.status)} - -
- -
-
- Contribution - - {formatAmount(group.contributionAmount)} tokens - -
-
- Members - - {group.members.length} / {group.maxMembers} - -
- {group.totalRounds > 0 && ( -
- Progress - - {group.currentRound} / {group.totalRounds} rounds - -
- )} - {group.totalRounds > 0 && ( -
-
-
-
-
- )} - {payoutInfo && ( -
- Next Payout - - {payoutInfo} - -
- )} -
-
- - ); - })} + {groups.map((g) => ( + + ))}