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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
|
"postcss": "^8.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"tailwindcss": "^4.1.8",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.3.5"
|
"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 {
|
#root {
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
65
src/App.tsx
65
src/App.tsx
@ -1,35 +1,42 @@
|
|||||||
import { useState } from 'react'
|
import React from 'react';
|
||||||
import reactLogo from './assets/react.svg'
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import viteLogo from '/vite.svg'
|
import Navbar from './components/Navbar';
|
||||||
import './App.css'
|
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() {
|
function App() {
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="bg-gray-900 min-h-screen w-full flex flex-col">
|
||||||
<div>
|
<Navbar />
|
||||||
<a href="https://vite.dev" target="_blank">
|
<Routes>
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
<Route path="/" element={<HomePage />} />
|
||||||
</a>
|
<Route path="/candidato/:id" element={<CandidatePage />} />
|
||||||
<a href="https://react.dev" target="_blank">
|
</Routes>
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
<Footer />
|
||||||
</a>
|
|
||||||
</div>
|
</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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@ -24,10 +29,12 @@ a:hover {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
padding: 0;
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@apply bg-gray-900 text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@ -35,14 +42,13 @@ h1 {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
/* button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
padding: 0.6em 1.2em;
|
padding: 0.6em 1.2em;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.25s;
|
transition: border-color 0.25s;
|
||||||
}
|
}
|
||||||
@ -52,17 +58,43 @@ button:hover {
|
|||||||
button:focus,
|
button:focus,
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
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) {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
:root {
|
width: 1px;
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
}
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
.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 { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</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 { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import postcss from '@tailwindcss/postcss'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
})
|
})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user