Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/src/modules/governance/dto/proposal-votes-response.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
41 changes: 41 additions & 0 deletions backend/src/modules/governance/governance-proposals.controller.ts
Original file line number Diff line number Diff line change
@@ -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<ProposalVotesResponseDto> {
return this.governanceService.getProposalVotesByOnChainId(id, limit);
}
}
3 changes: 2 additions & 1 deletion backend/src/modules/governance/governance.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {}
14 changes: 13 additions & 1 deletion backend/src/modules/governance/governance.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down
59 changes: 58 additions & 1 deletion backend/src/modules/governance/governance.service.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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<GovernanceProposal>,
@InjectRepository(Vote)
private readonly voteRepo: Repository<Vote>,
) {}

async getUserDelegation(userId: string): Promise<DelegationResponseDto> {
Expand Down Expand Up @@ -46,4 +55,52 @@ export class GovernanceService {
});
return { votingPower: `${votingPower} NST` };
}

async getProposalVotesByOnChainId(
onChainId: number,
limit = 20,
): Promise<ProposalVotesResponseDto> {
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(),
})),
};
}
}
21 changes: 17 additions & 4 deletions frontend/app/components/dashboard/ProposalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,27 @@ 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 (
<div className="w-full rounded-2xl bg-[#061E26] p-6 flex flex-col md:flex-row items-start md:items-center justify-between gap-4 md:gap-6 shadow-lg border border-transparent">
<div className="flex-1 min-w-0 w-full">
{/* Top row on mobile: id at left, status at right (status shown only on mobile here) */}
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-[#64748B]">{id}</span>
<span className="text-xs font-bold px-3 py-1 rounded-full bg-[#08333a] text-sky-200 md:hidden">
<span
className={`text-xs font-bold px-3 py-1 rounded-full md:hidden ${mobilePillClass}`}
>
{status}
</span>
</div>
Expand Down Expand Up @@ -95,7 +108,7 @@ export default function ProposalCard({
{/* Right column for desktop: status + button. Hidden on mobile. */}
<div className="hidden md:flex md:flex-col md:items-end md:gap-4">
<div className="flex flex-col items-end gap-3">
<span className="text-xs font-bold px-3 py-2 rounded-xl bg-[#10314F] border-2 border-[#215091] text-[#60A5FA]">
<span className={`text-xs font-bold px-3 py-2 rounded-xl ${statusPillClass}`}>
{status}
</span>
</div>
Expand All @@ -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}
</button>
</div>
</div>
Expand All @@ -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}
</button>
</div>
</div>
Expand Down
83 changes: 82 additions & 1 deletion frontend/app/dashboard/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -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" };

Expand All @@ -23,6 +23,87 @@ export default function AnalyticsPage() {
Analytics charts and data will appear here.
</p>
</div>

<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 mt-6">
<article className="rounded-2xl border border-[rgba(8,120,120,0.06)] bg-linear-to-b from-[rgba(6,18,20,0.45)] to-[rgba(4,12,14,0.35)] p-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white m-0">Asset Allocation</h3>
<button
type="button"
className="text-[#7caeb6] hover:text-cyan-300 transition-colors"
aria-label="Asset allocation options"
>
<MoreHorizontal size={18} />
</button>
</div>

<div className="flex items-center justify-center mt-6 mb-5">
<div className="relative w-44 h-44 rounded-full bg-[conic-gradient(#22d3ee_0_40%,#60a5fa_40%_75%,#a78bfa_75%_100%)] flex items-center justify-center">
<div className="w-28 h-28 rounded-full bg-[#081a20] border border-white/5 flex flex-col items-center justify-center">
<span className="text-xs uppercase tracking-widest text-[#79a9b0]">
Assets
</span>
<span className="text-3xl font-bold text-white leading-none">3</span>
</div>
</div>
</div>

<div className="space-y-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) => (
<div key={item.asset} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-[#d8f3f6]">
<span className={`w-2.5 h-2.5 rounded-full ${item.color}`} />
{item.asset}
</div>
<span className="text-[#8cc0c7] font-semibold">{item.percent}%</span>
</div>
))}
</div>
</article>

<article className="rounded-2xl border border-[rgba(8,120,120,0.06)] bg-linear-to-b from-[rgba(6,18,20,0.45)] to-[rgba(4,12,14,0.35)] p-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white m-0">Yield Breakdown</h3>
<span className="px-2.5 py-1 rounded-lg border border-emerald-400/25 bg-emerald-400/10 text-[11px] font-semibold uppercase tracking-wider text-emerald-300">
APY High
</span>
</div>
<p className="text-[#6e9ba2] text-sm mt-2 mb-0">
Earnings from your active pools
</p>

<div className="mt-5 p-4 rounded-xl border border-cyan-500/15 bg-cyan-500/5">
<p className="text-xs uppercase tracking-[0.18em] text-[#6e9ba2] m-0">
Total Interest Earned
</p>
<p className="text-3xl font-bold text-white m-0 mt-1">$743.20</p>
</div>

<div className="space-y-4 mt-5">
{[
{ label: "XLM Staking", amount: "+$420.50", progress: 57 },
{ label: "USDC Flexible", amount: "+$322.70", progress: 43 },
].map((pool) => (
<div key={pool.label}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-[#d8f3f6]">{pool.label}</span>
<span className="text-sm font-semibold text-emerald-300">{pool.amount}</span>
</div>
<div className="h-2 rounded-full bg-[#0d2530] overflow-hidden">
<div
className="h-full rounded-full bg-linear-to-r from-cyan-400 to-emerald-300"
style={{ width: `${pool.progress}%` }}
/>
</div>
</div>
))}
</div>
</article>
</div>
</div>
);
}
Loading
Loading