diff --git a/src/App.tsx b/src/App.tsx
index d112b8f..03d70ab 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,7 @@ import HeroSection from './components/HeroSection';
import StatisticsSection from './components/StatisticsSection';
import FeaturesSection from './components/FeaturesSection';
import Footer from './components/Footer';
+import CandidatePage from './components/CandidatePage';
import './App.css';
// HomePage component
@@ -16,16 +17,6 @@ const HomePage: React.FC = () => (
);
-// Placeholder for candidate detail page
-const CandidatePage: React.FC = () => (
-
-
-
Página do Candidato
-
Esta página será implementada em breve.
-
-
-);
-
function App() {
return (
diff --git a/src/api/index.ts b/src/api/index.ts
index b08f48a..7d4d1fb 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -7,6 +7,8 @@ export type {
Election,
CandidateAssets,
Asset,
+ CandidateRedesSociais,
+ RedeSocial,
PlatformStats,
} from './openCandApi';
diff --git a/src/api/openCandApi.ts b/src/api/openCandApi.ts
index 59561b6..82fab7a 100644
--- a/src/api/openCandApi.ts
+++ b/src/api/openCandApi.ts
@@ -15,6 +15,7 @@ export interface Candidate {
estadoCivil: string;
sexo: string;
ocupacao: string;
+ fotoUrl: string;
}
export interface CandidateDetails extends Candidate {
@@ -22,10 +23,12 @@ export interface CandidateDetails extends Candidate {
}
export interface Election {
- sqid: string;
- tipoeleicao: string;
- siglaUf: string;
- nomeue: string;
+ sqCandidato: string;
+ tipoEleicao: string;
+ cargo: string;
+ ano: number;
+ siglaUF: string;
+ nomeUE: string;
nrCandidato: string;
nomeCandidato: string;
resultado: string;
@@ -43,9 +46,23 @@ export interface Asset {
valor: number;
}
+export interface CandidateRedesSociais {
+ redesSociais: RedeSocial[];
+}
+
+export interface RedeSocial {
+ idCandidato: string;
+ rede: "Facebook" | "Instagram" | "X/Twitter" | "TikTok" | "YouTube" | "LinkedIn" | "WhatsApp" | "Threads" | "Telegram" | "Kwai" | "Spotify" | "Outros";
+ siglaUF: string;
+ link: string;
+ ano: number;
+}
+
export interface PlatformStats {
totalCandidatos: number;
totalBemCandidatos: number;
+ totalValorBemCandidatos: number;
+ totalRedesSociais: number;
totalEleicoes: number;
}
@@ -91,16 +108,11 @@ export class OpenCandApi extends BaseApiClient {
}
/**
- * Search candidates and get their details (limited to first 10 results to avoid too many requests)
+ * Get the assets of a specific candidate by ID
+ * GET /v1/candidato/{id}/bens
*/
- async searchCandidatesWithDetails(
- query: string,
- limit: number = 10
- ): Promise
{
- const searchResult = await this.searchCandidates(query);
- const candidatesLimit = searchResult.candidatos.slice(0, limit);
-
- return candidatesLimit;
+ async getCandidateRedesSociais(id: string): Promise {
+ return this.get(`/v1/candidato/${id}/rede-social`);
}
}
diff --git a/src/components/CandidatePage.tsx b/src/components/CandidatePage.tsx
new file mode 100644
index 0000000..e6712d5
--- /dev/null
+++ b/src/components/CandidatePage.tsx
@@ -0,0 +1,360 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { ArrowLeftIcon, UserIcon, DocumentTextIcon, CurrencyDollarIcon, LinkIcon } from '@heroicons/react/24/outline';
+import { openCandApi, type CandidateDetails, type CandidateAssets, type CandidateRedesSociais, type Election, type Asset, type RedeSocial, ApiError } from '../api';
+
+const CandidatePage: React.FC = () => {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const [candidateDetails, setCandidateDetails] = useState(null);
+ const [candidateAssets, setCandidateAssets] = useState(null);
+ const [candidateRedesSociais, setCandidateRedesSociais] = useState(null);
+ const [isLoadingDetails, setIsLoadingDetails] = useState(true);
+ const [isLoadingAssets, setIsLoadingAssets] = useState(true);
+ const [isLoadingRedesSociais, setIsLoadingRedesSociais] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!id) {
+ navigate('/');
+ return;
+ }
+
+ // Fetch candidate details
+ const fetchCandidateDetails = async () => {
+ try {
+ setIsLoadingDetails(true);
+ const details = await openCandApi.getCandidateById(id);
+ setCandidateDetails(details);
+ } catch (err) {
+ console.error('Error fetching candidate details:', err);
+ if (err instanceof ApiError) {
+ setError(`Erro ao carregar dados do candidato: ${err.message}`);
+ } else {
+ setError('Erro inesperado ao carregar dados do candidato');
+ }
+ } finally {
+ setIsLoadingDetails(false);
+ }
+ };
+
+ // Fetch candidate assets
+ const fetchCandidateAssets = async () => {
+ try {
+ setIsLoadingAssets(true);
+ const assets = await openCandApi.getCandidateAssets(id);
+ setCandidateAssets(assets);
+ } catch (err) {
+ console.error('Error fetching candidate assets:', err);
+ // Assets might not be available for all candidates, so we don't set error here
+ } finally {
+ setIsLoadingAssets(false);
+ }
+ };
+
+ // Fetch candidate social media
+ const fetchCandidateRedesSociais = async () => {
+ try {
+ setIsLoadingRedesSociais(true);
+ const redesSociais = await openCandApi.getCandidateRedesSociais(id);
+ setCandidateRedesSociais(redesSociais);
+ } catch (err) {
+ console.error('Error fetching candidate social media:', err);
+ // Social media might not be available for all candidates, so we don't set error here
+ } finally {
+ setIsLoadingRedesSociais(false);
+ }
+ };
+
+ fetchCandidateDetails();
+ fetchCandidateAssets();
+ fetchCandidateRedesSociais();
+ }, [id, navigate]);
+
+ const formatCurrency = (value: number) => {
+ return new Intl.NumberFormat('pt-BR', {
+ style: 'currency',
+ currency: 'BRL'
+ }).format(value);
+ };
+
+ const formatDate = (dateString: string) => {
+ try {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('pt-BR');
+ } catch {
+ return dateString;
+ }
+ };
+
+ if (error) {
+ return (
+
+
+
Erro
+
{error}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header with back button */}
+
+
+
+ {candidateDetails && (
+
{candidateDetails.nome}
+ )}
+
+
+
+ {/* Left Column - Basic Information and Social Media */}
+
+ {/* Basic Information Panel */}
+
+
+
+
Informações Básicas
+
+
+ {isLoadingDetails ? (
+
+ ) : candidateDetails ? (
+
+ {/* Candidate Photo */}
+ {candidateDetails.fotoUrl && (
+
+
+

{
+ const target = e.target as HTMLImageElement;
+ target.style.display = 'none';
+ }}
+ />
+
+
+ )}
+
+
+
+
{candidateDetails.nome}
+
+
+
+
+
{candidateDetails.cpf}
+
+
+
+
+
{formatDate(candidateDetails.dataNascimento)}
+
+
+ {candidateDetails.email && (
+
+
+
{candidateDetails.email}
+
+ )}
+
+
+
+
{candidateDetails.estadoCivil}
+
+
+
+
+
{candidateDetails.sexo}
+
+
+
+
+
{candidateDetails.ocupacao}
+
+
+ ) : (
+
+ Nenhuma informação encontrada
+
+ )}
+
+
+ {/* Social Media Panel */}
+
+
+
+
Redes Sociais
+
+
+ {isLoadingRedesSociais ? (
+
+ ) : candidateRedesSociais?.redesSociais && candidateRedesSociais.redesSociais.length > 0 ? (
+
+ {candidateRedesSociais.redesSociais.map((redeSocial: RedeSocial, index: number) => (
+
+ ))}
+
+ ) : (
+
+ Nenhuma rede social encontrada
+
+ )}
+
+
+
+ {/* Right Column - Elections and Assets */}
+
+ {/* Elections Panel */}
+
+
+
+
Histórico de Eleições
+
+
+ {isLoadingDetails ? (
+
+ ) : candidateDetails?.eleicoes && candidateDetails.eleicoes.length > 0 ? (
+
+
+
+
+ Ano |
+ Tipo |
+ Cargo |
+ UF |
+ Localidade |
+ Número |
+ Resultado |
+
+
+
+ {candidateDetails.eleicoes.map((election: Election, index: number) => (
+
+ {election.ano} |
+ {election.tipoEleicao} |
+ {election.cargo} |
+ {election.siglaUF} |
+ {election.nomeUE} |
+ {election.nrCandidato} |
+
+
+ {election.resultado}
+
+ |
+
+ ))}
+
+
+
+ ) : (
+
+ Nenhum histórico de eleições encontrado
+
+ )}
+
+
+ {/* Assets Panel */}
+
+
+
+
Patrimônio Declarado
+
+
+ {isLoadingAssets ? (
+
+ ) : candidateAssets?.bens && candidateAssets.bens.length > 0 ? (
+ <>
+
+
+
+
+ Ano |
+ Tipo |
+ Descrição |
+ Valor |
+
+
+
+ {candidateAssets.bens.map((asset: Asset, index: number) => (
+
+ {asset.ano} |
+ {asset.tipoBem} |
+ {asset.descricao} |
+
+ {formatCurrency(asset.valor)}
+ |
+
+ ))}
+
+
+
+
+ {/* Total Assets */}
+
+
+ Total Declarado:
+
+ {formatCurrency(candidateAssets.bens.reduce((total, asset) => total + asset.valor, 0))}
+
+
+
+ >
+ ) : (
+
+ Nenhum patrimônio declarado encontrado
+
+ )}
+
+
+
+
+ );
+};
+
+export default CandidatePage;
diff --git a/src/components/HeroSection.tsx b/src/components/HeroSection.tsx
index b2826a4..250cfa0 100644
--- a/src/components/HeroSection.tsx
+++ b/src/components/HeroSection.tsx
@@ -1,115 +1,7 @@
-import React, { useState, useEffect, useCallback, useRef } from 'react';
-import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/solid';
-import { useNavigate } from 'react-router-dom';
-import { openCandApi, type Candidate, ApiError } from '../api';
-import { mockSearchCandidates, DEMO_CONFIG } from '../config/demo';
+import React from 'react';
+import SearchBar from './SearchBar';
const HeroSection: React.FC = () => {
- const [searchQuery, setSearchQuery] = useState('');
- const [searchResults, setSearchResults] = useState([]);
- const [isLoading, setIsLoading] = useState(false);
- const [showResults, setShowResults] = useState(false);
- const [error, setError] = useState(null);
- const navigate = useNavigate();
- const searchTimeoutRef = useRef(null);
- const resultsRef = useRef(null);
-
- // Debounced search function
- const performSearch = useCallback(async (query: string) => {
- if (query.trim().length < 2) {
- setSearchResults([]);
- setShowResults(false);
- setError(null);
- return;
- }
-
- setIsLoading(true);
- setError(null);
-
- try {
- let result;
-
- // Use mock data if configured or if API call fails
- if (DEMO_CONFIG.useMockData) {
- result = await mockSearchCandidates(query.trim());
- } else {
- try {
- result = await openCandApi.searchCandidates(query.trim());
- } catch (apiError) {
- console.warn('API call failed, falling back to mock data:', apiError);
- result = await mockSearchCandidates(query.trim());
- }
- }
-
- setSearchResults(result.candidatos.slice(0, 8)); // Limit to 8 results
- setShowResults(true);
- } catch (err) {
- if (err instanceof ApiError) {
- setError(`Erro na busca: ${err.message}`);
- } else {
- setError('Erro inesperado na busca');
- }
- setSearchResults([]);
- setShowResults(false);
- } finally {
- setIsLoading(false);
- }
- }, []);
-
- // Handle input change with debouncing
- const handleInputChange = useCallback((e: React.ChangeEvent) => {
- const query = e.target.value;
- setSearchQuery(query);
-
- // Clear previous timeout
- if (searchTimeoutRef.current) {
- clearTimeout(searchTimeoutRef.current);
- }
-
- // Set new timeout for debounced search
- searchTimeoutRef.current = window.setTimeout(() => {
- performSearch(query);
- }, 300); // 300ms delay
- }, [performSearch]);
-
- // Handle candidate selection
- const handleCandidateSelect = useCallback((candidate: Candidate) => {
- navigate(`/candidato/${candidate.idCandidato}`);
- setShowResults(false);
- setSearchQuery('');
- }, [navigate]);
-
- // Handle form submission
- const handleSubmit = useCallback((e: React.FormEvent) => {
- e.preventDefault();
- if (searchResults.length > 0) {
- handleCandidateSelect(searchResults[0]);
- }
- }, [searchResults, handleCandidateSelect]);
-
- // Close results when clicking outside
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (resultsRef.current && !resultsRef.current.contains(event.target as Node)) {
- setShowResults(false);
- }
- };
-
- document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, []);
-
- // Clear timeout on unmount
- useEffect(() => {
- return () => {
- if (searchTimeoutRef.current) {
- clearTimeout(searchTimeoutRef.current);
- }
- };
- }, []);
-
return (
{
OpenCand oferece acesso fácil e visualizações intuitivas de dados abertos do Tribunal Superior Eleitoral (TSE) do Brasil.
-
-
-
- {/* Search Results Dropdown */}
- {showResults && (searchResults.length > 0 || error) && (
-
- {error ? (
-
- {error}
-
- ) : (
-
- {searchResults.map((candidate) => (
-
- ))}
-
- {searchResults.length === 8 && (
-
- Mostrando os primeiros 8 resultados
-
- )}
-
- )}
-
- )}
-
- {/* No results message */}
- {showResults && searchResults.length === 0 && !error && !isLoading && searchQuery.length >= 2 && (
-
-
- Nenhum candidato encontrado para "{searchQuery}"
-
-
- )}
-
+
);
diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx
new file mode 100644
index 0000000..e35d790
--- /dev/null
+++ b/src/components/SearchBar.tsx
@@ -0,0 +1,185 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/solid';
+import { useNavigate } from 'react-router-dom';
+import { openCandApi, type Candidate, ApiError } from '../api';
+import { mockSearchCandidates, DEMO_CONFIG } from '../config/demo';
+
+interface SearchBarProps {
+ className?: string;
+}
+
+const SearchBar: React.FC