Esta refactorización modifica la forma en que los widgets manejan sus estilos para prevenir conflictos con los CSS de los sitios anfitriones donde se incrustan. Se ha migrado el sistema de estilos de CSS global a CSS Modules para todos los componentes principales y sus hijos, asegurando que todas las clases sean únicas y estén aisladas. Cambios principales: - Se actualizan los componentes .tsx para importar y usar los módulos de estilos (`import styles from ...`). - Se renombran los archivos `.css` a `.module.css`. - Se añade una regla en cada módulo para proteger la `font-family` y el `box-sizing` del widget, evitando que sean sobreescritos por estilos externos. - Se ajustan los selectores para librerías de terceros (react-select, react-simple-maps) usando `:global()` para mantener la compatibilidad. - Se mueven las variables CSS de `:root` a las clases principales de cada widget para evitar colisiones en el scope global. Como resultado, los widgets (`HomeCarouselWidget`, `PanelNacionalWidget`, `ResultadosNacionalesCardsWidget`, `CongresoNacionalWidget`) son ahora más robustos, portátiles y visualmente consistentes en cualquier entorno.
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
// src/features/legislativas/nacionales/PanelNacionalWidget.tsx
|
||
|
||
import { useMemo, useState, Suspense, useEffect } from 'react';
|
||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||
import { getPanelElectoral } from '../../../apiService';
|
||
import { MapaNacional } from './components/MapaNacional';
|
||
import { PanelResultados } from './components/PanelResultados';
|
||
import { Breadcrumbs } from './components/Breadcrumbs';
|
||
import { MunicipioSearch } from './components/MunicipioSearch';
|
||
// 1. La importación de CSS ahora se hace como un módulo
|
||
import styles from './PanelNacional.module.css';
|
||
import Select from 'react-select';
|
||
import type { PanelElectoralDto, ResultadoTicker } from '../../../types/types';
|
||
import { FiMap, FiList, FiChevronDown, FiChevronUp } from 'react-icons/fi';
|
||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||
import { Toaster } from 'react-hot-toast';
|
||
import { ImageWithFallback } from '../../../components/common/ImageWithFallback';
|
||
import { assetBaseUrl } from '../../../apiService';
|
||
|
||
// --- COMPONENTE INTERNO PARA LA TARJETA DE RESULTADOS EN MÓVIL ---
|
||
interface MobileResultsCardProps {
|
||
eleccionId: number;
|
||
ambitoId: string | null;
|
||
categoriaId: number;
|
||
ambitoNombre: string;
|
||
ambitoNivel: 'pais' | 'provincia' | 'municipio';
|
||
}
|
||
|
||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||
|
||
// --- SUB-COMPONENTE PARA UNA FILA DE RESULTADO ---
|
||
// 2. Todas las props 'className' ahora usan el objeto 'styles'
|
||
const ResultRow = ({ partido }: { partido: ResultadoTicker }) => (
|
||
<div className={styles.mobileResultRow} style={{ borderLeftColor: partido.color || '#ccc' }}>
|
||
<div className={styles.mobileResultLogo} style={{ backgroundColor: partido.color || '#e9ecef' }}>
|
||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} />
|
||
</div>
|
||
<div className={styles.mobileResultInfo}>
|
||
{partido.nombreCandidato ? (
|
||
<>
|
||
<span className={styles.mobileResultPartyName}>{partido.nombreCandidato}</span>
|
||
<span className={styles.mobileResultCandidateName}>{partido.nombreCorto || partido.nombre}</span>
|
||
</>
|
||
) : (
|
||
<span className={styles.mobileResultPartyName}>{partido.nombreCorto || partido.nombre}</span>
|
||
)}
|
||
</div>
|
||
<div className={styles.mobileResultStats}>
|
||
<strong>{formatPercent(partido.porcentaje)}</strong>
|
||
<span>{partido.votos.toLocaleString('es-AR')}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
// --- COMPONENTE REFACTORIZADO PARA LA TARJETA MÓVIL ---
|
||
interface MobileResultsCardProps {
|
||
eleccionId: number;
|
||
ambitoId: string | null;
|
||
categoriaId: number;
|
||
ambitoNombre: string;
|
||
ambitoNivel: 'pais' | 'provincia' | 'municipio';
|
||
mobileView: 'mapa' | 'resultados';
|
||
setMobileView: (view: 'mapa' | 'resultados') => void;
|
||
}
|
||
|
||
const MobileResultsCard = ({
|
||
eleccionId, ambitoId, categoriaId, ambitoNombre, ambitoNivel, mobileView, setMobileView
|
||
}: MobileResultsCardProps) => {
|
||
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
const { data } = useSuspenseQuery<PanelElectoralDto>({
|
||
queryKey: ['panelElectoral', eleccionId, ambitoId, categoriaId],
|
||
queryFn: () => getPanelElectoral(eleccionId, ambitoId, categoriaId),
|
||
});
|
||
|
||
useEffect(() => {
|
||
setIsExpanded(ambitoNivel === 'municipio');
|
||
}, [ambitoNivel]);
|
||
|
||
const topResults = data.resultadosPanel.slice(0, 3);
|
||
|
||
if (topResults.length === 0 && ambitoNivel === 'pais') {
|
||
return null;
|
||
}
|
||
|
||
// 3. Clases condicionales también se construyen con el objeto 'styles'
|
||
const cardClasses = [
|
||
styles.mobileResultsCardContainer,
|
||
isExpanded ? styles.expanded : '',
|
||
styles[`view-${mobileView}`]
|
||
].join(' ');
|
||
|
||
return (
|
||
<div className={cardClasses}>
|
||
{/* Sección Colapsable con Resultados */}
|
||
<div className={styles.collapsibleSection}>
|
||
<div className={styles.mobileResultsHeader} onClick={() => setIsExpanded(!isExpanded)}>
|
||
<div className={styles.headerInfo}>
|
||
<h4>{ambitoNombre}</h4>
|
||
<span className={styles.headerActionText}>{isExpanded ? 'Ocultar resultados' : 'Ver top 3'}</span>
|
||
</div>
|
||
<div className={styles.headerToggleIcon}>
|
||
{isExpanded ? <FiChevronDown /> : <FiChevronUp />}
|
||
</div>
|
||
</div>
|
||
<div className={styles.mobileResultsContent}>
|
||
{topResults.length > 0 ? (
|
||
topResults.map(partido => <ResultRow key={partido.id} partido={partido} />)
|
||
) : (
|
||
<p className={styles.noResultsText}>No hay resultados para esta selección.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer Fijo con Botones de Navegación */}
|
||
<div className={styles.mobileCardViewToggle}>
|
||
<button
|
||
className={`${styles.toggleBtn} ${mobileView === 'mapa' ? styles.active : ''}`}
|
||
onClick={() => setMobileView('mapa')}
|
||
>
|
||
<FiMap />
|
||
<span>Mapa</span>
|
||
</button>
|
||
<button
|
||
className={`${styles.toggleBtn} ${mobileView === 'resultados' ? styles.active : ''}`}
|
||
onClick={() => setMobileView('resultados')}
|
||
>
|
||
<FiList />
|
||
<span>Detalles</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- WIDGET PRINCIPAL ---
|
||
interface PanelNacionalWidgetProps {
|
||
eleccionId: number;
|
||
}
|
||
|
||
type AmbitoState = {
|
||
id: string | null;
|
||
nivel: 'pais' | 'provincia' | 'municipio';
|
||
nombre: string;
|
||
provinciaNombre?: string;
|
||
provinciaDistritoId?: string | null;
|
||
};
|
||
|
||
const CATEGORIAS_NACIONALES = [
|
||
{ value: 2, label: 'Diputados Nacionales' },
|
||
{ value: 1, label: 'Senadores Nacionales' },
|
||
];
|
||
|
||
const PanelContenido = ({ eleccionId, ambitoActual, categoriaId }: { eleccionId: number, ambitoActual: AmbitoState, categoriaId: number }) => {
|
||
const { data } = useSuspenseQuery<PanelElectoralDto>({
|
||
queryKey: ['panelElectoral', eleccionId, ambitoActual.id, categoriaId],
|
||
queryFn: () => getPanelElectoral(eleccionId, ambitoActual.id, categoriaId),
|
||
});
|
||
return <PanelResultados resultados={data.resultadosPanel} estadoRecuento={data.estadoRecuento} />;
|
||
};
|
||
|
||
export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) => {
|
||
const [ambitoActual, setAmbitoActual] = useState<AmbitoState>({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null });
|
||
const [categoriaId, setCategoriaId] = useState<number>(2);
|
||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||
const [mobileView, setMobileView] = useState<'mapa' | 'resultados'>('mapa');
|
||
const isMobile = useMediaQuery('(max-width: 800px)');
|
||
|
||
const handleAmbitoSelect = (nuevoAmbitoId: string, nuevoNivel: 'provincia' | 'municipio', nuevoNombre: string) => {
|
||
setAmbitoActual(prev => ({
|
||
id: nuevoAmbitoId,
|
||
nivel: nuevoNivel,
|
||
nombre: nuevoNombre,
|
||
provinciaNombre: nuevoNivel === 'municipio' ? prev.provinciaNombre : (nuevoNivel === 'provincia' ? nuevoNombre : undefined),
|
||
provinciaDistritoId: nuevoNivel === 'provincia' ? nuevoAmbitoId : prev.provinciaDistritoId
|
||
}));
|
||
};
|
||
|
||
const handleResetToPais = () => {
|
||
setAmbitoActual({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null });
|
||
};
|
||
|
||
const handleVolverAProvincia = () => {
|
||
if (ambitoActual.provinciaDistritoId && ambitoActual.provinciaNombre) {
|
||
setAmbitoActual({
|
||
id: ambitoActual.provinciaDistritoId,
|
||
nivel: 'provincia',
|
||
nombre: ambitoActual.provinciaNombre,
|
||
provinciaDistritoId: ambitoActual.provinciaDistritoId,
|
||
provinciaNombre: ambitoActual.provinciaNombre,
|
||
});
|
||
} else {
|
||
handleResetToPais();
|
||
}
|
||
};
|
||
|
||
const selectedCategoria = useMemo(() =>
|
||
CATEGORIAS_NACIONALES.find(c => c.value === categoriaId),
|
||
[categoriaId]
|
||
);
|
||
|
||
const mainContentClasses = [
|
||
styles.panelMainContent,
|
||
!isPanelOpen ? styles.panelCollapsed : '',
|
||
isMobile ? styles[`mobile-view-${mobileView}`] : ''
|
||
].join(' ');
|
||
|
||
return (
|
||
<div className={styles.panelNacionalContainer}>
|
||
<Toaster containerClassName={styles.widgetToasterContainer} />
|
||
<header className={styles.panelHeader}>
|
||
<div className={styles.headerTopRow}>
|
||
<Select
|
||
options={CATEGORIAS_NACIONALES}
|
||
value={selectedCategoria}
|
||
onChange={(option) => option && setCategoriaId(option.value)}
|
||
// 4. Usamos un prefijo de clase simple que se asociará con las clases del módulo CSS
|
||
classNamePrefix="categoriaSelector"
|
||
className={styles.categoriaSelectorContainer}
|
||
isSearchable={false}
|
||
/>
|
||
</div>
|
||
<div className={styles.headerBottomRow}>
|
||
<Breadcrumbs
|
||
nivel={ambitoActual.nivel}
|
||
nombreAmbito={ambitoActual.nombre}
|
||
nombreProvincia={ambitoActual.provinciaNombre}
|
||
onReset={handleResetToPais}
|
||
onVolverProvincia={handleVolverAProvincia}
|
||
/>
|
||
{ambitoActual.nivel === 'provincia' && ambitoActual.provinciaDistritoId && (
|
||
<MunicipioSearch
|
||
distritoId={ambitoActual.provinciaDistritoId}
|
||
onMunicipioSelect={(municipioId, municipioNombre) =>
|
||
handleAmbitoSelect(municipioId, 'municipio', municipioNombre)
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
</header>
|
||
<main className={mainContentClasses}>
|
||
<div className={styles.mapaColumn}>
|
||
<button className={styles.panelToggleBtn} onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}> {isPanelOpen ? '›' : '‹'} </button>
|
||
|
||
<Suspense fallback={<div className={styles.spinner} />}>
|
||
<MapaNacional eleccionId={eleccionId} categoriaId={categoriaId} nivel={ambitoActual.nivel} nombreAmbito={ambitoActual.nombre} nombreProvinciaActiva={ambitoActual.provinciaNombre} provinciaDistritoId={ambitoActual.provinciaDistritoId ?? null} onAmbitoSelect={handleAmbitoSelect} onVolver={ambitoActual.nivel === 'municipio' ? handleVolverAProvincia : handleResetToPais} isMobileView={isMobile} />
|
||
</Suspense>
|
||
</div>
|
||
<div className={styles.resultadosColumn}>
|
||
<Suspense fallback={<div className={styles.spinner} />}>
|
||
<PanelContenido eleccionId={eleccionId} ambitoActual={ambitoActual} categoriaId={categoriaId} />
|
||
</Suspense>
|
||
</div>
|
||
|
||
<Suspense fallback={null}>
|
||
{isMobile && (
|
||
<MobileResultsCard
|
||
eleccionId={eleccionId}
|
||
ambitoId={ambitoActual.id}
|
||
categoriaId={categoriaId}
|
||
ambitoNombre={ambitoActual.nombre}
|
||
ambitoNivel={ambitoActual.nivel}
|
||
mobileView={mobileView}
|
||
setMobileView={setMobileView}
|
||
/>
|
||
)}
|
||
</Suspense>
|
||
</main>
|
||
</div>
|
||
);
|
||
}; |