Skip to content
Open
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
114 changes: 61 additions & 53 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import { fetchAllProposals, fetchTokenBalance, fetchTokenDecimals } from './api'
import { ProposalCard } from './components/ProposalCard';
import { ProposalSkeleton } from './components/ProposalSkeleton';
import { ProposalDetail } from './components/ProposalDetail';
import { ToastContainer, useToast } from './components/Toast';
import { ACTIVE_NETWORK } from './config';
import { formatTokenAmount } from './utils';

const ALL_STATES: ProposalState[] = ['Active', 'Passed', 'Rejected', 'Executed', 'Cancelled'];

export default function App() {
const { toasts, show: showToast, dismiss } = useToast();

const [proposals, setProposals] = useState<Proposal[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
Expand All @@ -22,78 +25,89 @@ export default function App() {

useEffect(() => {
Promise.all([fetchAllProposals(), fetchTokenDecimals()])
.then(([props, decs]) => {
setProposals(props);
setDecimals(decs);
})
.then(([props, decs]) => { setProposals(props); setDecimals(decs); })
.catch(e => setError(String(e)))
.finally(() => setLoading(false));
}, []);

const filtered = useMemo(() => {
return proposals.filter(p => {
const matchState = stateFilter === 'All' || p.state === stateFilter;
const q = search.toLowerCase();
const matchSearch = !q || p.title.toLowerCase().includes(q) || p.description.toLowerCase().includes(q);
return matchState && matchSearch;
});
}, [proposals, search, stateFilter]);
async function connect() {
const pendingId = showToast('pending', 'Waiting for wallet connection…');
try {
const addr = prompt('Enter your Stellar address (G…):');
if (!addr?.startsWith('G')) {
dismiss(pendingId);
showToast('error', 'Invalid Stellar address.');
return;
}
setWalletAddress(addr);
const bal = await fetchTokenBalance(addr).catch(() => null);
setTokenBalance(bal);
dismiss(pendingId);
showToast('success', `Wallet connected: ${addr.slice(0, 6)}…${addr.slice(-4)}`);
} catch {
dismiss(pendingId);
showToast('error', 'Wallet connection failed.');
}
}

function disconnect() {
setWalletAddress(null);
setTokenBalance(null);
showToast('success', 'Wallet disconnected.');
}

const filtered = useMemo(() => proposals.filter(p => {
const matchState = stateFilter === 'All' || p.state === stateFilter;
const q = search.toLowerCase();
return matchState && (!q || p.title.toLowerCase().includes(q) || p.description.toLowerCase().includes(q));
}), [proposals, search, stateFilter]);

return (
<div style={{ minHeight: '100vh', background: '#f8fafc', fontFamily: 'system-ui, sans-serif' }}>
{/* Header */}
<header style={{ background: '#1e293b', color: '#fff', padding: '1rem 2rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 style={{ margin: 0, fontSize: '1.5rem' }}>🌌 CosmosVote</h1>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>On-chain governance · {ACTIVE_NETWORK}</span>
</div>
<div style={{ textAlign: 'right' }}>
{walletAddress ? (
<div>
<div style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{walletAddress.slice(0, 6)}...{walletAddress.slice(-4)}</div>
{tokenBalance !== null && (
<div style={{ fontSize: '0.75rem', color: '#38bdf8' }}>{formatTokenAmount(tokenBalance, decimals)}</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div>
<div style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{walletAddress.slice(0, 6)}…{walletAddress.slice(-4)}</div>
{tokenBalance !== null && (
<div style={{ fontSize: '0.75rem', color: '#38bdf8' }}>{formatTokenAmount(tokenBalance, decimals)}</div>
)}
</div>
<button onClick={disconnect} style={{ background: 'transparent', color: '#94a3b8', border: '1px solid #475569', borderRadius: 6, padding: '0.35rem 0.75rem', cursor: 'pointer', fontSize: '0.75rem' }}>
Disconnect
</button>
</div>
) : (
<button
onClick={connect}
style={{ background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 6, padding: '0.5rem 1rem', cursor: 'pointer' }}
>
<button onClick={connect} style={{ background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 6, padding: '0.5rem 1rem', cursor: 'pointer' }}>
Connect Wallet
</button>
)}
</div>
</header>

<main style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem' }}>
{/* Filters */}
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<input
type="search"
placeholder="Search proposals..."
value={search}
onChange={e => setSearch(e.target.value)}
<input type="search" placeholder="Search proposals…" value={search} onChange={e => setSearch(e.target.value)}
style={{ flex: 1, minWidth: 200, padding: '0.5rem 0.75rem', border: '1px solid #d1d5db', borderRadius: 6, fontSize: '0.875rem' }}
aria-label="Search proposals"
/>
<select
value={stateFilter}
onChange={e => setStateFilter(e.target.value as ProposalState | 'All')}
aria-label="Search proposals" />
<select value={stateFilter} onChange={e => setStateFilter(e.target.value as ProposalState | 'All')}
style={{ padding: '0.5rem 0.75rem', border: '1px solid #d1d5db', borderRadius: 6, fontSize: '0.875rem' }}
aria-label="Filter by state"
>
aria-label="Filter by state">
<option value="All">All States</option>
{ALL_STATES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>

{/* Stats bar */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
{[
{ label: 'Total', count: proposals.length, color: '#1e293b' },
{ label: 'Active', count: proposals.filter(p => p.state === 'Active').length, color: '#2563eb' },
{ label: 'Passed', count: proposals.filter(p => p.state === 'Passed').length, color: '#16a34a' },
{ label: 'Total', count: proposals.length, color: '#1e293b' },
{ label: 'Active', count: proposals.filter(p => p.state === 'Active').length, color: '#2563eb' },
{ label: 'Passed', count: proposals.filter(p => p.state === 'Passed').length, color: '#16a34a' },
{ label: 'Executed', count: proposals.filter(p => p.state === 'Executed').length, color: '#7c3aed' },
].map(({ label, count, color }) => (
<div key={label} style={{ background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8, padding: '0.5rem 1rem', textAlign: 'center' }}>
Expand All @@ -103,22 +117,13 @@ export default function App() {
))}
</div>

{/* Content */}
{error && <p style={{ textAlign: 'center', color: '#dc2626', marginBottom: '1rem' }}>Error: {error}</p>}

<div style={{ display: 'grid', gap: '1rem' }}>
{loading && (
<>
<ProposalSkeleton />
<ProposalSkeleton />
<ProposalSkeleton />
</>
)}
{!loading && !error && filtered.length === 0 && (
<p style={{ textAlign: 'center', color: '#888' }}>No proposals found.</p>
)}
{loading && <><ProposalSkeleton /><ProposalSkeleton /><ProposalSkeleton /></>}
{!loading && !error && filtered.length === 0 && <p style={{ textAlign: 'center', color: '#888' }}>No proposals found.</p>}
{!loading && filtered.map(p => (
<ProposalCard key={String(p.id)} proposal={p} onClick={() => setSelected(p)} />
<ProposalCard key={String(p.id)} proposal={p} decimals={decimals} onClick={() => setSelected(p)} />
))}
</div>
</main>
Expand All @@ -129,8 +134,11 @@ export default function App() {
decimals={decimals}
walletAddress={walletAddress}
onClose={() => setSelected(null)}
onToast={showToast}
/>
)}

<ToastContainer toasts={toasts} onDismiss={dismiss} />
</div>
);
}
}
67 changes: 52 additions & 15 deletions frontend/src/components/ProposalDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Proposal } from '../types';
import type { ToastType } from './Toast';
import { fetchHasVoted, fetchVoteRecord } from '../api';
import { useEffect, useState } from 'react';
import { formatTokenAmount } from '../utils';
Expand All @@ -8,13 +9,14 @@ interface Props {
decimals: number;
walletAddress: string | null;
onClose: () => void;
onToast?: (type: ToastType, message: string) => number;
}

function formatDate(ts: bigint): string {
return new Date(Number(ts) * 1000).toLocaleString();
}

export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose }: Props) {
export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose, onToast }: Props) {
const [hasVoted, setHasVoted] = useState<boolean | null>(null);
const [voteRecord, setVoteRecord] = useState<{ vote: string; weight: bigint } | null>(null);

Expand All @@ -26,11 +28,28 @@ export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose }

const total = p.votes_yes + p.votes_no + p.votes_abstain;

// Simulated vote submission — real implementation would call castVote via SDK
async function handleVote(voteType: string) {
if (!walletAddress || !onToast) return;
const pendingId = onToast('pending', `Submitting ${voteType} vote — confirm in wallet…`);
try {
// Placeholder: actual SDK call would go here
await new Promise(res => setTimeout(res, 1000));
onToast('success', `Vote "${voteType}" submitted on proposal #${String(p.id)}.`);
} catch (e) {
onToast('error', `Vote failed: ${e instanceof Error ? e.message : 'unknown error'}`);
} finally {
// dismiss pending (the success/error toast replaced it)
void pendingId;
}
}

return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100,
}}
<div
role="dialog"
aria-modal="true"
aria-label={`Proposal #${String(p.id)} details`}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}
onClick={onClose}
>
<div
Expand All @@ -39,22 +58,22 @@ export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose }
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2 style={{ margin: 0 }}>Proposal #{String(p.id)}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: '1.5rem', cursor: 'pointer' }}>×</button>
<button onClick={onClose} aria-label="Close" style={{ background: 'none', border: 'none', fontSize: '1.5rem', cursor: 'pointer' }}>×</button>
</div>

