functional landing page
This commit is contained in:
parent
e65c0a3503
commit
64af6b23ff
908
package-lock.json
generated
908
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -10,18 +10,26 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"postcss": "^8.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
|
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
}
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
|
67
src/App.tsx
67
src/App.tsx
@ -1,35 +1,42 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import Navbar from './components/Navbar';
|
||||
import HeroSection from './components/HeroSection';
|
||||
import StatisticsSection from './components/StatisticsSection';
|
||||
import FeaturesSection from './components/FeaturesSection';
|
||||
import Footer from './components/Footer';
|
||||
import './App.css';
|
||||
|
||||
// HomePage component
|
||||
const HomePage: React.FC = () => (
|
||||
<main className="flex-grow">
|
||||
<HeroSection />
|
||||
<StatisticsSection />
|
||||
<FeaturesSection />
|
||||
</main>
|
||||
);
|
||||
|
||||
// Placeholder for candidate detail page
|
||||
const CandidatePage: React.FC = () => (
|
||||
<main className="flex-grow flex items-center justify-center min-h-screen">
|
||||
<div className="text-center text-white">
|
||||
<h1 className="text-4xl font-bold mb-4">Página do Candidato</h1>
|
||||
<p className="text-gray-300">Esta página será implementada em breve.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
<div className="bg-gray-900 min-h-screen w-full flex flex-col">
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/candidato/:id" element={<CandidatePage />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
187
src/api/base.ts
Normal file
187
src/api/base.ts
Normal file
@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Base API client class for handling HTTP requests and common functionality
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
public status: number;
|
||||
public response?: Response;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
status: number,
|
||||
response?: Response
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.response = response;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiRequestConfig {
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class BaseApiClient {
|
||||
private baseUrl: string;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
private timeout: number;
|
||||
|
||||
constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) {
|
||||
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
this.defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
...defaultHeaders,
|
||||
};
|
||||
this.timeout = 30000; // 30 seconds default timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the base URL for API requests
|
||||
*/
|
||||
setBaseUrl(url: string): void {
|
||||
this.baseUrl = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default headers for all requests
|
||||
*/
|
||||
setDefaultHeaders(headers: Record<string, string>): void {
|
||||
this.defaultHeaders = { ...this.defaultHeaders, ...headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set request timeout
|
||||
*/
|
||||
setTimeout(timeout: number): void {
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete URL with base URL and endpoint
|
||||
*/
|
||||
private buildUrl(endpoint: string): string {
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
return `${this.baseUrl}${cleanEndpoint}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API errors and create appropriate error objects
|
||||
*/
|
||||
private async handleApiError(response: Response): Promise<never> {
|
||||
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse the error response, use the default message
|
||||
}
|
||||
|
||||
throw new ApiError(errorMessage, response.status, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GET request
|
||||
*/
|
||||
async get<T>(endpoint: string, config: ApiRequestConfig = {}): Promise<T> {
|
||||
return this.request<T>('GET', endpoint, undefined, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request
|
||||
*/
|
||||
async post<T>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
config: ApiRequestConfig = {}
|
||||
): Promise<T> {
|
||||
return this.request<T>('POST', endpoint, data, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PUT request
|
||||
*/
|
||||
async put<T>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
config: ApiRequestConfig = {}
|
||||
): Promise<T> {
|
||||
return this.request<T>('PUT', endpoint, data, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a DELETE request
|
||||
*/
|
||||
async delete<T>(endpoint: string, config: ApiRequestConfig = {}): Promise<T> {
|
||||
return this.request<T>('DELETE', endpoint, undefined, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a generic HTTP request
|
||||
*/
|
||||
private async request<T>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
config: ApiRequestConfig = {}
|
||||
): Promise<T> {
|
||||
const url = this.buildUrl(endpoint);
|
||||
const headers = { ...this.defaultHeaders, ...config.headers };
|
||||
const timeout = config.timeout || this.timeout;
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (data !== undefined) {
|
||||
requestInit.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
// Create an AbortController for timeout handling
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...requestInit,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handleApiError(response);
|
||||
}
|
||||
|
||||
// Handle empty responses (like 204 No Content)
|
||||
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
return responseData as T;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new ApiError('Request timeout', 408);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new ApiError(`Network error: ${error.message}`, 0);
|
||||
}
|
||||
|
||||
throw new ApiError('Unknown error occurred', 0);
|
||||
}
|
||||
}
|
||||
}
|
15
src/api/index.ts
Normal file
15
src/api/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// Export the main API client and types
|
||||
export { OpenCandApi, openCandApi } from './openCandApi';
|
||||
export type {
|
||||
CandidateSearchResult,
|
||||
Candidate,
|
||||
CandidateDetails,
|
||||
Election,
|
||||
CandidateAssets,
|
||||
Asset,
|
||||
PlatformStats,
|
||||
} from './openCandApi';
|
||||
|
||||
// Export base API classes for custom implementations
|
||||
export { BaseApiClient, ApiError } from './base';
|
||||
export type { ApiRequestConfig } from './base';
|
111
src/api/openCandApi.ts
Normal file
111
src/api/openCandApi.ts
Normal file
@ -0,0 +1,111 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CandidateDetails extends Candidate {
|
||||
eleicoes: Election[];
|
||||
}
|
||||
|
||||
export interface Election {
|
||||
sqid: string;
|
||||
tipoeleicao: string;
|
||||
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 PlatformStats {
|
||||
totalCandidatos: number;
|
||||
totalBemCandidatos: number;
|
||||
totalEleicoes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCand API client for interacting with the OpenCand platform
|
||||
*/
|
||||
export class OpenCandApi extends BaseApiClient {
|
||||
constructor(baseUrl: string = '') {
|
||||
super(baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform statistics
|
||||
* GET /v1/stats
|
||||
*/
|
||||
async getStats(): Promise<PlatformStats> {
|
||||
return this.get<PlatformStats>('/v1/stats');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for candidates by name or other attributes
|
||||
* GET /v1/candidato/search?q={query}
|
||||
*/
|
||||
async searchCandidates(query: string): Promise<CandidateSearchResult> {
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
return this.get<CandidateSearchResult>(`/v1/candidato/search?q=${encodedQuery}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific candidate by ID
|
||||
* GET /v1/candidato/{id}
|
||||
*/
|
||||
async getCandidateById(id: string): Promise<CandidateDetails> {
|
||||
return this.get<CandidateDetails>(`/v1/candidato/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the assets of a specific candidate by ID
|
||||
* GET /v1/candidato/{id}/bens
|
||||
*/
|
||||
async getCandidateAssets(id: string): Promise<CandidateAssets> {
|
||||
return this.get<CandidateAssets>(`/v1/candidato/${id}/bens`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search candidates and get their details (limited to first 10 results to avoid too many requests)
|
||||
*/
|
||||
async searchCandidatesWithDetails(
|
||||
query: string,
|
||||
limit: number = 10
|
||||
): Promise<Candidate[]> {
|
||||
const searchResult = await this.searchCandidates(query);
|
||||
const candidatesLimit = searchResult.candidatos.slice(0, limit);
|
||||
|
||||
return candidatesLimit;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default instance for easy usage with proper configuration
|
||||
export const openCandApi = new OpenCandApi(API_CONFIG.baseUrl);
|
||||
|
||||
// Export the API error for error handling
|
||||
export { ApiError } from './base';
|
37
src/components/FeaturesSection.tsx
Normal file
37
src/components/FeaturesSection.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { ChartBarIcon, DocumentTextIcon, LightBulbIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const FeatureCard: React.FC<{ icon: React.ElementType, title: string, children: React.ReactNode }> = ({ icon: Icon, title, children }) => {
|
||||
return (
|
||||
<div className="bg-gray-800/40 p-6 rounded-lg">
|
||||
<Icon className="h-10 w-10 text-indigo-400 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">{title}</h3>
|
||||
<p className="text-gray-400">{children}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturesSection: React.FC = () => {
|
||||
return (
|
||||
<section id="features" className="py-20 bg-gray-800/30">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-center text-white mb-12">
|
||||
Por que OpenCand?
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 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>
|
||||
<FeatureCard icon={LightBulbIcon} title="Insights Valiosos">
|
||||
Obtenha informações relevantes sobre candidatos, partidos e financiamento de campanhas.
|
||||
</FeatureCard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesSection;
|
18
src/components/Footer.tsx
Normal file
18
src/components/Footer.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-gray-900 text-gray-400 py-8 text-center">
|
||||
<div className="container mx-auto">
|
||||
<p className="mb-2">
|
||||
© {new Date().getFullYear()} OpenCand. Todos os direitos reservados.
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Democratizando o acesso à informação eleitoral.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
209
src/components/HeroSection.tsx
Normal file
209
src/components/HeroSection.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
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';
|
||||
|
||||
const HeroSection: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Candidate[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const searchTimeoutRef = useRef<number | null>(null);
|
||||
const resultsRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<section
|
||||
className="min-h-screen flex flex-col justify-center items-center text-white p-4 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')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60"></div>
|
||||
<div className="relative z-10 text-center max-w-3xl">
|
||||
<h1 className="text-5xl md:text-7xl font-bold mb-6">
|
||||
Explore Dados Eleitorais
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl mb-10 text-gray-300">
|
||||
OpenCand oferece acesso fácil e visualizações intuitivas de dados abertos do Tribunal Superior Eleitoral (TSE) do Brasil.
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-xl mx-auto relative" ref={resultsRef}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex items-center bg-white/10 backdrop-blur-sm rounded-full shadow-xl p-2 transition-all duration-200 hover:bg-white/15">
|
||||
<MagnifyingGlassIcon className="h-6 w-6 text-gray-400 ml-3" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Pesquisar candidatos..."
|
||||
className="flex-grow bg-transparent text-white placeholder-gray-400 p-3 focus:outline-none"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="mr-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
{searchQuery && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setShowResults(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="mr-3 p-1 hover:bg-white/20 rounded-full transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
{showResults && (searchResults.length > 0 || error) && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden z-50">
|
||||
{error ? (
|
||||
<div className="p-4 text-red-600 text-center bg-white">
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto custom-scrollbar">
|
||||
{searchResults.map((candidate) => (
|
||||
<button
|
||||
key={candidate.idCandidato}
|
||||
onClick={() => handleCandidateSelect(candidate)}
|
||||
className="w-full p-4 text-left hover:bg-gray-100 transition-colors border-b border-gray-100 last:border-b-0 focus:outline-none focus:bg-gray-100"
|
||||
>
|
||||
<div className="text-black font-semibold text-base">{candidate.nome}</div>
|
||||
<div className="text-gray-600 text-sm mt-1">
|
||||
CPF: {candidate.cpf} | {candidate.ocupacao}
|
||||
</div>
|
||||
{candidate.email && (
|
||||
<div className="text-gray-500 text-xs mt-1">{candidate.email}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{searchResults.length === 8 && (
|
||||
<div className="p-3 text-center text-gray-500 text-sm bg-gray-50">
|
||||
Mostrando os primeiros 8 resultados
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results message */}
|
||||
{showResults && searchResults.length === 0 && !error && !isLoading && searchQuery.length >= 2 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden z-50">
|
||||
<div className="p-4 text-gray-600 text-center">
|
||||
Nenhum candidato encontrado para "{searchQuery}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
20
src/components/Navbar.tsx
Normal file
20
src/components/Navbar.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
return (
|
||||
<nav className="bg-gray-900/50 backdrop-blur-md text-white p-4 fixed inset-x-0 top-0 z-50">
|
||||
<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>
|
||||
<div className="space-x-4">
|
||||
<a href="#stats" className="hover:text-indigo-300 transition-colors">Estatíscas</a>
|
||||
<a href="#features" className="hover:text-indigo-300 transition-colors">Recursos</a>
|
||||
<a href="/about" className="hover:text-indigo-300 transition-colors">Sobre</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
54
src/components/StatisticsSection.tsx
Normal file
54
src/components/StatisticsSection.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ title, value, description }) => {
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-md p-6 rounded-lg shadow-xl hover:shadow-indigo-500/30 transform hover:-translate-y-1 transition-all duration-300">
|
||||
<h3 className="text-indigo-400 text-xl font-semibold mb-2">{title}</h3>
|
||||
<p className="text-4xl font-bold text-white mb-3">{value}</p>
|
||||
<p className="text-gray-400 text-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatisticsSection: React.FC = () => {
|
||||
const stats = [
|
||||
{
|
||||
title: "Total de Candidatos",
|
||||
value: "+500.000",
|
||||
description: "Registros de candidaturas desde 2014"
|
||||
},
|
||||
{
|
||||
title: "Total de Bens Declarados",
|
||||
value: "R$ +1 Trilhão",
|
||||
description: "Patrimônio agregado declarado pelos candidatos"
|
||||
},
|
||||
{
|
||||
title: "Anos de Eleição Processados",
|
||||
value: "2014 - 2024",
|
||||
description: "Cobertura das últimas eleições gerais e municipais"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="stats" className="py-20 bg-gray-900">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-center text-white mb-12">
|
||||
Dados em Números
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<StatCard key={index} title={stat.title} value={stat.value} description={stat.description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatisticsSection;
|
11
src/config/api.ts
Normal file
11
src/config/api.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// API configuration
|
||||
export const API_CONFIG = {
|
||||
baseUrl: import.meta.env.VITE_API_BASE_URL || 'https://api.opencand.com',
|
||||
timeout: 30000, // 30 seconds
|
||||
} as const;
|
||||
|
||||
// Environment configuration
|
||||
export const ENV_CONFIG = {
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
isProduction: import.meta.env.PROD,
|
||||
} as const;
|
62
src/config/demo.ts
Normal file
62
src/config/demo.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Demo configuration for testing the OpenCand API with mock data
|
||||
* This can be used when the real API is not available
|
||||
*/
|
||||
|
||||
import { type Candidate, type CandidateSearchResult } from '../api';
|
||||
|
||||
// Mock candidates for testing
|
||||
export const MOCK_CANDIDATES: Candidate[] = [
|
||||
{
|
||||
idCandidato: "6c2be869-339c-47d0-aeb6-77c686e528b5",
|
||||
nome: "João Silva",
|
||||
cpf: "123.***.789-10",
|
||||
dataNascimento: "1990-01-01",
|
||||
email: "joao.silva@example.com",
|
||||
estadoCivil: "Solteiro",
|
||||
sexo: "Masculino",
|
||||
ocupacao: "Advogado",
|
||||
},
|
||||
{
|
||||
idCandidato: "7d3cf97a-440d-58e1-bfb7-88d797f639c6",
|
||||
nome: "Maria Santos",
|
||||
cpf: "987.***.321-45",
|
||||
dataNascimento: "1985-05-15",
|
||||
email: "maria.santos@example.com",
|
||||
estadoCivil: "Casada",
|
||||
sexo: "Feminino",
|
||||
ocupacao: "Professora",
|
||||
},
|
||||
{
|
||||
idCandidato: "8e4df08b-551e-69f2-cgc8-99e808g750d7",
|
||||
nome: "Pedro Oliveira",
|
||||
cpf: "456.***.123-78",
|
||||
dataNascimento: "1978-12-03",
|
||||
email: "pedro.oliveira@example.com",
|
||||
estadoCivil: "Divorciado",
|
||||
sexo: "Masculino",
|
||||
ocupacao: "Empresário",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Mock search function for testing
|
||||
*/
|
||||
export const mockSearchCandidates = (query: string): Promise<CandidateSearchResult> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const filteredCandidates = MOCK_CANDIDATES.filter(candidate =>
|
||||
candidate.nome.toLowerCase().includes(query.toLowerCase()) ||
|
||||
candidate.ocupacao.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
resolve({
|
||||
candidatos: filteredCandidates
|
||||
});
|
||||
}, 500); // Simulate network delay
|
||||
});
|
||||
};
|
||||
|
||||
export const DEMO_CONFIG = {
|
||||
useMockData: import.meta.env.VITE_USE_MOCK_DATA === 'true',
|
||||
} as const;
|
27
src/examples/apiUsage.ts
Normal file
27
src/examples/apiUsage.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { openCandApi, ApiError, 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<string, string>): void {
|
||||
this.api.setBaseUrl(baseUrl);
|
||||
|
||||
if (additionalHeaders) {
|
||||
this.api.setDefaultHeaders(additionalHeaders);
|
||||
}
|
||||
|
||||
// Set a custom timeout (optional)
|
||||
this.api.setTimeout(45000); // 45 seconds
|
||||
}
|
||||
}
|
@ -1,3 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
@ -24,10 +29,12 @@ a:hover {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
|
||||
@apply bg-gray-900 text-white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@ -35,14 +42,13 @@ h1 {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
/* button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
@ -52,17 +58,43 @@ button:hover {
|
||||
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;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
27
tailwind.config.js
Normal file
27
tailwind.config.js
Normal file
@ -0,0 +1,27 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
indigo: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
950: '#1e1b4b',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import postcss from '@tailwindcss/postcss'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
|
Loading…
x
Reference in New Issue
Block a user