Compare commits

...

14 Commits

Author SHA1 Message Date
720596f6f8 adding dynamic hero text header
All checks were successful
Frontend Build and Deploy / build (push) Successful in 44s
2025-09-12 21:36:04 -03:00
dd2af50722 scroll not working for recursos 2025-09-12 21:30:30 -03:00
f56b030435 removing firebase hard-dependency 2025-09-12 21:27:05 -03:00
4e12ab32e8 add filters to statistics 2025-09-12 21:26:58 -03:00
8090fd13a3 add Firebase integration and configuration
All checks were successful
Frontend Build and Deploy / build (push) Successful in 32s
2025-07-11 22:32:45 -03:00
3084a8e27d small fix
All checks were successful
Frontend Build and Deploy / build (push) Successful in 1m4s
2025-06-30 09:37:23 -03:00
3e8ce05f79 refactors
All checks were successful
Frontend Build and Deploy / build (push) Successful in 27s
2025-06-20 20:00:15 -03:00
a1d3add884 fix disabled
All checks were successful
Frontend Build and Deploy / build (push) Successful in 28s
2025-06-19 21:43:23 -03:00
46e76acad3 tech stats + db dump
Some checks failed
Frontend Build and Deploy / build (push) Failing after 57s
2025-06-19 21:40:38 -03:00
ceaffc088e adding logos e melhorias 2025-06-19 20:33:02 -03:00
af7bd64617 solving scrollbar missing "issue" 2025-06-19 20:07:20 -03:00
c347d5ce24 cpf masking 2025-06-19 19:55:35 -03:00
7acbc48f43 mais melhorias gerais 2025-06-19 18:10:49 -03:00
7512f42e2f pequenas mudanças 2025-06-19 09:58:58 -03:00
38 changed files with 1811 additions and 276 deletions

View File

@@ -1,3 +1,11 @@
# OpenCand API Configuration
# The base URL for the OpenCand API
VITE_API_BASE_URL=https://api.example.com
VITE_FIREBASE_API_KEY="your_api_key"
VITE_FIREBASE_AUTH_DOMAIN="your_auth_domain"
VITE_FIREBASE_PROJECT_ID="your_project_id"
VITE_FIREBASE_STORAGE_BUCKET="your_storage_bucket"
VITE_FIREBASE_MESSAGING_SENDER_ID="your_messaging_sender_id"
VITE_FIREBASE_APP_ID="your_app_id"
VITE_FIREBASE_MEASUREMENT_ID="your_measurement_id"

View File

@@ -32,9 +32,15 @@ jobs:
docker build \
--build-arg VITE_API_BASE_URL="https://api.opencand.ivanch.me" \
--build-arg VITE_FIREBASE_API_KEY="${{ secrets.VITE_FIREBASE_API_KEY }}" \
--build-arg VITE_FIREBASE_AUTH_DOMAIN="${{ secrets.VITE_FIREBASE_AUTH_DOMAIN }}" \
--build-arg VITE_FIREBASE_PROJECT_ID="${{ secrets.VITE_FIREBASE_PROJECT_ID }}" \
--build-arg VITE_FIREBASE_STORAGE_BUCKET="${{ secrets.VITE_FIREBASE_STORAGE_BUCKET }}" \
--build-arg VITE_FIREBASE_MESSAGING_SENDER_ID="${{ secrets.VITE_FIREBASE_MESSAGING_SENDER_ID }}" \
--build-arg VITE_FIREBASE_APP_ID="${{ secrets.VITE_FIREBASE_APP_ID }}" \
--build-arg VITE_FIREBASE_MEASUREMENT_ID="${{ secrets.VITE_FIREBASE_MEASUREMENT_ID }}" \
--file OpenCand.UI.dockerfile \
-t "${{ env.IMAGE_FRONTEND }}:${TAG}" \
.
-t "${{ env.IMAGE_FRONTEND }}:${TAG}" .
docker push "${{ env.IMAGE_FRONTEND }}:${TAG}"

View File

