diff --git a/backend/src/modules/governance/dto/proposal-votes-response.dto.ts b/backend/src/modules/governance/dto/proposal-votes-response.dto.ts new file mode 100644 index 000000000..196509cd1 --- /dev/null +++ b/backend/src/modules/governance/dto/proposal-votes-response.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { VoteDirection } from '../entities/vote.entity'; + +class ProposalVoteTallyDto { + @ApiProperty({ example: 128 }) + forVotes: number; + + @ApiProperty({ example: 64 }) + againstVotes: number; + + @ApiProperty({ example: '84250.5' }) + forWeight: string; + + @ApiProperty({ example: '29300' }) + againstWeight: string; + + @ApiProperty({ example: '113550.5' }) + totalWeight: string; +} + +class RecentVoterDto { + @ApiProperty({ + example: 'GB7TAYQB6A6E7MCCKRUYJ4JYK2YTHJOTD4A5Q65XAH2EJQ2F6J67P5ST', + }) + walletAddress: string; + + @ApiProperty({ enum: VoteDirection, enumName: 'VoteDirection' }) + direction: VoteDirection; + + @ApiProperty({ example: '5000' }) + weight: string; + + @ApiProperty({ example: '2026-03-26T13:01:15.518Z' }) + votedAt: string; +} + +export class ProposalVotesResponseDto { + @ApiProperty({ example: 12 }) + proposalOnChainId: number; + + @ApiProperty({ type: ProposalVoteTallyDto }) + tally: ProposalVoteTallyDto; + + @ApiProperty({ type: [RecentVoterDto] }) + recentVoters: RecentVoterDto[]; +} diff --git a/backend/src/modules/governance/governance-proposals.controller.ts b/backend/src/modules/governance/governance-proposals.controller.ts new file mode 100644 index 000000000..4bf657dc5 --- /dev/null +++ b/backend/src/modules/governance/governance-proposals.controller.ts @@ -0,0 +1,41 @@ +import { + Controller, + DefaultValuePipe, + Get, + Param, + ParseIntPipe, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ProposalVotesResponseDto } from './dto/proposal-votes-response.dto'; +import { GovernanceService } from './governance.service'; + +@ApiTags('governance') +@Controller('governance/proposals') +export class GovernanceProposalsController { + constructor(private readonly governanceService: GovernanceService) {} + + @Get(':id/votes') + @ApiOperation({ + summary: 'Get proposal vote tally and recent voters', + description: + 'Returns a proposal vote tally plus the most recent voters for a given proposal onChainId.', + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Maximum number of recent voter entries to return', + example: 20, + }) + @ApiResponse({ + status: 200, + description: 'Vote tally and recent voter list for proposal', + type: ProposalVotesResponseDto, + }) + getProposalVotes( + @Param('id', ParseIntPipe) id: number, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + ): Promise { + return this.governanceService.getProposalVotesByOnChainId(id, limit); + } +} diff --git a/backend/src/modules/governance/governance.module.ts b/backend/src/modules/governance/governance.module.ts index 1439ab428..983e7bcdc 100644 --- a/backend/src/modules/governance/governance.module.ts +++ b/backend/src/modules/governance/governance.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { GovernanceController } from './governance.controller'; +import { GovernanceProposalsController } from './governance-proposals.controller'; import { GovernanceService } from './governance.service'; import { GovernanceIndexerService } from './governance-indexer.service'; import { UserModule } from '../user/user.module'; @@ -14,7 +15,7 @@ import { Vote } from './entities/vote.entity'; BlockchainModule, TypeOrmModule.forFeature([GovernanceProposal, Vote]), ], - controllers: [GovernanceController], + controllers: [GovernanceController, GovernanceProposalsController], providers: [GovernanceService, GovernanceIndexerService], }) export class GovernanceModule {} diff --git a/backend/src/modules/governance/governance.service.spec.ts b/backend/src/modules/governance/governance.service.spec.ts index 6d3443575..f5738f0d8 100644 --- a/backend/src/modules/governance/governance.service.spec.ts +++ b/backend/src/modules/governance/governance.service.spec.ts @@ -1,26 +1,38 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { GovernanceService } from './governance.service'; import { UserService } from '../user/user.service'; import { StellarService } from '../blockchain/stellar.service'; import { SavingsService } from '../blockchain/savings.service'; +import { GovernanceProposal } from './entities/governance-proposal.entity'; +import { Vote } from './entities/vote.entity'; describe('GovernanceService', () => { let service: GovernanceService; let userService: { findById: jest.Mock }; let stellarService: { getDelegationForUser: jest.Mock }; let savingsService: { getUserVaultBalance: jest.Mock }; + let proposalRepo: { findOneBy: jest.Mock }; + let voteRepo: { find: jest.Mock }; beforeEach(async () => { userService = { findById: jest.fn() }; stellarService = { getDelegationForUser: jest.fn() }; savingsService = { getUserVaultBalance: jest.fn() }; + proposalRepo = { findOneBy: jest.fn() }; + voteRepo = { find: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ providers: [ GovernanceService, { provide: UserService, useValue: userService }, { provide: StellarService, useValue: stellarService }, - { provide: SavingsService, useValue: savingsService }, // 👈 this was missing + { provide: SavingsService, useValue: savingsService }, + { + provide: getRepositoryToken(GovernanceProposal), + useValue: proposalRepo, + }, + { provide: getRepositoryToken(Vote), useValue: voteRepo }, ], }).compile(); diff --git a/backend/src/modules/governance/governance.service.ts b/backend/src/modules/governance/governance.service.ts index 3269c1aa8..7ef6622f8 100644 --- a/backend/src/modules/governance/governance.service.ts +++ b/backend/src/modules/governance/governance.service.ts @@ -1,8 +1,13 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { StellarService } from '../blockchain/stellar.service'; import { SavingsService } from '../blockchain/savings.service'; import { UserService } from '../user/user.service'; import { DelegationResponseDto } from './dto/delegation-response.dto'; +import { ProposalVotesResponseDto } from './dto/proposal-votes-response.dto'; +import { GovernanceProposal } from './entities/governance-proposal.entity'; +import { Vote, VoteDirection } from './entities/vote.entity'; import { VotingPowerResponseDto } from './dto/voting-power-response.dto'; @Injectable() @@ -11,6 +16,10 @@ export class GovernanceService { private readonly userService: UserService, private readonly stellarService: StellarService, private readonly savingsService: SavingsService, + @InjectRepository(GovernanceProposal) + private readonly proposalRepo: Repository, + @InjectRepository(Vote) + private readonly voteRepo: Repository, ) {} async getUserDelegation(userId: string): Promise { @@ -46,4 +55,52 @@ export class GovernanceService { }); return { votingPower: `${votingPower} NST` }; } + + async getProposalVotesByOnChainId( + onChainId: number, + limit = 20, + ): Promise { + const proposal = await this.proposalRepo.findOneBy({ onChainId }); + if (!proposal) { + throw new NotFoundException(`Proposal ${onChainId} not found`); + } + + const safeLimit = Math.max(1, Math.min(100, Math.floor(limit))); + const votes = await this.voteRepo.find({ + where: { proposalId: proposal.id }, + order: { createdAt: 'DESC' }, + take: safeLimit, + }); + + let forWeight = 0; + let againstWeight = 0; + for (const vote of votes) { + const voteWeight = Number(vote.weight) || 0; + if (vote.direction === VoteDirection.FOR) { + forWeight += voteWeight; + } else { + againstWeight += voteWeight; + } + } + + return { + proposalOnChainId: onChainId, + tally: { + forVotes: votes.filter((vote) => vote.direction === VoteDirection.FOR) + .length, + againstVotes: votes.filter( + (vote) => vote.direction === VoteDirection.AGAINST, + ).length, + forWeight: String(forWeight), + againstWeight: String(againstWeight), + totalWeight: String(forWeight + againstWeight), + }, + recentVoters: votes.map((vote) => ({ + walletAddress: vote.walletAddress, + direction: vote.direction, + weight: String(vote.weight), + votedAt: vote.createdAt.toISOString(), + })), + }; + } } diff --git a/frontend/app/components/dashboard/ProposalCard.tsx b/frontend/app/components/dashboard/ProposalCard.tsx index 865c2710d..068f4ae0c 100644 --- a/frontend/app/components/dashboard/ProposalCard.tsx +++ b/frontend/app/components/dashboard/ProposalCard.tsx @@ -23,6 +23,17 @@ export default function ProposalCard({ }: ProposalCardProps) { const safeFor = Math.max(0, Math.min(100, Math.round(forPercent))); const safeAgainst = Math.max(0, Math.min(100, Math.round(againstPercent))); + const normalizedStatus = status.toUpperCase(); + const isActive = normalizedStatus === "ACTIVE"; + const isRejected = + normalizedStatus === "REJECTED" || normalizedStatus === "FAILED"; + const statusPillClass = isRejected + ? "bg-red-500/15 text-red-300 border-red-400/40" + : "bg-[#10314F] border-2 border-[#215091] text-[#60A5FA]"; + const mobilePillClass = isRejected + ? "bg-red-500/15 text-red-300" + : "bg-[#08333a] text-sky-200"; + const ctaLabel = isActive ? "Vote Now" : "View details"; return (
@@ -30,7 +41,9 @@ export default function ProposalCard({ {/* Top row on mobile: id at left, status at right (status shown only on mobile here) */}
{id} - + {status}
@@ -95,7 +108,7 @@ export default function ProposalCard({ {/* Right column for desktop: status + button. Hidden on mobile. */}
- + {status}
@@ -105,7 +118,7 @@ export default function ProposalCard({ onClick={onVote} className="px-5 py-3 rounded-xl bg-linear-to-r from-sky-400 to-cyan-300 text-[#042024] font-semibold shadow-md hover:brightness-110" > - Vote Now + {ctaLabel}
@@ -116,7 +129,7 @@ export default function ProposalCard({ onClick={onVote} className="w-full px-5 py-3 rounded-xl bg-linear-to-r from-sky-400 to-cyan-300 text-[#042024] font-semibold shadow-md hover:brightness-110" > - Vote Now + {ctaLabel} diff --git a/frontend/app/dashboard/analytics/page.tsx b/frontend/app/dashboard/analytics/page.tsx index a0992c9ba..5dd07861f 100644 --- a/frontend/app/dashboard/analytics/page.tsx +++ b/frontend/app/dashboard/analytics/page.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { PieChart } from "lucide-react"; +import { MoreHorizontal, PieChart } from "lucide-react"; export const metadata = { title: "Analytics – Nestera" }; @@ -23,6 +23,87 @@ export default function AnalyticsPage() { Analytics charts and data will appear here.

+ +
+
+
+

Asset Allocation

+ +
+ +
+
+
+ + Assets + + 3 +
+
+
+ +
+ {[ + { asset: "USDC", percent: 40, color: "bg-cyan-400" }, + { asset: "XLM", percent: 35, color: "bg-blue-400" }, + { asset: "ETH", percent: 25, color: "bg-violet-400" }, + ].map((item) => ( +
+
+ + {item.asset} +
+ {item.percent}% +
+ ))} +
+
+ +
+
+

Yield Breakdown

+ + APY High + +
+

+ Earnings from your active pools +

+ +
+

+ Total Interest Earned +

+

$743.20

+
+ +
+ {[ + { label: "XLM Staking", amount: "+$420.50", progress: 57 }, + { label: "USDC Flexible", amount: "+$322.70", progress: 43 }, + ].map((pool) => ( +
+
+ {pool.label} + {pool.amount} +
+
+
+
+
+ ))} +
+
+
); } diff --git a/frontend/app/dashboard/governance/GovernanceClient.tsx b/frontend/app/dashboard/governance/GovernanceClient.tsx new file mode 100644 index 000000000..f1ef24a9a --- /dev/null +++ b/frontend/app/dashboard/governance/GovernanceClient.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState } from "react"; +import { ShieldCheck, Users, Vote, FileText, XCircle } from "lucide-react"; +import PassedProposalCard, { + type PassedProposal, +} from "@/app/components/dashboard/PassedProposalCard"; +import ProposalCard from "@/app/components/dashboard/ProposalCard"; + +export default function GovernanceClient() { + const [activeTab, setActiveTab] = useState("Overview"); + const tabs = [ + { label: "Overview", icon: FileText }, + { label: "Active Votes", icon: Vote }, + { label: "Rejected", icon: XCircle }, + { label: "Delegations", icon: Users }, + ]; + + const passedProposals: PassedProposal[] = [ + { + id: "p-001", + title: "Reduce protocol fees for small deposits", + category: "Parameters", + passedOn: "Mar 18, 2026", + forVotes: 1824, + againstVotes: 312, + }, + { + id: "p-002", + title: "Add USDT (testnet) as a supported stablecoin", + category: "Assets", + passedOn: "Feb 27, 2026", + forVotes: 1490, + againstVotes: 410, + }, + { + id: "p-003", + title: "Increase timelock delay to 24 hours", + category: "Security", + passedOn: "Jan 30, 2026", + forVotes: 2055, + againstVotes: 155, + }, + ]; + const activeProposals = [ + { + id: "NIP-4", + title: "Increase USDC Base Yield to 14%", + categories: ["Finance"], + countdownText: "Ends in 2 days", + forPercent: 75, + againstPercent: 25, + status: "ACTIVE", + }, + { + id: "NIP-12", + title: "Add new ecosystem grants program", + categories: ["Ecosystem", "Finance"], + countdownText: "Ends in 6 days", + forPercent: 52, + againstPercent: 48, + status: "ACTIVE", + }, + ]; + const rejectedProposals = [ + { + id: "NIP-1", + title: "Increase Treasury Risk Exposure", + categories: ["Treasury"], + countdownText: "Ended Mar 10, 2026", + forPercent: 34, + againstPercent: 66, + status: "REJECTED", + }, + { + id: "NIP-6", + title: "Remove XLM Staking Incentives", + categories: ["Staking"], + countdownText: "Ended Feb 19, 2026", + forPercent: 41, + againstPercent: 59, + status: "REJECTED", + }, + ]; + + return ( +
+
+
+
+ +
+
+

Governance

+

+ Vote on proposals and protocol decisions +

+
+
+ +
+
+

+ Voting Power +

+ + Connected + +
+

+ 12,480 +

+

+ NSTR delegated to your wallet +

+
+
+

Available

+

9,120

+
+
+

Locked

+

3,360

+
+
+
+
+ +
+
+ {tabs.map((tab) => { + const TabIcon = tab.icon; + return ( + + ); + })} +
+
+ +
+ {activeTab === "Overview" && ( + <> +
+ +
+
+

Passed Proposals

+ + View all + +
+ + )} + + {(activeTab === "Overview" || activeTab === "Active Votes") && ( +
+
+

Active Proposals

+ + View all + +
+ +
+ {activeProposals.map((proposal) => ( + + ))} +
+
+ )} + + {activeTab === "Rejected" && ( +
+
+

Rejected Proposals

+ + View all + +
+ +
+ {rejectedProposals.map((proposal) => ( + + ))} +
+
+ )} + + {activeTab === "Overview" && ( +
+ {passedProposals.map((proposal) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/dashboard/governance/page.tsx b/frontend/app/dashboard/governance/page.tsx index 1e3e8e94a..8ca5cbebc 100644 --- a/frontend/app/dashboard/governance/page.tsx +++ b/frontend/app/dashboard/governance/page.tsx @@ -1,166 +1,8 @@ -import { ShieldCheck, Users, Vote, FileText } from "lucide-react"; -import PassedProposalCard, { - type PassedProposal, -} from "@/app/components/dashboard/PassedProposalCard"; -import ProposalCard from "@/app/components/dashboard/ProposalCard"; +import React from "react"; +import GovernanceClient from "./GovernanceClient"; export const metadata = { title: "Governance – Nestera" }; export default function GovernancePage() { - const tabs = [ - { label: "Overview", icon: FileText, active: true }, - { label: "Active Votes", icon: Vote, active: false }, - { label: "Delegations", icon: Users, active: false }, - ]; - - const passedProposals: PassedProposal[] = [ - { - id: "p-001", - title: "Reduce protocol fees for small deposits", - category: "Parameters", - passedOn: "Mar 18, 2026", - forVotes: 1824, - againstVotes: 312, - }, - { - id: "p-002", - title: "Add USDT (testnet) as a supported stablecoin", - category: "Assets", - passedOn: "Feb 27, 2026", - forVotes: 1490, - againstVotes: 410, - }, - { - id: "p-003", - title: "Increase timelock delay to 24 hours", - category: "Security", - passedOn: "Jan 30, 2026", - forVotes: 2055, - againstVotes: 155, - }, - ]; - - return ( -
-
-
-
- -
-
-

Governance

-

- Vote on proposals and protocol decisions -

-
-
- -
-
-

- Voting Power -

- - Connected - -
-

- 12,480 -

-

- NSTR delegated to your wallet -

-
-
-

Available

-

9,120

-
-
-

Locked

-

3,360

-
-
-
-
- -
-
- {tabs.map((tab) => { - const TabIcon = tab.icon; - return ( - - ); - })} -
-
- -
-
- -
-
-

Passed Proposals

- - View all - -
- - {/* Active proposals list (uses ProposalCard) */} -
-
-

Active Proposals

- - View all - -
- -
- {/* Example active proposals - replace with API data mapping in real app */} - - - -
-
- -
- {passedProposals.map((proposal) => ( - - ))} -
-
-
- ); + return ; } diff --git a/frontend/app/savings/page.tsx b/frontend/app/savings/page.tsx index 86f3c8a23..90b145e40 100644 --- a/frontend/app/savings/page.tsx +++ b/frontend/app/savings/page.tsx @@ -4,6 +4,9 @@ import React from "react"; import Link from "next/link"; import { LayoutGrid, + List, + Search, + ChevronDown, CheckCircle2, Trophy, Banknote, @@ -18,6 +21,89 @@ import GoalCard, { GoalStatus } from "./components/GoalCard"; // export const metadata = { title: "Goal-Based Savings - Nestera" }; export default function GoalBasedSavingsPage() { + const [searchQuery, setSearchQuery] = React.useState(""); + const [statusFilter, setStatusFilter] = React.useState("All"); + const [sortBy, setSortBy] = React.useState("Progress"); + const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid"); + const goals = [ + { + id: "1", + icon: , + title: "Emergency Fund", + status: "active" as GoalStatus, + targetAmount: "$12,000", + currentSaved: "$6,400", + remainingAmount: "$5,600", + progressPercent: 53, + scheduleLabel: "By Dec 20, 2026", + contributionFrequency: "Weekly", + nextContributionLabel: "Next contribution", + nextContributionValue: "$150 on Jun 28", + }, + { + id: "2", + icon: , + title: "Down Payment", + status: "near-deadline" as GoalStatus, + targetAmount: "$40,000", + currentSaved: "$28,000", + remainingAmount: "$12,000", + progressPercent: 70, + scheduleLabel: "Due Oct 03, 2026", + contributionFrequency: "Monthly", + nextContributionLabel: "Next contribution", + nextContributionValue: "$1,000 on Jul 01", + }, + { + id: "3", + icon: , + title: "Summer Trip", + status: "behind-schedule" as GoalStatus, + targetAmount: "$8,000", + currentSaved: "$3,100", + remainingAmount: "$4,900", + progressPercent: 39, + scheduleLabel: "By Aug 15, 2026", + contributionFrequency: "Every other week", + nextContributionLabel: "Next contribution", + nextContributionValue: "$250 on Jul 05", + }, + { + id: "4", + icon: , + title: "New Laptop", + status: "paused" as GoalStatus, + targetAmount: "$2,500", + currentSaved: "$1,500", + remainingAmount: "$1,000", + progressPercent: 60, + scheduleLabel: "Paused until decision", + contributionFrequency: "Paused", + nextContributionLabel: "Next contribution", + nextContributionValue: "N/A", + }, + ]; + const featuredGoal = goals[1]; + const filteredGoals = React.useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + let filtered = goals.filter((goal) => { + const matchesSearch = + !query || + goal.title.toLowerCase().includes(query) || + goal.contributionFrequency.toLowerCase().includes(query); + const matchesStatus = statusFilter === "All" || goal.status === statusFilter; + return matchesSearch && matchesStatus; + }); + + filtered = filtered.sort((a, b) => + sortBy === "Target" + ? parseInt(b.targetAmount.replace(/[$,]/g, ""), 10) - + parseInt(a.targetAmount.replace(/[$,]/g, ""), 10) + : b.progressPercent - a.progressPercent, + ); + return filtered; + }, [goals, searchQuery, sortBy, statusFilter]); + return (
{/* Header Band */} @@ -101,67 +187,102 @@ export default function GoalBasedSavingsPage() { {/* Goal cards grid */}
+
+
+

Featured Goal

+ + Focus + +
+ console.log("Add funds", featuredGoal.id)} + onViewDetails={() => console.log("View details", featuredGoal.id)} + onOverflowAction={() => console.log("More actions", featuredGoal.id)} + /> +
+ +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search goals..." + className="w-full bg-[#0e2330] border border-white/5 rounded-xl py-3 pl-12 pr-4 text-white placeholder:text-[#4e7a86] focus:outline-hidden focus:border-cyan-500/50 transition-colors" + /> +
+
+ + +
+ + +
+
+
+

Your Savings Goals

-
- {[ - { - id: '1', - icon: , - title: 'Emergency Fund', - status: 'active' as GoalStatus, - targetAmount: '$12,000', - currentSaved: '$6,400', - remainingAmount: '$5,600', - progressPercent: 53, - scheduleLabel: 'By Dec 20, 2026', - contributionFrequency: 'Weekly', - nextContributionLabel: 'Next contribution', - nextContributionValue: '$150 on Jun 28', - }, - { - id: '2', - icon: , - title: 'Down Payment', - status: 'near-deadline' as GoalStatus, - targetAmount: '$40,000', - currentSaved: '$28,000', - remainingAmount: '$12,000', - progressPercent: 70, - scheduleLabel: 'Due Oct 03, 2026', - contributionFrequency: 'Monthly', - nextContributionLabel: 'Next contribution', - nextContributionValue: '$1,000 on Jul 01', - }, - { - id: '3', - icon: , - title: 'Summer Trip', - status: 'behind-schedule' as GoalStatus, - targetAmount: '$8,000', - currentSaved: '$3,100', - remainingAmount: '$4,900', - progressPercent: 39, - scheduleLabel: 'By Aug 15, 2026', - contributionFrequency: 'Every other week', - nextContributionLabel: 'Next contribution', - nextContributionValue: '$250 on Jul 05', - }, - { - id: '4', - icon: , - title: 'New Laptop', - status: 'paused' as GoalStatus, - targetAmount: '$2,500', - currentSaved: '$1,500', - remainingAmount: '$1,000', - progressPercent: 60, - scheduleLabel: 'Paused until decision', - contributionFrequency: 'Paused', - nextContributionLabel: 'Next contribution', - nextContributionValue: 'N/A', - }, - ].map((goal) => ( +
+ {filteredGoals.map((goal) => ( console.log('Add funds', goal.id)} - onViewDetails={() => console.log('View details', goal.id)} - onOverflowAction={() => console.log('More actions', goal.id)} + onAddFunds={() => console.log("Add funds", goal.id)} + onViewDetails={() => console.log("View details", goal.id)} + onOverflowAction={() => console.log("More actions", goal.id)} /> ))}