From 557a157226157770c42cf8d24148b60e70eb84a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Henrique?= Date: Sat, 31 May 2025 14:19:23 -0300 Subject: [PATCH] initial fixes and enhancements --- src/App.tsx | 2 +- src/api/apiModels.ts | 68 ++++ src/api/index.ts | 2 +- src/api/openCandApi.ts | 79 +--- src/components/CandidatePage.tsx | 360 ------------------ .../CandidatePage/AssetsComponent.tsx | 75 ++++ .../BasicCandidateInfoComponent.tsx | 135 +++++++ .../CandidatePage/CandidatePage.tsx | 147 +++++++ .../CandidatePage/ElectionsComponent.tsx | 87 +++++ .../CandidatePage/SocialMediaComponent.tsx | 57 +++ src/components/SearchBar.tsx | 14 +- src/components/Tooltip.tsx | 180 +++++++++ src/examples/apiUsage.ts | 27 -- src/utils/utils.ts | 58 +++ 14 files changed, 831 insertions(+), 460 deletions(-) create mode 100644 src/api/apiModels.ts delete mode 100644 src/components/CandidatePage.tsx create mode 100644 src/components/CandidatePage/AssetsComponent.tsx create mode 100644 src/components/CandidatePage/BasicCandidateInfoComponent.tsx create mode 100644 src/components/CandidatePage/CandidatePage.tsx create mode 100644 src/components/CandidatePage/ElectionsComponent.tsx create mode 100644 src/components/CandidatePage/SocialMediaComponent.tsx create mode 100644 src/components/Tooltip.tsx delete mode 100644 src/examples/apiUsage.ts create mode 100644 src/utils/utils.ts diff --git a/src/App.tsx b/src/App.tsx index 03d70ab..0fe60bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +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 CandidatePage from './components/CandidatePage/CandidatePage'; import './App.css'; // HomePage component diff --git a/src/api/apiModels.ts b/src/api/apiModels.ts new file mode 100644 index 0000000..a0b5d17 --- /dev/null +++ b/src/api/apiModels.ts @@ -0,0 +1,68 @@ +// Type definitions based on the API specs +export interface CandidateSearchResult { + candidatos: Candidate[]; +} + +export interface Candidate { + idCandidato: string; + nome: string; + cpf: string; + dataNascimento: string; + email: string; + estadoCivil: string; + sexo: string; + ocupacao: string; + fotoUrl: string; +} + +export interface CandidateDetails extends Candidate { + eleicoes: Election[]; +} + +export interface Election { + sqCandidato: string; + tipoEleicao: string; + cargo: string; + ano: number; + siglaUF: string; + nomeUE: string; + nrCandidato: string; + nomeCandidato: string; + resultado: string; +} + +export interface CandidateAssets { + bens: Asset[]; +} + +export interface Asset { + idCandidato: string; + ano: number; + tipoBem: string; + descricao: string; + 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; +} + +export interface CpfRevealResult { + cpf: string; +} \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts index 7d4d1fb..885ac21 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -10,7 +10,7 @@ export type { CandidateRedesSociais, RedeSocial, PlatformStats, -} from './openCandApi'; +} from './apiModels'; // Export base API classes for custom implementations export { BaseApiClient, ApiError } from './base'; diff --git a/src/api/openCandApi.ts b/src/api/openCandApi.ts index 82fab7a..05e9fcf 100644 --- a/src/api/openCandApi.ts +++ b/src/api/openCandApi.ts @@ -1,70 +1,6 @@ import { BaseApiClient } from './base'; import { API_CONFIG } from '../config/api'; - -// Type definitions based on the API specs -export interface CandidateSearchResult { - candidatos: Candidate[]; -} - -export interface Candidate { - idCandidato: string; - nome: string; - cpf: string; - dataNascimento: string; - email: string; - estadoCivil: string; - sexo: string; - ocupacao: string; - fotoUrl: string; -} - -export interface CandidateDetails extends Candidate { - eleicoes: Election[]; -} - -export interface Election { - sqCandidato: string; - tipoEleicao: string; - cargo: string; - ano: number; - siglaUF: string; - nomeUE: string; - nrCandidato: string; - nomeCandidato: string; - resultado: string; -} - -export interface CandidateAssets { - bens: Asset[]; -} - -export interface Asset { - idCandidato: string; - ano: number; - tipoBem: string; - descricao: string; - 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; -} +import type { CandidateAssets, CandidateDetails, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, PlatformStats } from './apiModels'; /** * OpenCand API client for interacting with the OpenCand platform @@ -84,7 +20,6 @@ export class OpenCandApi extends BaseApiClient { /** * Search for candidates by name or other attributes - * GET /v1/candidato/search?q={query} */ async searchCandidates(query: string): Promise { const encodedQuery = encodeURIComponent(query); @@ -93,7 +28,6 @@ export class OpenCandApi extends BaseApiClient { /** * Get detailed information about a specific candidate by ID - * GET /v1/candidato/{id} */ async getCandidateById(id: string): Promise { return this.get(`/v1/candidato/${id}`); @@ -101,19 +35,24 @@ export class OpenCandApi extends BaseApiClient { /** * Get the assets of a specific candidate by ID - * GET /v1/candidato/{id}/bens */ async getCandidateAssets(id: string): Promise { return this.get(`/v1/candidato/${id}/bens`); } /** - * Get the assets of a specific candidate by ID - * GET /v1/candidato/{id}/bens + * Get the social networks of a specific candidate by ID */ async getCandidateRedesSociais(id: string): Promise { return this.get(`/v1/candidato/${id}/rede-social`); } + + /** + * Get the social networks of a specific candidate by ID + */ + async getCandidateCpf(id: string): Promise { + return this.get(`/v1/candidato/${id}/reveal-cpf`); + } } // Create a default instance for easy usage with proper configuration diff --git a/src/components/CandidatePage.tsx b/src/components/CandidatePage.tsx deleted file mode 100644 index e6712d5..0000000 --- a/src/components/CandidatePage.tsx +++ /dev/null @@ -1,360 +0,0 @@ -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/CandidatePage/AssetsComponent.tsx b/src/components/CandidatePage/AssetsComponent.tsx new file mode 100644 index 0000000..67319c1 --- /dev/null +++ b/src/components/CandidatePage/AssetsComponent.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { CurrencyDollarIcon } from '@heroicons/react/24/outline'; +import { type Asset } from '../../api'; + +interface AssetsComponentProps { + assets: Asset[] | null; + isLoading: boolean; +} + +const AssetsComponent: React.FC = ({ assets, isLoading }) => { + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL' + }).format(value); + }; + + return ( +
+
+ +