<h3 style={{ margin: '0 0 0.5rem' }}>{p.title}</h3>
<p style={{ color: '#555' }}>{p.description}</p>

<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '1rem' }}>
<tbody>
{[
{([
['State', p.state],
['Proposer', `${p.proposer.slice(0, 8)}...${p.proposer.slice(-4)}`],
['Start', formatDate(p.start_time)],
['End', formatDate(p.end_time)],
['Quorum', formatTokenAmount(p.quorum, decimals)],
['Total Votes', formatTokenAmount(total, decimals)],
].map(([k, v]) => (
] as [string, string][]).map(([k, v]) => (
<tr key={k} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '0.4rem 0', color: '#888', width: '40%' }}>{k}</td>
<td style={{ padding: '0.4rem 0', fontWeight: 500 }}>{v}</td>
Expand All @@ -64,11 +83,11 @@ export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose }
</table>

<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem', marginBottom: '1rem' }}>
{[
{ label: '✅ Yes', value: p.votes_yes, color: '#16a34a' },
{ label: '❌ No', value: p.votes_no, color: '#dc2626' },
{([
{ label: '✅ Yes', value: p.votes_yes, color: '#16a34a' },
{ label: '❌ No', value: p.votes_no, color: '#dc2626' },
{ label: '⬜ Abstain', value: p.votes_abstain, color: '#6b7280' },
].map(({ label, value, color }) => (
] as { label: string; value: bigint; color: string }[]).map(({ label, value, color }) => (
<div key={label} style={{ textAlign: 'center', padding: '0.75rem', background: '#f9fafb', borderRadius: 8 }}>
<div style={{ fontSize: '0.75rem', color: '#888' }}>{label}</div>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color }}>{formatTokenAmount(value, decimals).replace(' CVT', '')}</div>
Expand All @@ -78,10 +97,28 @@ export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose }

{walletAddress && (
<div style={{ padding: '0.75rem', background: '#f0f9ff', borderRadius: 8, fontSize: '0.875rem' }}>
{hasVoted === null ? 'Checking vote status...' :
hasVoted && voteRecord
{hasVoted === null
? 'Checking vote status…'
: hasVoted && voteRecord
? `You voted ${voteRecord.vote} with weight ${formatTokenAmount(voteRecord.weight, decimals)}`
: 'You have not voted on this proposal'}
: p.state === 'Active'
? (
<div>
<div style={{ marginBottom: '0.5rem' }}>Cast your vote:</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{['Yes', 'No', 'Abstain'].map(v => (
<button
key={v}
onClick={() => handleVote(v)}
style={{ flex: 1, padding: '0.4rem', borderRadius: 6, border: '1px solid #d1d5db', cursor: 'pointer', background: '#fff', fontSize: '0.8rem' }}
>
{v === 'Yes' ? '✅' : v === 'No' ? '❌' : '⬜'} {v}
</button>
))}
</div>
</div>
)
: 'You have not voted on this proposal.'}
</div>
)}
</div>
Expand Down
Loading