refactor(ui): modulariza App.jsx y unifica branding y caché PWA
- Extrae App.jsx en módulos reutilizables: constants/ (categorias, provincias), utils/geo, styles/shared, hooks/usePwaInstall y components/ (StarRating, LocalCard, CampoUbicacion, BannerMovil, CategoriaBadge) - AdminPanel reutiliza constantes y componentes compartidos y corrige la limpieza de suscripciones de Firebase al desmontar - Añade logos de marca (logo-localesp.png + @2x) y regenera los iconos PWA con tamaños menores - sw.js: sube la caché del shell a v2 e incluye los nuevos iconos y logos
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const ESTRELLAS = [1, 2, 3, 4, 5];
|
||||
|
||||
/**
|
||||
* Muestra siempre 5 estrellas (usando el font icon de Tabler ya cargado en
|
||||
* index.html, para no depender de una fuente/librería extra). Si no hay
|
||||
* valoración (0, null, undefined o un valor no numérico) se muestran las
|
||||
* 5 estrellas vacías en lugar de no renderizar nada.
|
||||
*
|
||||
* - Modo lectura (readOnly, por defecto): <div role="img"> con aria-label
|
||||
* describiendo la puntuación, para lectores de pantalla.
|
||||
* - Modo edición (onChange): botones reales con role="radio" para que sea
|
||||
* utilizable con teclado y tenga foco visible.
|
||||
*/
|
||||
export default function StarRating({ value = 0, onChange, readOnly = false, size = 20 }) {
|
||||
const [valorHover, setValorHover] = useState(0);
|
||||
const esInteractivo = !readOnly && typeof onChange === "function";
|
||||
|
||||
const valorSeguro = Math.min(5, Math.max(0, Math.round(Number(value)) || 0));
|
||||
const valorMostrado = esInteractivo && valorHover ? valorHover : valorSeguro;
|
||||
|
||||
const contenedorProps = esInteractivo
|
||||
? { role: "radiogroup", "aria-label": "Selecciona una puntuación de 1 a 5 estrellas" }
|
||||
: { role: "img", "aria-label": `Valoración: ${valorSeguro} de 5 estrellas` };
|
||||
|
||||
return (
|
||||
<div
|
||||
{...contenedorProps}
|
||||
style={{ display: "inline-flex", gap: 2 }}
|
||||
onMouseLeave={() => esInteractivo && setValorHover(0)}
|
||||
>
|
||||
{ESTRELLAS.map((estrella) => {
|
||||
const rellena = estrella <= valorMostrado;
|
||||
const iconStyle = {
|
||||
fontSize: size,
|
||||
color: rellena ? "#D4AF37" : "var(--color-border-secondary)",
|
||||
transition: "color 0.15s",
|
||||
lineHeight: 0,
|
||||
};
|
||||
|
||||
if (!esInteractivo) {
|
||||
return (
|
||||
<i key={estrella} className={`ti ${rellena ? "ti-star-filled" : "ti-star"}`} style={iconStyle} aria-hidden="true" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={estrella}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={valorSeguro === estrella}
|
||||
aria-label={`${estrella} estrella${estrella === 1 ? "" : "s"}`}
|
||||
onClick={() => onChange(estrella)}
|
||||
onMouseEnter={() => setValorHover(estrella)}
|
||||
onFocus={() => setValorHover(estrella)}
|
||||
onBlur={() => setValorHover(0)}
|
||||
style={{ background: "none", border: "none", padding: 2, margin: 0, cursor: "pointer", lineHeight: 0 }}
|
||||
>
|
||||
<i className={`ti ${rellena ? "ti-star-filled" : "ti-star"}`} style={iconStyle} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user