refactor(ui): modulariza App.jsx y unifica branding y caché PWA
deploy-branch / deploy (push) Successful in 2m36s
deploy-branch / teardown (push) Skipped

- 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:
2026-08-05 19:07:00 +02:00
parent 28d066b5af
commit 257fb9c622
20 changed files with 674 additions and 360 deletions
+67
View File
@@ -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>
);
}