From 112eff2acbdb0294b8ff92add2519ca7739f7d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Henrique?= Date: Fri, 30 May 2025 22:39:15 -0300 Subject: [PATCH] things working nice --- src/App.tsx | 11 +- src/api/index.ts | 2 + src/api/openCandApi.ts | 38 ++- src/components/CandidatePage.tsx | 360 +++++++++++++++++++++++++++ src/components/HeroSection.tsx | 190 +------------- src/components/SearchBar.tsx | 185 ++++++++++++++ src/components/StatisticsSection.tsx | 127 ++++++++-- 7 files changed, 688 insertions(+), 225 deletions(-) create mode 100644 src/components/CandidatePage.tsx create mode 100644 src/components/SearchBar.tsx 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 && ( +
+
+ {`Foto { + 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) => ( +
+
+
+
+ {redeSocial.rede} + ({redeSocial.ano}) +
+ + {redeSocial.link} + +
+
+
+ ))} +
+ ) : ( +
+ Nenhuma rede social encontrada +
+ )} +
+
+ + {/* Right Column - Elections and Assets */} +
+ {/* Elections Panel */} +
+
+ +

Histórico de Eleições

+
+ + {isLoadingDetails ? ( +
+
+
+ ) : candidateDetails?.eleicoes && candidateDetails.eleicoes.length > 0 ? ( +
+ + + + + + + + + + + + + + {candidateDetails.eleicoes.map((election: Election, index: number) => ( + + + + + + + + + + ))} + +
AnoTipoCargoUFLocalidadeNúmeroResultado
{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 ? ( + <> +
+ + + + + + + + + + + {candidateAssets.bens.map((asset: Asset, index: number) => ( + + + + + + + ))} + +
AnoTipoDescriçãoValor
{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.

-
-
-
- - - {isLoading && ( -
-
-
- )} - {searchQuery && !isLoading && ( - - )} -
-
- - {/* 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 = ({ className = '' }) => { + 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 = await openCandApi.searchCandidates(query.trim()); + setSearchResults(result.candidatos); // 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 ( +
+
+
+ + + {isLoading && ( +
+
+
+ )} + {searchQuery && !isLoading && ( + + )} +
+
+ + {/* Search Results Dropdown */} + {showResults && (searchResults.length > 0 || error) && ( +
+ {error ? ( +
+ {error} +
+ ) : ( +
+ {searchResults.map((candidate) => ( + + ))} + + {searchResults.length === 10 && ( +
+ Mostrando os primeiros 10 resultados +
+ )} +
+ )} +
+ )} + + {/* No results message */} + {showResults && searchResults.length === 0 && !error && !isLoading && searchQuery.length >= 2 && ( +
+
+ Nenhum candidato encontrado para "{searchQuery}" +
+
+ )} +
+ ); +}; + +export default SearchBar; diff --git a/src/components/StatisticsSection.tsx b/src/components/StatisticsSection.tsx index 925c0ff..27546d7 100644 --- a/src/components/StatisticsSection.tsx +++ b/src/components/StatisticsSection.tsx @@ -1,37 +1,106 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { openCandApi, type PlatformStats, ApiError } from '../api'; interface StatCardProps { title: string; value: string; description: string; + isLoading?: boolean; } -const StatCard: React.FC = ({ title, value, description }) => { +const StatCard: React.FC = ({ title, value, description, isLoading = false }) => { return (

{title}

-

{value}

+ {isLoading ? ( +
+
+
+ ) : ( +

{value}

+ )}

{description}

); }; const StatisticsSection: React.FC = () => { - const stats = [ + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchStats = async () => { + try { + setIsLoading(true); + setError(null); + const platformStats = await openCandApi.getStats(); + setStats(platformStats); + } catch (err) { + console.error('Error fetching platform stats:', err); + if (err instanceof ApiError) { + setError(`Erro ao carregar estatísticas: ${err.message}`); + } else { + setError('Erro inesperado ao carregar estatísticas'); + } + // Use default stats as fallback + setStats({ + totalCandidatos: 0, + totalBemCandidatos: 0, + totalEleicoes: 0, + totalValorBemCandidatos: 0, + totalRedesSociais: 0 + }); + } finally { + setIsLoading(false); + } + }; + + fetchStats(); + }, []); + + const formatNumber = (num: number): string => { + if (num >= 1000000000000) { + return `${(num / 1000000000000).toFixed(1)} trilhão`; + } else if (num >= 1000000000) { + return `${(num / 1000000000).toFixed(1)} bilhão`; + } else if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)} milhão`; + } else if (num >= 1000) { + return `${(num / 1000).toFixed(0)}K`; + } + return num.toLocaleString('pt-BR'); + }; + + const formatCurrency = (value: number): string => { + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + notation: 'compact', + maximumSignificantDigits: 3 + }).format(value); + }; + + const statisticsData = [ { title: "Total de Candidatos", - value: "+500.000", - description: "Registros de candidaturas desde 2014" + value: isLoading ? "" : `+${formatNumber(stats?.totalCandidatos || 0)}`, + description: "Registros de candidaturas na plataforma" }, { - title: "Total de Bens Declarados", - value: "R$ +1 Trilhão", - description: "Patrimônio agregado declarado pelos candidatos" + title: "Total de Bens Registrados", + value: isLoading ? "" : `+${formatNumber(stats?.totalBemCandidatos || 0)}`, + description: isLoading ? "" : `Somando ${formatCurrency(stats?.totalValorBemCandidatos || 0)} em Patrimônio agregado declarado pelos candidatos` }, { - title: "Anos de Eleição Processados", - value: "2014 - 2024", - description: "Cobertura das últimas eleições gerais e municipais" + title: "Total de Redes Sociais", + value: isLoading ? "" : formatNumber(stats?.totalRedesSociais || 0), + description: "Redes sociais conectadas aos candidatos" + }, + { + title: "Total de Eleições", + value: isLoading ? "" : formatNumber(stats?.totalEleicoes || 0), + description: "Eleições processadas no sistema" } ]; @@ -41,10 +110,38 @@ const StatisticsSection: React.FC = () => {

Dados em Números

-
- {stats.map((stat, index) => ( - + {error && !isLoading && ( +
+

+ ⚠️ Usando dados de demonstração - {error} +

+
+ )} +
+ {statisticsData.slice(0, 3).map((stat, index) => ( +
+ +
))} + {statisticsData.length > 3 && ( +
+ {statisticsData.slice(3).map((stat, index) => ( +
+ +
+ ))} +
+ )}