@@ -11,6 +11,21 @@ COPY . .
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ARG VITE_FIREBASE_API_KEY
ENV VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY}
ARG VITE_FIREBASE_AUTH_DOMAIN
ENV VITE_FIREBASE_AUTH_DOMAIN=${VITE_FIREBASE_AUTH_DOMAIN}
ARG VITE_FIREBASE_PROJECT_ID
ENV VITE_FIREBASE_PROJECT_ID=${VITE_FIREBASE_PROJECT_ID}
ARG VITE_FIREBASE_STORAGE_BUCKET
ENV VITE_FIREBASE_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET}
ARG VITE_FIREBASE_MESSAGING_SENDER_ID
ENV VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID}
ARG VITE_FIREBASE_APP_ID
ENV VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_ID}
ARG VITE_FIREBASE_MEASUREMENT_ID
ENV VITE_FIREBASE_MEASUREMENT_ID=${VITE_FIREBASE_MEASUREMENT_ID}
RUN yarn build
# ─── Stage 2: Serve ────────────────────────────────────────────────────────
@@ -20,6 +35,7 @@ RUN rm -rf /usr/share/nginx/html/*
# Replace default nginx.conf
COPY nginx.conf /etc/nginx/nginx.conf
# Copy our built files into nginxs html folder
COPY ./public/assets /usr/share/nginx/html
COPY --from=builder /app/dist /usr/share/nginx/html
# (Optional) If you need any custom nginx.conf, COPY it here—

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>OpenCand</title>
</head>
<body>

996
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@tailwindcss/postcss": "^4.1.8",
"@tailwindcss/vite": "^4.1.8",
"firebase": "^11.10.0",
"postcss": "^8.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -13,7 +13,8 @@ body, html {
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
/* Custom animations */
@@ -95,3 +96,52 @@ body, html {
-ms-overflow-style: none; /* Internet Explorer and Edge */
overflow: -moz-scrollbars-none; /* Old versions of Firefox */
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
/* Custom minimal scrollbar styles */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 1px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 2px;
transition: background-color 0.2s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.7);
}
.custom-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
/* Custom fade-in animation for dropdown content */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import React, { useEffect } from 'react';
import { Routes, Route, useLocation } from 'react-router-dom';
import Navbar from './components/Navbar';
import HeroSection from './components/HeroSection';
import StatisticsSection from './components/StatisticsSection';
@@ -8,21 +8,48 @@ import Footer from './components/Footer';
import CandidatePage from './components/CandidatePage/CandidatePage';
import DataStatsPage from './components/DataStatsPage';
import StatisticsPage from './components/StatisticsPage';
import SobrePage from './components/SobrePage';
import NotFound from './components/NotFound';
import MatrixBackground from './components/MatrixBackground';
import './App.css';
// HomePage component
const HomePage: React.FC = () => (
const HomePage: React.FC = () => {
const location = useLocation();
useEffect(() => {
if (location.hash) {
const element = document.getElementById(location.hash.substring(1));
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
}, [location]);
return (
<main className="flex-grow">
<HeroSection />
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-12 rounded-full"></div>
<StatisticsSection />
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
<FeaturesSection />
</main>
);
);
};
function App() {
const location = useLocation();
useEffect(() => {
const segment = location.hash;
if (segment) {
const element = document.getElementById(segment.replace('#', ''));
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}, [location]);
return (
<div className="min-h-screen w-full flex flex-col relative" style={{ backgroundColor: 'transparent' }}>
<MatrixBackground />
@@ -34,6 +61,7 @@ function App() {
<Route path="/candidato/:id" element={<CandidatePage />} />
<Route path="/dados-disponiveis" element={<DataStatsPage />} />
<Route path="/estatisticas" element={<StatisticsPage />} />
<Route path="/sobre" element={<SobrePage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>

View File

@@ -145,3 +145,22 @@ export interface OpenCandDataAvailabilityStats {
redeSocialCandidatos: number[];
fotosCandidatos: number[];
}
export interface OpenCandDatabaseStats {
tables: {
name: string;
totalSize: number; // in bytes
entries: number; // number of rows
}[];
materializedViews: {
name: string;
totalSize: number; // in bytes
entries: number; // number of rows
}[];
indexes: {
amount: number; // number of indexes
size: number; // total size of indexes in bytes
};
totalSize: number; // total size of the database in bytes
totalEntries: number; // total number of entries across all tables
}

View File

@@ -1,7 +1,8 @@
import { BaseApiClient } from './base';
import { API_CONFIG } from '../config/api';
import type { CandidateAssets, CandidateDetails, CandidateExpenses, CandidateIncome, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, OpenCandDataAvailabilityStats, PlatformStats, RandomCandidate } from './apiModels';
import type { CandidateAssets, CandidateDetails, CandidateExpenses, CandidateIncome, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, OpenCandDataAvailabilityStats, OpenCandDatabaseStats, PlatformStats, RandomCandidate } from './apiModels';
import type { EnrichmentResponse, StatisticsConfig, ValueSumRequest, ValueSumResponse } from './apiStatisticsModels';
import type { StatisticsRequestFilters, StatisticsRequestOptions } from '../components/StatisticsPage/statisticsRequests';
/**
* OpenCand API client for interacting with the OpenCand platform
@@ -27,6 +28,13 @@ export class OpenCandApi extends BaseApiClient {
return this.get<OpenCandDataAvailabilityStats>('/v1/stats/data-availability');
}
/**
* Get the database tech stats
*/
async getDatabaseTechStats(): Promise<OpenCandDatabaseStats> {
return this.get<OpenCandDatabaseStats>(`/v1/stats/tech`);
}
/**
* Search for candidates by name or other attributes
*/
@@ -94,8 +102,28 @@ export class OpenCandApi extends BaseApiClient {
/**
* Get the enrichment statistics for candidates
*/
async getStatisticsEnrichment(): Promise<EnrichmentResponse[]> {
return this.get<EnrichmentResponse[]>(`/v1/estatistica/enriquecimento`, { timeout: 90000 });
async getStatisticsEnrichment(filters?: StatisticsRequestFilters): Promise<EnrichmentResponse[]> {
let url = `/v1/estatistica/enriquecimento`;
if (filters) {
const params = new URLSearchParams();
if (filters.partido !== null && filters.partido !== undefined) {
params.append('partido', filters.partido);
}
if (filters.uf !== null && filters.uf !== undefined) {
params.append('uf', filters.uf);
}
if (filters.ano !== null && filters.ano !== undefined) {
params.append('ano', String(filters.ano));
}
if (filters.cargo !== null && filters.cargo !== undefined) {
params.append('cargo', filters.cargo);
}
const queryString = params.toString();
if (queryString) {
url += `?${queryString}`;
}
}
return this.get<EnrichmentResponse[]>(url, { timeout: 90000 });
}
/**

View File

@@ -11,12 +11,25 @@ interface IncomeExpenseComponentProps {
isLoadingIncome: boolean;
}
const hasIncomeData = (income: CandidateIncome | null) => {
if (!income || income.receitas.length === 0) return false;
return true;
};
const hasExpenseData = (expenses: CandidateExpenses | null) => {
if (!expenses || expenses.despesas.length === 0) return false;
return true;
};
const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({
expenses,
income,
isLoadingExpenses,
isLoadingIncome
}) => {
const showIncome = hasIncomeData(income);
const showExpenses = hasExpenseData(expenses);
if (!showIncome && !showExpenses) return null;
return (
<div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 p-6">
<div className="flex items-center mb-6">
@@ -25,12 +38,15 @@ const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({
</div>
{/* Income Section */}
{showIncome && (
<IncomeSection
income={income}
isLoadingIncome={isLoadingIncome}
/>
)}
{/* Separator */}
{showIncome && showExpenses && (
<div className="flex items-center my-8">
<div className="flex-grow border-t border-gray-300"></div>
<div className="flex-shrink-0 px-4">
@@ -38,12 +54,15 @@ const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({
</div>
<div className="flex-grow border-t border-gray-300"></div>
</div>
)}
{/* Expenses Section */}
{showExpenses && (
<ExpenseSection
expenses={expenses}
isLoadingExpenses={isLoadingExpenses}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,80 @@
import React from 'react';
import type { OpenCandDataAvailabilityStats } from '../../api/apiModels';
interface DataAvailabilityTableProps {
stats: OpenCandDataAvailabilityStats;
sortedYears: number[];
}
const dataTypes = [
{ key: 'candidatos', label: 'Candidatos', icon: '👤' },
{ key: 'bemCandidatos', label: 'Bens de Candidatos', icon: '💰' },
{ key: 'despesaCandidatos', label: 'Despesas de Candidatos', icon: '💸' },
{ key: 'receitaCandidatos', label: 'Receitas de Candidatos', icon: '💵' },
{ key: 'redeSocialCandidatos', label: 'Redes Sociais', icon: '📱' },
{ key: 'fotosCandidatos', label: 'Fotos de Candidatos (API)', icon: '📸' },
];
const DataAvailabilityTable: React.FC<DataAvailabilityTableProps> = ({ stats, sortedYears }) => {
return (
<div className="bg-gray-800/10 backdrop-blur-xs rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.005] transition-all duration-200 overflow-hidden ring-1 ring-gray-700 hover:ring-indigo-300">
<div className="p-6 border-b border-gray-700/30 bg-gray-800/10">
<h2 className="text-2xl font-bold text-white">
Matriz de Disponibilidade
</h2>
<p className="text-gray-400 mt-2">
Disponível Não Disponível
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-800/10">
<th className="text-left p-4 text-white font-semibold border-b border-gray-700/30 sticky left-0 bg-gray-800/10">
Tipo de Dado
</th>
{sortedYears.map((year, index) => (
<th
key={year}
className="text-center p-4 text-white font-semibold border-b border-gray-700/30 animate-slide-in-left"
style={{ animationDelay: `${index * 50}ms` }}
>
{year}
</th>
))}
</tr>
</thead>
<tbody>
{dataTypes.map((dataType, rowIndex) => (
<tr
key={dataType.key}
className="hover:bg-gray-800/10 transition-all duration-300 animate-slide-in-left"
style={{ animationDelay: `${rowIndex * 100}ms` }}
>
<td className="p-4 border-b border-gray-700/20 text-white sticky left-0 bg-gray-800/10">
<div className="flex items-center space-x-3">
<span className="text-xl">{dataType.icon}</span>
<span>{dataType.label}</span>
</div>
</td>
{sortedYears.map((year) => {
const isAvailable = (stats[dataType.key as keyof OpenCandDataAvailabilityStats] as number[]).includes(year);
return (
<td key={year} className="text-center p-4 border-b border-gray-700/20">
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all duration-100 ${isAvailable ? 'bg-green-500/20 text-green-300 hover:bg-green-500/30 hover:scale-110 hover:cursor-default' : 'bg-red-500/20 text-red-300 hover:bg-red-500/30 hover:cursor-default'}`}>
{isAvailable ? '✅' : '❌'}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default DataAvailabilityTable;

View File

@@ -0,0 +1,39 @@
import React from 'react';
interface DbIndexesStatsProps {
amount: number;
size: number;
}
const formatSize = (sizeInBytes: number): string => {
const sizeMB = sizeInBytes / 1024 / 1024;
return sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
};
const DbIndexesStats: React.FC<DbIndexesStatsProps> = ({ amount, size }) => {
return (
<div>
<h3 className="text-xl font-semibold text-white mb-2">Índices</h3>
<div className="overflow-x-auto max-h-96 flex justify-center" style={{maxHeight: '28rem'}}>
<table className="w-auto text-left border-collapse mx-auto">
<thead>
<tr className="bg-gray-800/10">
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Quantidade</th>
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Tamanho Total</th>
</tr>
</thead>
<tbody>
<tr>
<td className="p-3 text-gray-300">{amount}</td>
<td className="p-3 text-gray-300">{formatSize(size)}</td>
</tr>
</tbody>
</table>
</div>
</div>
);
};
export default DbIndexesStats;

View File

@@ -0,0 +1,60 @@
import React from 'react';
interface DbStatItem {
name: string;
totalSize: number;
entries: number;
}
interface DbStatsTableProps {
title: string;
items: DbStatItem[];
showTotal?: boolean;
}
const formatSize = (sizeInBytes: number): string => {
const sizeMB = sizeInBytes / 1024 / 1024;
return sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
};
const DbStatsTable: React.FC<DbStatsTableProps> = ({ title, items, showTotal = false }) => {
const totalSize = showTotal ? items.reduce((acc, item) => acc + item.totalSize, 0) : 0;
const totalEntries = showTotal ? items.reduce((acc, item) => acc + item.entries, 0) : 0;
return (
<div>
<h3 className="text-xl font-semibold text-white mb-2">{title}</h3>
<div className="overflow-x-auto" style={{maxHeight: '32rem'}}>
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-800/10">
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Nome</th>
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Tamanho Total</th>
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Entradas</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.name} className="hover:bg-gray-800/10 transition-all duration-200">
<td className="p-3 text-white">{item.name.replace(/^public\./, '')}</td>
<td className="p-3 text-gray-300">{formatSize(item.totalSize)}</td>
<td className="p-3 text-gray-300">{item.entries.toLocaleString()}</td>
</tr>
))}
{showTotal && (
<tr className="font-bold bg-gray-900/30">
<td className="p-3 text-white">Total</td>
<td className="p-3 text-gray-300">{formatSize(totalSize)}</td>
<td className="p-3 text-gray-300">{totalEntries.toLocaleString()}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default DbStatsTable;

View File

@@ -1,13 +1,21 @@
import React, { useState, useEffect } from 'react';
import { FaCloudDownloadAlt, FaGoogleDrive } from 'react-icons/fa';
import { openCandApi } from '../api';
import type { OpenCandDataAvailabilityStats } from '../api/apiModels';
import type { OpenCandDataAvailabilityStats, OpenCandDatabaseStats } from '../api/apiModels';
import Card from '../shared/Card';
import ErrorPage from './ErrorPage';
import DataAvailabilityTable from './DataStats/DataAvailabilityTable';
import DbStatsTable from './DataStats/DbStatsTable';
import GradientButton from '../shared/GradientButton';
import DbIndexesStats from './DataStats/DbIndexesStats';
const DataStatsPage: React.FC = () => {
const [stats, setStats] = useState<OpenCandDataAvailabilityStats | null>(null);
const [dbStats, setDbStats] = useState<OpenCandDatabaseStats | null>(null);
const [loading, setLoading] = useState(true);
const [dbLoading, setDbLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null);
useEffect(() => {
const fetchStats = async () => {
@@ -22,16 +30,28 @@ const DataStatsPage: React.FC = () => {
setLoading(false);
}
};
const fetchDbStats = async () => {
try {
setDbLoading(true);
const dbData = await openCandApi.getDatabaseTechStats();
setDbStats(dbData);
} catch (err) {
setDbError('Erro ao carregar estatísticas técnicas do banco de dados');
console.error('Error fetching database tech stats:', err);
} finally {
setDbLoading(false);
}
};
fetchStats();
fetchDbStats();
}, []);
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-indigo-600 border-t-transparent mx-auto mb-4"></div>
<p className="text-lg text-gray-700">Carregando dados...</p>
<div className="flex items-center justify-center min-h-[40vh]">
<div className="backdrop-blur-md bg-white/90 rounded-2xl shadow-xl p-8 max-w-md w-full border border-white/30 flex flex-col items-center">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-indigo-600 border-t-transparent mb"></div>
<p className="text-base text-gray-700">Carregando dados...</p>
</div>
</div>
);
@@ -62,20 +82,11 @@ const DataStatsPage: React.FC = () => {
});
const sortedYears = Array.from(allYears).sort((a, b) => b - a);
const dataTypes = [
{ key: 'candidatos', label: 'Candidatos', icon: '👤' },
{ key: 'bemCandidatos', label: 'Bens de Candidatos', icon: '💰' },
{ key: 'despesaCandidatos', label: 'Despesas de Candidatos', icon: '💸' },
{ key: 'receitaCandidatos', label: 'Receitas de Candidatos', icon: '💵' },
{ key: 'redeSocialCandidatos', label: 'Redes Sociais', icon: '📱' },
{ key: 'fotosCandidatos', label: 'Fotos de Candidatos', icon: '📸' },
];
return (
<div className="min-h-screen py-20 px-4 hover:cursor-default">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-12 animate-fade-in">
<div className="text-center mb-12 animate-slide-in-left">
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white">
Disponibilidade de Dados
</h1>
@@ -85,12 +96,29 @@ const DataStatsPage: React.FC = () => {
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
</div>
{/* Download DB Dump Section */}
<div className="flex justify-center mb-10 animate-slide-in-left backdrop-blur-xs bg-gray-800/10 rounded-xl shadow-lg hover:shadow-xl transform transition-all duration-200 overflow-hidden ring-1 ring-gray-700 hover:ring-indigo-300 px-8 py-6 flex flex-col md:flex-row items-center gap-4 max-w-xl-auto">
<div className="flex items-center gap-3 mb-2 md:mb-0">
<FaCloudDownloadAlt className="text-3xl text-green-400" />
<span className="text-lg font-semibold text-white">Download do Dump do Banco de Dados</span>
</div>
<GradientButton
href="https://drive.google.com/file/d/1cfMItrsAdv8y8YUNp04D33s6pYrRbmDn/view?usp=sharing"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 py-2 px-5"
>
<FaGoogleDrive className="text-xl" />
Google Drive
</GradientButton>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<Card hasAnimation={true} className="bg-gray-800/10 rounded-xl">
<div className="text-center">
<div className="text-3xl mb-2">📊</div>
<div className="text-2xl font-bold text-white">{dataTypes.length}</div>
<div className="text-2xl font-bold text-white">6</div>
<div className="text-gray-400">Tipos de Dados</div>
</div>
</Card>
@@ -113,69 +141,30 @@ const DataStatsPage: React.FC = () => {
</div>
{/* Data Availability Table */}
<div className="bg-gray-800/10 backdrop-blur-xs rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.005] transition-all duration-200 overflow-hidden ring-1 ring-gray-700 hover:ring-indigo-300">
<div className="p-6 border-b border-gray-700/30 bg-gray-800/10">
<h2 className="text-2xl font-bold text-white">
Matriz de Disponibilidade
</h2>
<p className="text-gray-400 mt-2">
Disponível Não Disponível
</p>
<DataAvailabilityTable stats={stats} sortedYears={sortedYears} />
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-800/10">
<th className="text-left p-4 text-white font-semibold border-b border-gray-700/30 sticky left-0 bg-gray-800/10">
Tipo de Dado
</th>
{sortedYears.map((year, index) => (
<th
key={year}
className="text-center p-4 text-white font-semibold border-b border-gray-700/30 animate-fade-in"
style={{ animationDelay: `${index * 50}ms` }}
>
{year}
</th>
))}
</tr>
</thead>
<tbody>
{dataTypes.map((dataType, rowIndex) => (
<tr
key={dataType.key}
className="hover:bg-gray-800/10 transition-all duration-300 animate-slide-in-left"
style={{ animationDelay: `${rowIndex * 100}ms` }}
>
<td className="p-4 border-b border-gray-700/20 text-white sticky left-0 bg-gray-800/10">
<div className="flex items-center space-x-3">
<span className="text-xl">{dataType.icon}</span>
<span>{dataType.label}</span>
{/* Database Tech Stats Section */}
<div className="mt-20 flex justify-center">
<div className="bg-gray-800/10 backdrop-blur-xs rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.005] transition-all duration-200 overflow-hidden ring-1 ring-gray-700 hover:ring-indigo-300 mb-12 w-full max-w-2xl">
<div className="p-6 border-b border-gray-700/30 bg-gray-800/10">
<h2 className="text-2xl font-bold text-white">Dados Técnicas do Banco de Dados</h2>
<p className="text-gray-400 mt-2">Informações sobre tabelas, views materializadas e índices</p>
</div>
</td>
{sortedYears.map((year) => {
const isAvailable = (stats[dataType.key as keyof OpenCandDataAvailabilityStats] as number[]).includes(year);
return (
<td
key={year}
className="text-center p-4 border-b border-gray-700/20"
>
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all duration-100 ${
isAvailable
? 'bg-green-500/20 text-green-300 hover:bg-green-500/30 hover:scale-110 hover:cursor-default'
: 'bg-red-500/20 text-red-300 hover:bg-red-500/30 hover:cursor-default'
}`}>
{isAvailable ? '✅' : '❌'}
{dbLoading ? (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-indigo-600 border-t-transparent mr-4"></div>
<span className="text-gray-300">Carregando dados do banco de dados...</span>
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
) : dbError ? (
<div className="p-8 text-red-400">{dbError}</div>
) : dbStats ? (
<div className="p-6 space-y-10">
<DbStatsTable title="Tabelas" items={dbStats.tables} showTotal />
<DbStatsTable title="Views Materializadas" items={dbStats.materializedViews} />
<DbIndexesStats amount={dbStats.indexes.amount} size={dbStats.indexes.size} />
</div>
) : null}
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Link } from 'react-router-dom';
import GradientButton from '../shared/GradientButton';
interface ErrorPageProps {
title: string;
@@ -21,12 +22,9 @@ const ErrorPage: React.FC<ErrorPageProps> = ({ title, description, helperText })
</div>
<div className="space-y-4">
<Link
to="/"
className="inline-block px-8 py-3 bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-semibold rounded-lg hover:from-indigo-600 hover:to-purple-700 transition-all duration-300 transform shadow-lg hover:shadow-xl"
>
<GradientButton to="/" className="inline-block px-8 py-3">
Voltar para a página inicial
</Link>
</GradientButton>
{helperText && (
<div className="mt-6">

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { ArrowDownOnSquareStackIcon, BookOpenIcon, ChartBarIcon, DocumentTextIcon, LightBulbIcon } from '@heroicons/react/24/outline';
import { ArrowDownOnSquareStackIcon, BookOpenIcon, ChartBarIcon, ChartBarSquareIcon, DocumentMagnifyingGlassIcon, DocumentTextIcon, IdentificationIcon, LightBulbIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import Card from '../shared/Card';
const FeatureCard: React.FC<{ icon: React.ElementType, title: string, children: React.ReactNode }> = ({ icon: Icon, title, children }) => {
@@ -14,23 +14,26 @@ const FeatureCard: React.FC<{ icon: React.ElementType, title: string, children:
const FeaturesSection: React.FC = () => {
return (
<section id="features" className="py-20">
<section id="recursos" className="py-20">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-center text-white mb-12">
Por que OpenCand?
<h2 className="text-4xl md:text-5xl font-bold text-center text-white mb-12">
Recursos
</h2>
<div className="flex flex-wrap justify-center gap-8">
<FeatureCard icon={DocumentTextIcon} title="Acesso Simplificado">
Navegue facilmente por dados complexos do TSE com uma interface limpa e amigável.
</FeatureCard>
<FeatureCard icon={ChartBarIcon} title="Visualizações Claras">
Entenda as tendências e padrões com gráficos e resumos visuais dos dados eleitorais.
<FeatureCard icon={IdentificationIcon} title="Visualizações Claras">
Visualização detalhada de perfis, redes sociais e histórico eleitoral.
</FeatureCard>
<FeatureCard icon={LightBulbIcon} title="Insights Valiosos">
Obtenha informações relevantes sobre candidatos, partidos e financiamento de campanhas.
<FeatureCard icon={DocumentMagnifyingGlassIcon} title="Declaração de Bens">
Visualização acerca de bens declarados para cada candidato.
</FeatureCard>
<FeatureCard icon={BookOpenIcon} title="Open Source">
Contribua para um projeto aberto e transparente, ajudando a melhorar a plataforma para todos.
<FeatureCard icon={MagnifyingGlassIcon} title="Informações de Campanha">
Análise de receitas e despesas de campanha quando disponível.
</FeatureCard>
<FeatureCard icon={ChartBarSquareIcon} title="Estatísticas">
Estatísticas e gráficos interativos para entender melhor o cenário eleitoral.
</FeatureCard>
<FeatureCard icon={ArrowDownOnSquareStackIcon} title="Dados Abertos">
Os dados são acessíveis através do TSE e também disponibilizados em nosso repositório GitHub, garantindo transparência e confiabilidade.

View File

@@ -2,13 +2,15 @@ import React from 'react';
const Footer: React.FC = () => {
return (
<footer className="bg-gray-800/30 text-gray-400 py-8 text-center backdrop-blur-xs shadow-lg ring-1 ring-gray-800">
<footer className="bg-gray-800/30 text-gray-400 py-4 text-center backdrop-blur-xs shadow-lg ring-1 ring-gray-800">
<div className="container mx-auto">
<p className="mb-2">
&copy; {new Date().getFullYear()} OpenCand. Todos os direitos reservados.
<p className="mb-2 text-md">
OpenCand
</p>
<p className="text-sm">
Democratizando o acesso à informação eleitoral.
<p className="text-xs">
Não nos responsabilizamos por quaisquer erros ou inconsistências nos dados apresentados, pois estes são de livre interpretação da plataforma e devem ser verificados com os dados oficiais do TSE.
<br />
Logos desenhados por Freepik.
</p>
</div>
</footer>

View File

@@ -1,17 +1,33 @@
import React from 'react';
import React, { useState } from 'react';
import SearchBar from './SearchBar';
import RandomCandButton from '../shared/RandomCandButton';
const HeroSection: React.FC = () => {
const headers = [
"Explore Dados Eleitorais",
"Analise Dados Eleitorais",
"Consulte Dados Eleitorais",
"Verifique Dados Eleitorais",
"Descubra Dados Eleitorais",
"Acesse Informações Eleitorais",
"Verifique Candidatos",
"Descubra Candidatos",
"Pesquise Candidaturas",
"Consulte Candidatos",
"Navegue pelos Dados do TSE"
];
const [header] = useState(headers[Math.floor(Math.random() * headers.length)]);
return (
<section
className="min-h-screen flex flex-col justify-center items-center text-white bg-cover bg-center bg-no-repeat bg-gray-900 relative"
style={{ backgroundImage: "url('https://upload.wikimedia.org/wikipedia/commons/thumb/2/21/National_Congress_of_Brazil%2C_Brasilia.jpg/1024px-National_Congress_of_Brazil%2C_Brasilia.jpg')" }}
style={{ backgroundImage: "url('/assets/Congresso_Nacional_hero.jpg')" }}
>
<div className="absolute inset-0 bg-black/60"></div>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[#0a0f1a] to-transparent"></div>
<div className="relative z-10 text-center max-w-6xl">
<h1 className="text-5xl md:text-7xl font-bold mb-6">
Explore Dados Eleitorais
{header}
</h1>
<p className="text-lg md:text-xl mb-10 text-gray-300">
OpenCand oferece acesso fácil e visualizações intuitivas de dados abertos do Tribunal Superior Eleitoral (TSE).
@@ -26,6 +42,18 @@ const HeroSection: React.FC = () => {
/>
</div>
</div>
{/* Image credit */}
<span className="absolute left-4 bottom-2 z-20 text-[10px] text-gray-200 bg-black/30 px-1.5 py-0.5 rounded select-none pointer-events-none opacity-55">
Edilson Rodrigues/Agência Senado. Senado Federal,
<a
href="https://creativecommons.org/licenses/by/2.0"
target="_blank"
rel="noopener noreferrer"
className="ml-1 text-gray-200 hover:text-white"
>
CC BY 2.0
</a>, via Wikimedia Commons
</span>
</section>
);
};

View File

@@ -34,8 +34,8 @@ const MatrixBackground: React.FC = () => {
connectionDistance: 150,
dotSpeed: 0.3,
hoverRadius: 120,
baseBrightness: 0.4, // Moderate base brightness for main background
hoverBrightness: 0.7, // Moderate hover brightness
baseBrightness: 0.2, // Moderate base brightness for main background
hoverBrightness: 0.5, // Moderate hover brightness
baseThickness: 0.6,
hoverThickness: 1.8,
fadeSpeed: 0.08, // Slightly faster fade for better responsiveness

View File

@@ -10,14 +10,15 @@ const Navbar: React.FC = () => {
<NavbarMatrixBackground />
<div className="relative z-10 p-4">
<div className="container mx-auto flex justify-between items-center">
<a href="/" className="text-2xl font-bold text-indigo-400 hover:text-indigo-300 transition-colors">
OpenCand
<a href="/" className="flex items-center gap-2 text-2xl font-bold text-indigo-400 hover:text-indigo-300 transition-colors">
<img src="/assets/opencand-line.png" alt="OpenCand logo" className="h-8 w-auto" />
<span>OpenCand</span>
</a>
<div className="space-x-4">
<Button href="/dados-disponiveis">Dados Disponíveis</Button>
<Button href="/estatisticas">Estatíscas</Button>
<Button href="/#features">Recursos</Button>
<Button href="/about">Sobre</Button>
<Button href="/#recursos">Recursos</Button>
<Button href="/sobre">Sobre</Button>
</div>
</div>
</div>

View File

@@ -15,9 +15,11 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
const [showResults, setShowResults] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const navigate = useNavigate();
const searchTimeoutRef = useRef<number | null>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const resultsContainerRef = useRef<HTMLDivElement>(null);
// Debounced search function
const performSearch = useCallback(async (query: string) => {
@@ -74,10 +76,12 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
// Handle form submission
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (searchResults.length > 0) {
if (activeIndex > -1 && searchResults[activeIndex]) {
handleCandidateSelect(searchResults[activeIndex]);
} else if (searchResults.length > 0) {
handleCandidateSelect(searchResults[0]);
}
}, [searchResults, handleCandidateSelect]);
}, [searchResults, handleCandidateSelect, activeIndex]);
// Close results when clicking outside
useEffect(() => {
@@ -93,6 +97,43 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
};
}, []);
// Reset active index when search results change
useEffect(() => {
setActiveIndex(-1);
}, [searchResults]);
// Scroll active item into view
useEffect(() => {
if (activeIndex < 0 || !resultsContainerRef.current) return;
const resultsContainer = resultsContainerRef.current;
const activeButton = resultsContainer.children[activeIndex] as HTMLElement;
if (activeButton) {
activeButton.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}, [activeIndex]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setShowResults(false);
return;
}
if (showResults && searchResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(prevIndex => (prevIndex + 1) % searchResults.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(prevIndex => (prevIndex - 1 + searchResults.length) % searchResults.length);
}
}
};
const getCandidateDescription = (candidate: Candidate) => {
const desc = [''];
@@ -126,6 +167,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
onChange={handleInputChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown}
placeholder="Pesquisar candidatos..."
className="flex-grow bg-transparent text-white placeholder-gray-400 p-3 focus:outline-none"
autoComplete="off"
@@ -160,12 +202,13 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
{error}
</div>
) : (
<div className="max-h-80 overflow-y-auto custom-scrollbar">
{searchResults.map((candidate) => (
<div ref={resultsContainerRef} className="max-h-80 overflow-y-auto custom-scrollbar">
{searchResults.map((candidate, index) => (
<button
key={candidate.idCandidato}
onClick={() => handleCandidateSelect(candidate)}
className="w-full p-4 text-left hover:bg-gray-100 transition-colors border-b border-gray-100 last:border-b-0 focus:outline-none focus:bg-gray-100"
onMouseEnter={() => setActiveIndex(index)}
className={`w-full p-4 text-left hover:bg-gray-100 transition-colors border-b border-gray-100 last:border-b-0 focus:outline-none focus:bg-gray-100 ${index === activeIndex ? 'bg-gray-100' : ''}`}
>
<div className="text-black font-semibold text-base">{candidate.nome}</div>
<div className="text-gray-600 text-sm mt-1">

View File

@@ -0,0 +1,120 @@
import React from 'react';
import { FaGithub, FaLinkedin } from 'react-icons/fa';
const SobrePage: React.FC = () => {
return (
<div className="flex justify-center items-center min-h-[80vh] py-12 px-4">
<div
className="backdrop-blur-md bg-white/90 rounded-2xl shadow-xl p-8 max-w-3xl w-full border border-white/30"
style={{ boxShadow: '0 8px 32px 0 rgba(31, 38, 135, 0.2)' }}
>
<div className="space-y-4 text-gray-700">
<p className="text-lg leading-relaxed">
O <strong>OpenCand</strong> é uma plataforma que visa explorar de forma intuitiva os dados eleitorais brasileiros e possui
o objetivo de ser uma alternativa para o acesso às informações públicas do Tribunal Superior Eleitoral (TSE).
</p>
<p className="leading-relaxed">
O projeto foi desenvolvido por <strong className="inline-flex items-center gap-1">
ivanch
<a
href="https://github.com/ivanch"
target="_blank"
rel="noopener noreferrer"
className="align-middle inline-block hover:scale-110 transition-transform"
aria-label="GitHub"
>
<FaGithub className="text-gray-700 hover:text-black" size={16} />
</a>
<a
href="https://www.linkedin.com/in/joseivanch"
target="_blank"
rel="noopener noreferrer"
className="align-middle inline-block hover:scale-110 transition-transform"
aria-label="LinkedIn"
>
<FaLinkedin className="text-blue-700 hover:text-blue-900" size={16} />
</a>
</strong> como hobby, o código é semi-aberto e está disponível no <strong>GitHub</strong>.
Atualmente apenas o ETL (Extract-Transform-Load) está disponível, juntamente com o banco de dados final.
</p>
<p className="leading-relaxed">
A API também pode ser utilizada para acessar os dados de forma pública, porém ainda não está documentada. A documentação da API será disponibilizada em breve.
</p>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
<p className="leading-relaxed">
Não nos responsabilizamos por quaisquer erros ou inconsistências nos dados apresentados, pois estes são de livre interpretação da plataforma e devem ser verificados com os dados oficiais do TSE.
<br />
A plataforma é uma iniciativa independente e não possui qualquer vínculo com o TSE ou órgãos governamentais.
</p>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
<div className="mt-6 flex flex-col items-center">
<p className="font-semibold text-gray-800 mb-2 text-center">Links úteis:</p>
<ul className="space-y-2 w-full max-w-md">
<li className="flex items-center justify-center">
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-3"></span>
<a
href="https://divulgacandcontas.tse.jus.br/divulga/#/home"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-700 hover:underline hover:text-indigo-900 transition-colors text-center"
>
DivulgaCand do TSE
</a>
</li>
<li className="flex items-center justify-center">
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-3"></span>
<a
href="https://sig.tse.jus.br/ords/dwapr/r/seai/sig-eleicao/home"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-700 hover:underline hover:text-indigo-900 transition-colors text-center"
>
Estatísticas do TSE
</a>
</li>
<li className="flex items-center justify-center">
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-3"></span>
<a
href="https://dadosabertos.tse.jus.br/dataset/"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-700 hover:underline hover:text-indigo-900 transition-colors text-center"
>
Dataset do TSE
</a>
</li>
</ul>
</div>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
<p className="leading-relaxed">
Contato: {[
'opencand',
<span key="at" style={{ userSelect: 'text' }}>@</span>,
'ivanch',
<span key="dot" style={{ userSelect: 'text' }}>.</span>,
'me'
]}
</p>
<div className="mt-6 pt-4 border-t border-gray-300">
<p className="text-sm text-gray-600 text-center">
Desenvolvido com .NET, PostgreSQL, React (TypeScript/Tailwind CSS) e dados abertos do TSE.<br />
<span className="text-xs">
</span>
</p>
</div>
</div>
</div>
</div>
);
};
export default SobrePage;

View File

@@ -79,7 +79,7 @@ const StatisticsFilters: React.FC<StatisticsFiltersProps> = ({
{/* Party Filter */}
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
Partido (Opcional)
Partido
</label>
<select
value={localFilters.partido || ''}
@@ -99,7 +99,7 @@ const StatisticsFilters: React.FC<StatisticsFiltersProps> = ({
{/* UF Filter */}
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
UF (Opcional)
UF
</label>
<select
value={localFilters.uf || ''}
@@ -119,7 +119,7 @@ const StatisticsFilters: React.FC<StatisticsFiltersProps> = ({
{/* Year Filter */}
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
Ano (Opcional)
Ano
</label>
<select
value={localFilters.ano || ''}
@@ -139,7 +139,7 @@ const StatisticsFilters: React.FC<StatisticsFiltersProps> = ({
{/* Cargo Filter */}
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
Cargo (Opcional)
Cargo
</label>
<select
value={localFilters.cargo || ''}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import type { StatisticsData } from './statisticsRequests';
import GlassCard from '../../shared/GlassCard';
import WhiteCard from '../../shared/WhiteCard';
interface StatisticsGraphsProps {
isLoading: boolean;
@@ -16,29 +16,29 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
const [currentEnrichmentIndex, setCurrentEnrichmentIndex] = React.useState(0);
if (error) {
return (
<GlassCard className="flex items-center justify-center h-64">
<WhiteCard className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-red-600 text-lg mb-2"> Erro</div>
<p className="text-gray-700">{error}</p>
</div>
</GlassCard>
</WhiteCard>
);
}
if (isLoading) {
return (
<GlassCard className="flex items-center justify-center h-64">
<WhiteCard className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-700">Carregando dados...</p>
</div>
</GlassCard>
</WhiteCard>
);
}
if (!statisticsData) {
return (
<GlassCard className="flex items-center justify-center h-64">
<WhiteCard className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-gray-500 text-4xl mb-4">📊</div>
<h3 className="text-gray-900 text-lg mb-2">Nenhum dado encontrado</h3>
@@ -46,19 +46,19 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
Os dados estatísticos não puderam ser carregados
</p>
</div>
</GlassCard>
</WhiteCard>
);
}
const renderDataTable = (title: string, data: any[], type: 'candidate' | 'party' | 'state' | 'enrichment') => {
if (!data || data.length === 0) {
return (
<GlassCard>
<WhiteCard>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
<p className="text-gray-700">Nenhum dado disponível</p>
</div>
</GlassCard>
</WhiteCard>
);
}
@@ -79,7 +79,7 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
};
return (
<GlassCard>
<WhiteCard>
<div className="relative">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
<div className="space-y-3">
@@ -95,14 +95,14 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
<div className="flex justify-between items-center">
<span className="text-gray-600">Patrimônio Inicial (<span className="text-xs text-gray-500">{enrichmentData.anoInicial}</span>):</span>
<span className="text-gray-900 font-medium">
R$ {enrichmentData.patrimonioInicial?.toLocaleString('pt-BR') || '0'}
R$ {enrichmentData.patrimonioInicial?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Patrimônio Final (<span className="text-xs text-gray-500">{enrichmentData.anoFinal}</span>):</span>
<span className="text-gray-900 font-medium">
R$ {enrichmentData.patrimonioFinal?.toLocaleString('pt-BR') || '0'}
R$ {enrichmentData.patrimonioFinal?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</span>
</div>
<div className="border-t border-gray-200 pt-3">
@@ -112,7 +112,7 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
enrichmentData.enriquecimento >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{enrichmentData.enriquecimento >= 0 ? '+' : ''}
R$ {enrichmentData.enriquecimento?.toLocaleString('pt-BR') || '0'}
R$ {enrichmentData.enriquecimento?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</span>
</div>
</div>
@@ -157,12 +157,12 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
</div>
)}
</div>
</GlassCard>
</WhiteCard>
);
}
return (
<GlassCard>
<WhiteCard>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
<div className="overflow-auto max-h-90 overflow-y-auto custom-scrollbar pr-2">
@@ -202,7 +202,7 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
)}
<td className="py-3 text-gray-900">{item.ano || 'N/A'}</td>
<td className="py-3 text-right font-medium text-gray-900">
R$ {item.valor?.toLocaleString('pt-BR') || '0'}
R$ {item.valor?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</td>
</tr>
))}
@@ -210,7 +210,7 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
</table>
</div>
</div>
</GlassCard>
</WhiteCard>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import GlassCard from '../../shared/GlassCard';
import WhiteCard from '../../shared/WhiteCard';
import StatisticsFilters from './StatisticsFilters';
import StatisticsGraphs from './StatisticsGraphs';
import { fetchAllStatisticsData, type StatisticsData, type StatisticsRequestOptions } from './statisticsRequests';
@@ -66,13 +66,16 @@ const StatisticsPage: React.FC = () => {
<p className="text-white/70 text-lg">
Análise de dados e estatísticas dos candidatos e partidos brasileiros
</p>
<p className="text-white/70 text-lg">
Para mais informações, acesse a <a href="https://sig.tse.jus.br/ords/dwapr/r/seai/sig-eleicao/home" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:underline">página de estatísticas do TSE</a> ou o <a href="https://divulgacandcontas.tse.jus.br/divulga/#/home" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:underline">DivulgaCand do TSE</a>.
</p>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
</div>
<div className="flex gap-6 ">
{/* Left Sidebar - Filters (20% width) */}
<div className="w-1/5 min-w-[300px] h-[calc(100vh-12rem)]">
<GlassCard
<WhiteCard
fullHeight
className="overflow-y-auto"
>
@@ -81,7 +84,7 @@ const StatisticsPage: React.FC = () => {
onFiltersChange={handleFiltersChange}
isLoading={isLoading}
/>
</GlassCard>
</WhiteCard>
</div>
{/* Right Content - Graphs (80% width) */}

View File

@@ -24,12 +24,14 @@ export interface StatisticsData {
}
export interface StatisticsRequestOptions {
filters?: {
filters?: StatisticsRequestFilters;
}
export interface StatisticsRequestFilters {
partido?: string | null;
uf?: string | null;
ano?: number | null;
cargo?: CargoFilter;
};
}
// First Row Requests
@@ -44,9 +46,9 @@ export async function getCandidatesWithMostAssets(options?: StatisticsRequestOpt
return Array.isArray(response) ? response : [response];
}
export async function getEnrichmentData(): Promise<EnrichmentResponse[] | null> {
export async function getEnrichmentData(filters?: StatisticsRequestFilters): Promise<EnrichmentResponse[] | null> {
try {
return await openCandApi.getStatisticsEnrichment();
return await openCandApi.getStatisticsEnrichment(filters);
} catch (error) {
console.error('Error fetching enrichment data:', error);
return null;
@@ -167,7 +169,7 @@ export async function fetchAllStatisticsData(options?: StatisticsRequestOptions)
statesWithMostRevenue
] = await Promise.all([
getCandidatesWithMostAssets(options),
getEnrichmentData(),
getEnrichmentData(options?.filters),
getCandidatesWithMostRevenue(options),
getCandidatesWithMostExpenses(options),
getPartiesWithMostAssets(options),

25
src/config/firebase.ts Normal file
View File

@@ -0,0 +1,25 @@
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
};
let app = null;
let analytics = null;
if (firebaseConfig.apiKey) {
app = initializeApp(firebaseConfig);
analytics = getAnalytics(app);
} else {
console.warn("Firebase API key is not set. Firebase will not be initialized.");
}
export { app, analytics };

View File

@@ -27,81 +27,3 @@ body {
@apply bg-gray-900 text-white;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
/* button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
} */
/* Custom minimal scrollbar styles */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 1px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 2px;
transition: background-color 0.2s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.7);
}
.custom-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
/* Ensure full width layout */
html, body, #root {
width: 100%;
margin: 0;
padding: 0;
}
#root {
min-height: 100vh;
}
/* Custom fade-in animation for dropdown content */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import './config/firebase.ts'
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@@ -5,6 +5,7 @@ interface ButtonProps {
className?: string;
hasAnimation?: boolean;
disableCursor?: boolean;
disabled?: boolean;
onClick?: () => void;
href?: string;
type?: 'button' | 'submit' | 'reset';
@@ -15,6 +16,7 @@ const Button: React.FC<ButtonProps> = ({
className = "",
hasAnimation = true,
disableCursor = false,
disabled = false,
onClick,
href,
type = 'button'
@@ -54,6 +56,7 @@ const Button: React.FC<ButtonProps> = ({
type={type}
onClick={onClick}
className={baseClasses}
disabled={disabled}
>
{children}
</button>

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { Link, type LinkProps } from 'react-router-dom';
interface GradientButtonProps extends React.PropsWithChildren {
className?: string;
to?: LinkProps['to'];
href?: string;
[x: string]: any; // To allow other props like target, rel, type, etc.
}
const GradientButton: React.FC<GradientButtonProps> = ({
children,
className = '',
to,
href,
...props
}) => {
const baseClasses =
'bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-semibold rounded-lg hover:from-indigo-600 hover:to-purple-700 transition-all duration-300 transform shadow-lg hover:shadow-xl';
const combinedClassName = `${baseClasses} ${className}`;
if (to) {
return (
<Link to={to} {...props} className={combinedClassName}>
{children}
</Link>
);
}
if (href) {
return (
<a href={href} {...props} className={combinedClassName}>
{children}
</a>
);
}
return (
<button type="button" {...props} className={combinedClassName}>
{children}
</button>
);
};
export default GradientButton;

View File

@@ -9,30 +9,44 @@ interface RandomCandButtonProps {
hasAnimation?: boolean;
}
const RandomCandButton: React.FC<RandomCandButtonProps> = ({
className = '',
hasAnimation = false
}) => {
const navigate = useNavigate();
const [loading, setLoading] = React.useState(false);
const handleRandomCandidate = async () => {
setLoading(true);
try {
const randomCandidate = await openCandApi.getRandomCandidate();
navigate(`/candidato/${randomCandidate.idCandidato}`);
} catch (error) {
console.error('Erro ao buscar candidato aleatório:', error);
// You might want to show a toast notification or error message to the user
} finally {
setLoading(false);
}
};
return (
<Button
onClick={handleRandomCandidate}
className={`flex items-center ${className}`}
className={`flex items-center relative overflow-hidden ${className}`}
hasAnimation={hasAnimation}
disabled={loading}
>
<ArrowPathIcon
className={`h-4 w-4 transition-transform duration-500 ${loading ? 'animate-spin' : 'mr-2'}`}
style={{ zIndex: 2 }}
/>
<span
className={`inline-block transition-all duration-300 origin-left whitespace-nowrap ${loading ? 'scale-x-0 max-w-0' : 'scale-x-100 max-w-xs'}`}
style={{ zIndex: 1, transitionProperty: 'transform, max-width' }}
>
<ArrowPathIcon className="h-4 w-4 mr-2" />
Candidato aleatório
</span>
</Button>
);
};

View File

@@ -1,13 +1,13 @@
import React from 'react';
interface GlassCardProps {
interface WhiteCardProps {
children: React.ReactNode;
className?: string;
fullHeight?: boolean;
padding?: string;
}
const GlassCard: React.FC<GlassCardProps> = ({
const WhiteCard: React.FC<WhiteCardProps> = ({
children,
className = '',
fullHeight = false,
@@ -27,4 +27,4 @@ const GlassCard: React.FC<GlassCardProps> = ({
);
};
export default GlassCard;
export default WhiteCard;

View File

@@ -1,19 +1,15 @@
/**
* Formats a CPF string with masking (123.***.789-10)
* Formats a CPF string (might include masking) and masks it for display.
* @param cpf - A CPF string (11 digits without punctuation)
* @returns Formatted CPF with masking or the original input if invalid
*/
export function maskCpf(cpf: string): string {
// Clean input, keeping only digits
const cleanCpf = cpf.replace(/\D/g, '');
// Validate if it's 11 digits
if (cleanCpf.length !== 11) {
if (cpf.length !== 11) {
return cpf;
}
// Format with mask: 123.***.789-10
return `${cleanCpf.slice(0, 3)}.***.${cleanCpf.slice(6, 9)}-${cleanCpf.slice(9)}`;
// Mask with standard punctuation: 123.456.789-10
return `${cpf.slice(0, 3)}.${cpf.slice(3, 6)}.${cpf.slice(6, 9)}-${cpf.slice(9)}`;
}
/**