Compare commits

...

6 Commits

Author SHA1 Message Date
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
21 changed files with 469 additions and 140 deletions

View File

@ -20,6 +20,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>

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

@ -8,6 +8,7 @@ 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';
@ -16,6 +17,7 @@ import './App.css';
const HomePage: React.FC = () => (
<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 />
@ -34,6 +36,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,6 +1,6 @@
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';
/**
@ -27,6 +27,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
*/

View File

@ -11,12 +11,23 @@ interface IncomeExpenseComponentProps {
isLoadingIncome: boolean;
}
const hasIncomeData = (income: CandidateIncome | null) => {
if (!income || income.receitas.length === 0) return false;
};
const hasExpenseData = (expenses: CandidateExpenses | null) => {
if (!expenses || expenses.despesas.length === 0) return false;
};
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 +36,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 +52,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

@ -1,13 +1,17 @@
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';
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 +26,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>
);
@ -68,14 +84,14 @@ const DataStatsPage: React.FC = () => {
{ 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: '📸' },
{ key: 'fotosCandidatos', label: 'Fotos de Candidatos (API)', 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,6 +101,23 @@ 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>
<a
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 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"
>
<FaGoogleDrive className="text-xl" />
Google Drive
</a>
</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">
@ -133,7 +166,7 @@ const DataStatsPage: React.FC = () => {
{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"
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}
@ -178,6 +211,131 @@ const DataStatsPage: React.FC = () => {
</div>
</div>
</div>
{/* 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>
{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>
) : dbError ? (
<div className="p-8 text-red-400">{dbError}</div>
) : dbStats ? (
<div className="p-6 space-y-10">
{/* Tables */}
<div>
<h3 className="text-xl font-semibold text-white mb-2">Tabelas</h3>
<div className="overflow-x-auto max-h-112" 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>
{dbStats.tables.map((table) => {
const name = table.name.replace(/^public\./, '');
const sizeMB = table.totalSize / 1024 / 1024;
const sizeDisplay = sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
return (
<tr key={table.name} className="hover:bg-gray-800/10 transition-all duration-200">
<td className="p-3 text-white">{name}</td>
<td className="p-3 text-gray-300">{sizeDisplay}</td>
<td className="p-3 text-gray-300">{table.entries.toLocaleString()}</td>
</tr>
);
})}
{/* Total row */}
{(() => {
const totalSize = dbStats.tables.reduce((acc, t) => acc + t.totalSize, 0);
const totalEntries = dbStats.tables.reduce((acc, t) => acc + t.entries, 0);
const totalMB = totalSize / 1024 / 1024;
const totalDisplay = totalMB > 1024
? `${(totalMB / 1024).toFixed(2)} GB`
: `${totalMB.toFixed(2)} MB`;
return (
<tr className="font-bold bg-gray-900/30">
<td className="p-3 text-white">Total</td>
<td className="p-3 text-gray-300">{totalDisplay}</td>
<td className="p-3 text-gray-300">{totalEntries.toLocaleString()}</td>
</tr>
);
})()}
</tbody>
</table>
</div>
</div>
{/* Materialized Views */}
<div>
<h3 className="text-xl font-semibold text-white mb-2">Views Materializadas</h3>
<div className="overflow-x-auto max-h-96" style={{maxHeight: '28rem'}}>
<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>
{dbStats.materializedViews.map((view) => {
const name = view.name.replace(/^public\./, '');
const sizeMB = view.totalSize / 1024 / 1024;
const sizeDisplay = sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
return (
<tr key={view.name} className="hover:bg-gray-800/10 transition-all duration-200">
<td className="p-3 text-white">{name}</td>
<td className="p-3 text-gray-300">{sizeDisplay}</td>
<td className="p-3 text-gray-300">{view.entries.toLocaleString()}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Indexes */}
<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">{dbStats.indexes.amount}</td>
{(() => {
const sizeMB = dbStats.indexes.size / 1024 / 1024;
const sizeDisplay = sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
return <td className="p-3 text-gray-300">{sizeDisplay}</td>;
})()}
</tr>
</tbody>
</table>
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
);
};

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

@ -6,7 +6,7 @@ const HeroSection: React.FC = () => {
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="relative z-10 text-center max-w-6xl">
@ -26,6 +26,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

@ -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

@ -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

@ -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>
@ -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>
))}

View File

@ -66,6 +66,9 @@ 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>

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

@ -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,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)}`;
}
/**