Patrimônio Declarado

+
+ + {isLoading ? ( +
+
+
+ ) : assets && assets.length > 0 ? ( + <> +
+ + + + + + + + + + + {assets.map((asset: Asset, index: number) => ( + + + + + + + ))} + +
AnoTipoDescriçãoValor
{asset.ano}{asset.tipoBem}{asset.descricao} + {formatCurrency(asset.valor)} +
+
+ + {/* Total Assets */} +
+
+ Total Declarado: + + {formatCurrency(assets.reduce((total, asset) => total + asset.valor, 0))} + +
+
+ + ) : ( +
+ Nenhum patrimônio declarado encontrado +
+ )} +
+ ); +}; + +export default AssetsComponent; diff --git a/src/components/CandidatePage/BasicCandidateInfoComponent.tsx b/src/components/CandidatePage/BasicCandidateInfoComponent.tsx new file mode 100644 index 0000000..3ed5ff1 --- /dev/null +++ b/src/components/CandidatePage/BasicCandidateInfoComponent.tsx @@ -0,0 +1,135 @@ +import React, { useState } from 'react'; +import { UserIcon } from '@heroicons/react/24/outline'; +import { type CandidateDetails, openCandApi } from '../../api'; +import { formatCpf, maskCpf } from '../../utils/utils'; + +interface BasicCandidateInfoComponentProps { + candidateDetails: CandidateDetails | null; + isLoading: boolean; +} + +const BasicCandidateInfoComponent: React.FC = ({ + candidateDetails, + isLoading +}) => { + const [revealedCpf, setRevealedCpf] = useState(null); + const [isRevealingCpf, setIsRevealingCpf] = useState(false); + + const formatDate = (dateString: string) => { + try { + const date = new Date(dateString); + return date.toLocaleDateString('pt-BR'); + } catch { + return dateString; + } + }; + + const handleCpfClick = async () => { + if (!candidateDetails?.idCandidato || isRevealingCpf || revealedCpf) return; + + setIsRevealingCpf(true); + try { + const cpfResult = await openCandApi.getCandidateCpf(candidateDetails?.idCandidato || ''); + setRevealedCpf(formatCpf(cpfResult.cpf)); + } catch (error) { + console.error('Failed to reveal CPF:', error); + } finally { + setIsRevealingCpf(false); + } + }; + + return ( +
+
+ +

Informações Básicas

+
+ + {isLoading ? ( +
+
+
+ ) : candidateDetails ? ( +
+ {/* Candidate Photo */} + {candidateDetails.fotoUrl && ( +
+
+ {`Foto { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + }} + /> +
+
+ )} + +
+ +

{candidateDetails.nome}

+
+ + {candidateDetails.cpf && +
+ +
+ {isRevealingCpf ? ( +
+
+ Carregando... +
+ ) : ( + + {revealedCpf || maskCpf(candidateDetails.cpf)} + + )} +
+
+ } + +
+ +

{formatDate(candidateDetails.dataNascimento)}

+
+ + {candidateDetails.email && ( +
+ +

{candidateDetails.email}

+
+ )} + +
+ +

{candidateDetails.estadoCivil}

+
+ +
+ +

{candidateDetails.sexo}

+
+ +
+ +

{candidateDetails.ocupacao}

+
+
+ ) : ( +
+ Nenhuma informação encontrada +
+ )} +
+ ); +}; + +export default BasicCandidateInfoComponent; diff --git a/src/components/CandidatePage/CandidatePage.tsx b/src/components/CandidatePage/CandidatePage.tsx new file mode 100644 index 0000000..4003546 --- /dev/null +++ b/src/components/CandidatePage/CandidatePage.tsx @@ -0,0 +1,147 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ArrowLeftIcon } from '@heroicons/react/24/outline'; +import { openCandApi, type CandidateDetails, type CandidateAssets, type CandidateRedesSociais, ApiError } from '../../api'; +import ElectionsComponent from './ElectionsComponent'; +import AssetsComponent from './AssetsComponent'; +import BasicCandidateInfoComponent from './BasicCandidateInfoComponent'; +import SocialMediaComponent from './SocialMediaComponent'; + +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]); + + if (error) { + return ( +
+
+

Erro

+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Header with back button */} +
+ + + {candidateDetails && ( +

{candidateDetails.nome}

+ )} +
+ +
+ {/* Left Column - Basic Information and Social Media */} +
+ {/* Basic Information Panel */} + + + {/* Social Media Panel */} + +
+ + {/* Right Column - Elections and Assets */} +
+ {/* Elections Panel */} + + + {/* Assets Panel */} + +
+
+
+ ); +}; + +export default CandidatePage; diff --git a/src/components/CandidatePage/ElectionsComponent.tsx b/src/components/CandidatePage/ElectionsComponent.tsx new file mode 100644 index 0000000..ac62ee0 --- /dev/null +++ b/src/components/CandidatePage/ElectionsComponent.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { DocumentTextIcon } from '@heroicons/react/24/outline'; +import { type Election } from '../../api'; +import Tooltip from '../Tooltip'; + +interface ElectionsComponentProps { + elections: Election[] | null; + isLoading: boolean; +} + +const ElectionsComponent: React.FC = ({ elections, isLoading }) => { + const getResultadoHelper = (resultado: string | null) => { + resultado = resultado?.toLowerCase() || ''; + if (resultado == 'eleito') return 'Eleito!'; + if (resultado == 'não eleito') return 'Não Eleito'; + if (resultado == 'eleito por qp') return 'Eleito por Quociente Partidário'; + if (resultado.includes('nulo')) return 'Dado não disponível'; + if (resultado == '2º turno') return 'Foi para o segundo Turno'; + if (resultado == 'suplente') return 'Foi eleito como suplente'; + return ''; + } + + + return ( +
+
+ +

Histórico de Eleições

+
+ + {isLoading ? ( +
+
+
+ ) : elections && elections.length > 0 ? ( +
+ + + + + + + + + + + + + + {elections.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 +
+ )} +
+ ); +}; + +export default ElectionsComponent; diff --git a/src/components/CandidatePage/SocialMediaComponent.tsx b/src/components/CandidatePage/SocialMediaComponent.tsx new file mode 100644 index 0000000..ebe5375 --- /dev/null +++ b/src/components/CandidatePage/SocialMediaComponent.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { LinkIcon } from '@heroicons/react/24/outline'; +import { type RedeSocial } from '../../api'; + +interface SocialMediaComponentProps { + redesSociais: RedeSocial[] | null; + isLoading: boolean; +} + +const SocialMediaComponent: React.FC = ({ + redesSociais, + isLoading +}) => { + return ( +
+
+ +

Redes Sociais

+
+ + {isLoading ? ( +
+
+
+ ) : redesSociais && redesSociais.length > 0 ? ( +
+ {redesSociais.map((redeSocial: RedeSocial, index: number) => ( +
+
+
+
+ {redeSocial.rede} + ({redeSocial.ano}) +
+ + {redeSocial.link} + +
+
+
+ ))} +
+ ) : ( +
+ Nenhuma rede social encontrada +
+ )} +
+ ); +}; + +export default SocialMediaComponent; diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 2faf85d..5984f3b 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -2,6 +2,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 { formatDateToDDMMYYYY, maskCpf } from '../utils/utils'; interface SearchBarProps { className?: string; @@ -91,6 +92,17 @@ const SearchBar: React.FC = ({ className = '' }) => { }; }, []); + const getCandidateDescription = (candidate: Candidate) => { + let desc = ['']; + + if (candidate.cpf) desc.push(`CPF: ${maskCpf(candidate.cpf)}`); + if (candidate.ocupacao && candidate.ocupacao != 'OUTROS') desc.push(`${candidate.ocupacao}`); + if (desc.length == 0) + if (candidate.dataNascimento) desc.push(`${formatDateToDDMMYYYY(candidate.dataNascimento)}`); + + return desc.filter(Boolean).join(' | '); + } + // Clear timeout on unmount useEffect(() => { return () => { @@ -151,7 +163,7 @@ const SearchBar: React.FC = ({ className = '' }) => { >
{candidate.nome}
- CPF: {candidate.cpf} | {candidate.ocupacao} + {getCandidateDescription(candidate)}
{candidate.email && (
{candidate.email}
diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 0000000..e1696a7 --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,180 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +interface TooltipProps { + content: string; + children: React.ReactNode; + position?: 'top' | 'bottom' | 'left' | 'right'; + delay?: number; + className?: string; +} + +const Tooltip: React.FC = ({ + content, + children, + position = 'top', + delay = 300, + className = '' +}) => { + const [isVisible, setIsVisible] = useState(false); + const [showTooltip, setShowTooltip] = useState(false); + const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 }); + const timeoutRef = useRef(null); + const tooltipRef = useRef(null); + const triggerRef = useRef(null); + + const calculateTooltipPosition = () => { + if (!triggerRef.current) return { top: 0, left: 0 }; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + + let top = 0; + let left = 0; + + switch (position) { + case 'top': + top = triggerRect.top - 8; // 8px offset for arrow and spacing + left = triggerRect.left + triggerRect.width / 2; + break; + case 'bottom': + top = triggerRect.bottom + 8; + left = triggerRect.left + triggerRect.width / 2; + break; + case 'left': + top = triggerRect.top + triggerRect.height / 2; + left = triggerRect.left - 8; + break; + case 'right': + top = triggerRect.top + triggerRect.height / 2; + left = triggerRect.right + 8; + break; + } + + return { top, left }; + }; + + const showTooltipWithDelay = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + const position = calculateTooltipPosition(); + setTooltipPosition(position); + setIsVisible(true); + // Small delay to allow for positioning before showing + setTimeout(() => setShowTooltip(true), 10); + }, delay); + }; + + const hideTooltip = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + setShowTooltip(false); + setTimeout(() => setIsVisible(false), 150); // Match transition duration + }; + + // Update tooltip position on scroll or resize + useEffect(() => { + if (!isVisible) return; + + const updatePosition = () => { + const position = calculateTooltipPosition(); + setTooltipPosition(position); + }; + + const handleScroll = () => updatePosition(); + const handleResize = () => updatePosition(); + + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleResize, { passive: true }); + + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleResize); + }; + }, [isVisible, position]); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const getTooltipClasses = () => { + const baseClasses = ` + fixed z-[9999] px-3 py-2 text-sm font-medium text-white bg-gray-900 + rounded-lg shadow-xl backdrop-blur-sm border border-gray-700 + transition-all duration-150 ease-in-out pointer-events-none + max-w-xs break-words + `; + + const transformClasses = { + top: '-translate-x-1/2 -translate-y-full', + bottom: '-translate-x-1/2', + left: '-translate-x-full -translate-y-1/2', + right: '-translate-y-1/2' + }; + + const visibilityClasses = showTooltip + ? 'opacity-100 scale-100' + : 'opacity-0 scale-95'; + + return `${baseClasses} ${transformClasses[position]} ${visibilityClasses}`; + }; + + const getArrowClasses = () => { + const baseClasses = 'absolute w-2 h-2 bg-gray-900 border border-gray-700 transform rotate-45'; + + const arrowPositions = { + top: 'bottom-0 left-1/2 transform -translate-x-1/2 translate-y-1/2 border-t-0 border-l-0', + bottom: 'top-0 left-1/2 transform -translate-x-1/2 -translate-y-1/2 border-b-0 border-r-0', + left: 'right-0 top-1/2 transform translate-x-1/2 -translate-y-1/2 border-l-0 border-b-0', + right: 'left-0 top-1/2 transform -translate-x-1/2 -translate-y-1/2 border-r-0 border-t-0' + }; + + return `${baseClasses} ${arrowPositions[position]}`; + }; + + if (!content) { + return <>{children}; + } + + return ( + <> +
+ {children} +
+ + {isVisible && createPortal( +
+ {content} +
+
, + document.body + )} + + ); +}; + +export default Tooltip; diff --git a/src/examples/apiUsage.ts b/src/examples/apiUsage.ts deleted file mode 100644 index 1cc6f95..0000000 --- a/src/examples/apiUsage.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { openCandApi, OpenCandApi } from '../api'; - -/** - * Example usage of the OpenCand API client - */ -export class OpenCandApiExample { - private api: OpenCandApi; - - constructor(baseUrl?: string) { - // You can use the default instance or create a new one with custom base URL - this.api = baseUrl ? new OpenCandApi(baseUrl) : openCandApi; - } - - /** - * Setup API configuration - */ - setupApi(baseUrl: string, additionalHeaders?: Record): void { - this.api.setBaseUrl(baseUrl); - - if (additionalHeaders) { - this.api.setDefaultHeaders(additionalHeaders); - } - - // Set a custom timeout (optional) - this.api.setTimeout(45000); // 45 seconds - } -} \ No newline at end of file diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..1b33162 --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,58 @@ +/** + * Formats a CPF string with masking (123.***.789-10) + * @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) { + return cpf; + } + + // Format with mask: 123.***.789-10 + return `${cleanCpf.slice(0, 3)}.***.${cleanCpf.slice(6, 9)}-${cleanCpf.slice(9)}`; +} + +/** + * Formats a CPF string with proper punctuation (123.456.789-10) + * @param cpf - A CPF string (11 digits without punctuation) + * @returns Formatted CPF or the original input if invalid + */ +export function formatCpf(cpf: string): string { + // Clean input, keeping only digits + const cleanCpf = cpf.replace(/\D/g, ''); + + // Validate if it's 11 digits + if (cleanCpf.length !== 11) { + return cpf; + } + + // Format with standard punctuation: 123.456.789-10 + return `${cleanCpf.slice(0, 3)}.${cleanCpf.slice(3, 6)}.${cleanCpf.slice(6, 9)}-${cleanCpf.slice(9)}`; +} + +/** + * Formats a date to DD/MM/YYYY format + * @param date - Date object or string/number that can be parsed into a Date + * @returns Formatted date string in DD/MM/YYYY format or empty string if invalid + */ +export function formatDateToDDMMYYYY(date: Date | string | number): string { + try { + const dateObj = date instanceof Date ? date : new Date(date); + + if (isNaN(dateObj.getTime())) { + return ''; + } + + const day = String(dateObj.getDate()).padStart(2, '0'); + const month = String(dateObj.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed + const year = dateObj.getFullYear(); + + return `${day}/${month}/${year}`; + } catch (error) { + return ''; + } +} \ No newline at end of file