<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
  <!-- v28-octo: base href garantiza que cualquier ruta relativa se resuelve contra
       la raíz del sitio, no contra la URL actual. Imprescindible cuando usamos
       URLs profundas tipo /caimanes/2026-05-25/... porque sin esto los <script src="js/...">
       se buscarían en /caimanes/2026-05-25/js/... → 404. -->
  <base href="/" />
  <title>Grupetas.com</title>
  <meta name="description" content="La app de las grupetas ciclistas" />
  <meta name="theme-color" content="#312e81" />
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
  <meta name="apple-mobile-web-app-title" content="Grupetas" />

  <!-- PWA: añadir a pantalla de inicio (ligera, sin service-worker) -->
  <link rel="manifest" href="/manifest.json" />
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
  <link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png" />

  <script src="https://cdn.tailwindcss.com"></script>
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />

  <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
  <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
  <script src="https://unpkg.com/@babel/standalone@7.23.6/babel.min.js"></script>

  <!-- Firebase SDK (compat version for inline use) -->
  <script src="https://www.gstatic.com/firebasejs/10.12.5/firebase-app-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/10.12.5/firebase-database-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/10.12.5/firebase-auth-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/10.12.5/firebase-firestore-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/10.12.5/firebase-storage-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/10.12.5/firebase-messaging-compat.js"></script>

  <style>
    html,body{margin:0;padding:0;min-height:100%;background:#1e1b4b}
    body{background:#1e1b4b;-webkit-tap-highlight-color:transparent}
    #root{min-height:100vh;min-height:-webkit-fill-available;background:#1e1b4b}
    .display-font{font-family:'Oswald','Arial Narrow',sans-serif;letter-spacing:.02em}
    .body-font{font-family:'Inter',system-ui,sans-serif}
    @keyframes spin-slow{from{transform:rotate(0)}to{transform:rotate(360deg)}}
    .spinning{animation:spin-slow .8s linear}
    @keyframes fade-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
    .fade-up{animation:fade-up .3s ease-out}
    @keyframes fade-in{from{opacity:0}to{opacity:1}}
    .fade-in{animation:fade-in .4s ease-out;min-height:100dvh;min-height:100vh}
    @keyframes slide-down{from{opacity:0;transform:translate(-50%,-20px)}to{opacity:1;transform:translate(-50%,0)}}
    .slide-down{animation:slide-down .25s ease-out}
    .scrollbar-hide::-webkit-scrollbar{display:none}
    .scrollbar-hide{-ms-overflow-style:none;scrollbar-width:none}
    .initial-loader{position:fixed;inset:0;background:linear-gradient(180deg,#312e81 0%,#1e1b4b 50%,#2e1065 100%);display:flex;align-items:center;justify-content:center;flex-direction:column;color:white;z-index:100;font-family:'Inter',sans-serif;padding:20px;text-align:center}
    .initial-loader .title{font-size:1.6rem;font-weight:700;margin-bottom:0.5rem;letter-spacing:-0.02em}
    .initial-loader .subtitle{font-size:0.85rem;opacity:0.6;margin-bottom:1.5rem}
    .initial-loader .dot{width:8px;height:8px;border-radius:50%;background:white;animation:bounce 1.2s infinite ease-in-out;display:inline-block;margin:0 3px}
    .initial-loader .dot:nth-child(2){animation-delay:.15s}
    .initial-loader .dot:nth-child(3){animation-delay:.3s}
    @keyframes bounce{0%,80%,100%{transform:scale(.8);opacity:.5}40%{transform:scale(1.2);opacity:1}}
    /* v68: rótulo de noticias (texto desplazándose hacia la izquierda en bucle) */
    .marquee-wrap{overflow:hidden;width:100%;white-space:nowrap;-webkit-mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent);mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent)}
    .marquee-track{display:inline-block;white-space:nowrap;padding-left:100%;animation:marquee-left 18s linear infinite}
    .marquee-wrap:hover .marquee-track{animation-play-state:paused}
    @keyframes marquee-left{from{transform:translateX(0)}to{transform:translateX(-100%)}}
    #error-display{display:none;position:fixed;inset:0;background:#7f1d1d;color:white;padding:20px;font-family:monospace;font-size:12px;overflow:auto;z-index:200}
    /* v75: respetar la zona segura del iPhone (hora/batería/notch). Suma a lo que ya tenía la cabecera. */
    .safe-top{padding-top:calc(1.5rem + env(safe-area-inset-top, 0px))}
    .safe-top-lg{padding-top:calc(2.75rem + env(safe-area-inset-top, 0px))}
    .safe-top-fix{top:calc(1.5rem + env(safe-area-inset-top, 0px))}
    /* v75b: en la PWA instalada de iOS garantizamos un mínimo aunque env() venga corto */
    @media (display-mode: standalone){
      @supports (-webkit-touch-callout: none){
        .safe-top{padding-top:max(calc(1.5rem + env(safe-area-inset-top, 0px)), 4.25rem)}
        .safe-top-lg{padding-top:max(calc(2.75rem + env(safe-area-inset-top, 0px)), 5rem)}
        .safe-top-fix{top:max(calc(1.5rem + env(safe-area-inset-top, 0px)), 4.25rem)}
      }
    }
  /* v95: barra de navegación inferior */
  #gnav { display:none; position:fixed; bottom:0; left:0; right:0; z-index:9000; background:#fff; border-top:1px solid #e5e7eb; height:calc(60px + env(safe-area-inset-bottom)); padding-bottom:env(safe-area-inset-bottom); transition:transform 0.15s; }
  #gnav.kb-hidden { transform: translateY(120px); }
  /* Evitar que el menú tape contenido */
  body.gnav-visible { padding-bottom: calc(60px + env(safe-area-inset-bottom)); }
  #gnav button.gnav-active { color: #ef4444; }
  #gnav button.gnav-active svg { stroke: #ef4444; }
  #gnav button.gnav-active span:not(#gnav-msg-badge) { color: #ef4444; font-weight: 700; }
  #gnav.visible { display:flex; }
  #gnav button { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; background:none; border:none; cursor:pointer; color:#9ca3af; padding:4px; }
  #gnav button svg { display:block; }
  #gnav button span { font-size:10px; font-family:inherit; }
  #gnav-spacer { display:none; height:calc(60px + env(safe-area-inset-bottom)); }
  #gnav-spacer.visible { display:block; }
  </style>
</head>
<body>
  <div id="root">
    <div class="initial-loader" id="initial-loader">
      <img src="/img/logo-grupetas.jpeg" alt="GRUPETAS.COM" style="width:260px;max-width:80%;border-radius:16px;margin-bottom:1rem" />
      <div class="subtitle">Cargando…</div>
      <div><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
      <div id="loader-fallback" style="display:none;margin-top:2rem;max-width:340px">
        <div style="background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:14px;padding:16px;text-align:left;line-height:1.5">
          <div style="font-weight:700;margin-bottom:8px;font-size:0.95rem">⚠️ La app tarda en cargar</div>
          <div style="font-size:0.8rem;opacity:0.85">Si estás viendo este archivo desde una vista previa local (Archivos, mensajería, etc.), los scripts de React podrían estar bloqueados.</div>
          <div style="font-size:0.8rem;opacity:0.85;margin-top:8px"><b>Solución:</b> sube el archivo a Netlify y abre la URL real. Allí carga sin problemas.</div>
        </div>
      </div>
    </div>
  </div>
  <div id="error-display"></div>

  <script>
    // Show fallback message if app hasn't loaded after 8s
    setTimeout(function() {
      var fb = document.getElementById('loader-fallback');
      if (fb && document.getElementById('initial-loader')) fb.style.display = 'block';
    }, 8000);

    // v75: errores inofensivos (típicos de iOS al volver de segundo plano: IndexedDB de
    // Firebase, conexión cerrada...). No deben mostrar el overlay rojo a pantalla completa.
    window.__isBenignError = function(msg) {
      msg = (msg || '').toString();
      return /in-progress transaction/i.test(msg)
        || /object that is not, or is no longer, usable/i.test(msg)
        || /database connection is closing/i.test(msg)
        || /IDBDatabase|IndexedDB|transaction (is not active|finished)/i.test(msg)
        || /The operation was aborted/i.test(msg)
        || /Failed to execute 'transaction'/i.test(msg);
    };

    window.addEventListener('error', function(e) {
      var msg = (e.message || 'Error desconocido');
      // Si la app ya arrancó o es un error benigno, solo lo registramos: no tapamos la pantalla.
      if (window.__appReady || window.__isBenignError(msg) || (e.error && window.__isBenignError(e.error.message))) {
        console.error('Error (ignorado en overlay):', e.error || msg);
        return;
      }
      var d = document.getElementById('error-display');
      if (d) {
        d.style.display = 'block';
        var loc = (e.filename || '') + ':' + (e.lineno || '') + ':' + (e.colno || '');
        var stack = '';
        if (e.error && e.error.stack) stack = '\n\n' + e.error.stack;
        else if (e.error) stack = '\n\n' + String(e.error);
        d.innerHTML = '<h2 style="font-family:sans-serif">Error al cargar la app</h2>' +
          '<pre style="white-space:pre-wrap;background:rgba(0,0,0,.3);padding:10px;border-radius:8px;font-size:11px">' +
          msg + '\n' + loc + stack +
          '</pre><p style="font-family:sans-serif;font-size:14px">Recarga la página o prueba en otro navegador.</p>';
      }
    });
    window.addEventListener('unhandledrejection', function(e) {
      var reason = e.reason;
      var msg = reason && reason.message ? reason.message : String(reason);
      // App ya arrancada o error benigno → silenciar (no overlay).
      if (window.__appReady || window.__isBenignError(msg)) {
        console.error('Promesa rechazada (ignorada en overlay):', reason);
        return;
      }
      var d = document.getElementById('error-display');
      if (d && d.style.display !== 'block') {
        d.style.display = 'block';
        var stack = reason && reason.stack ? '\n\n' + reason.stack : '';
        d.innerHTML = '<h2 style="font-family:sans-serif">Promesa rechazada sin manejar</h2>' +
          '<pre style="white-space:pre-wrap;background:rgba(0,0,0,.3);padding:10px;border-radius:8px;font-size:11px">' +
          msg + stack +
          '</pre>';
      }
    });
  </script>

  <!-- Split modules: load shared globals first (constants → icons → helpers).
       These declare `var`s and `function`s at top level, which become global.
       v28-octo: usamos rutas absolutas (/js/...) porque con URLs profundas
       (/caimanes/2026-05-25/...) las rutas relativas se resuelven contra el
       directorio actual y dan 404. -->
  <script type="text/babel" data-presets="react" src="/js/constants.js"></script>
  <script type="text/babel" data-presets="react" src="/js/icons.js"></script>
  <script type="text/babel" data-presets="react" src="/js/helpers.js"></script>

  <script type="text/babel" data-presets="react">
    const { useState, useEffect, useRef } = React;

    // ============ FIREBASE SETUP ============
    const firebaseConfig = {
      apiKey: "AIzaSyAGRhC1Op31FPKvwngjKGsUZUEugHDmnLU",
      authDomain: "cartagena-ciclistas.firebaseapp.com",
      databaseURL: "https://cartagena-ciclistas-default-rtdb.europe-west1.firebasedatabase.app",
      projectId: "cartagena-ciclistas",
      storageBucket: "cartagena-ciclistas.firebasestorage.app",
      messagingSenderId: "753606517698",
      appId: "1:753606517698:web:fd14236303b106330cec5b"
    };
    firebase.initializeApp(firebaseConfig);
    const db = firebase.database();
    // v28-nonoI: Firestore se usa SOLO para disparar emails vía la extensión "Trigger Email from Firestore".
    // Todo el resto de datos sigue en Realtime Database (db).
    const fdb = firebase.firestore();
    // v28-oct12: Firebase Storage para GPX de la biblioteca de rutas (los archivos completos).
    // Los metadatos (nombre, distancia, desnivel, preview simplificado) viven en RTDB en /routeLibrary.
    const stg = firebase.storage();
    const ADMIN_NOTIFICATION_EMAIL = 'jmartinezcarrion@gmail.com';

    // ============ PUSH NOTIFICATIONS (Firebase Cloud Messaging) ============
    // Clave pública VAPID (no secreta) generada en la consola de Firebase.
    const VAPID_PUBLIC_KEY = 'BNjgOgRPWxW-G1Mo1RzCPAo4TuiLr_dx9vFz8cVFVPPG45H-wHL2zYF69JFRICKbfsNTJciOR9_tA_y7l3mpIYM';
    // messaging puede no existir si el navegador no lo soporta (p.ej. iOS sin instalar PWA).
    let fcmMessaging = null;
    try {
      if (firebase.messaging && firebase.messaging.isSupported && firebase.messaging.isSupported()) {
        fcmMessaging = firebase.messaging();
      }
    } catch (e) { fcmMessaging = null; }

    function pushIsSupported() {
      return !!(fcmMessaging && 'serviceWorker' in navigator && 'Notification' in window);
    }

    // v57: AVISOS TAMBIÉN EN PRIMER PLANO.
    // Con la app ABIERTA, los push no pasan por onBackgroundMessage del SW y no
    // se mostraba ningún cartel (parecía que "no llegaban"). Este onMessage los
    // recibe en primer plano y pinta EL MISMO cartel a través del service worker.
    // El mecanismo de segundo plano NO se toca (lección de iOS: notification +
    // showNotification en el SW). El `tag` compartido garantiza que si un cartel
    // se generase por las dos vías, el segundo SUSTITUYE al primero (anti-dobles).
    try {
      if (fcmMessaging && typeof fcmMessaging.onMessage === 'function') {
        fcmMessaging.onMessage(function(payload) {
          try {
            var n = (payload && payload.notification) || {};
            var d = (payload && payload.data) || {};
            var title = n.title || d.title || 'Grupetas';
            var options = {
              body: n.body || d.body || '',
              icon: '/icons/icon-192.png',
              badge: '/icons/icon-192.png',
              data: { url: d.url || '/' },
              tag: d.tag || undefined
            };
            if ('serviceWorker' in navigator) {
              navigator.serviceWorker.ready.then(function(reg) {
                if (reg && typeof reg.showNotification === 'function') {
                  reg.showNotification(title, options);
                }
              }).catch(function() { /* sin SW listo: no mostramos nada */ });
            }
          } catch (e) { /* best-effort: nunca rompemos la app por un aviso */ }
        });
      }
    } catch (e) { /* messaging no disponible: nada que hacer */ }

    // Pide permiso, registra el SW, obtiene el token y lo guarda en ciclistas/<uid>/pushTokens/<token>.
    // Devuelve una promesa que resuelve { ok:true, token } o { ok:false, reason }.
    function enablePushForUser(uid) {
      if (!uid) return Promise.resolve({ ok: false, reason: 'no-uid' });
      if (!pushIsSupported()) return Promise.resolve({ ok: false, reason: 'unsupported' });
      return Notification.requestPermission().then(function(perm) {
        if (perm !== 'granted') return { ok: false, reason: 'denied' };
        return navigator.serviceWorker.register('/firebase-messaging-sw.js').then(function(reg) {
          return fcmMessaging.getToken({
            vapidKey: VAPID_PUBLIC_KEY,
            serviceWorkerRegistration: reg
          });
        }).then(function(token) {
          if (!token) return { ok: false, reason: 'no-token' };
          // Guardamos el token bajo el propio usuario (las reglas ya permiten $other en ciclistas/<uid>).
          var thisUA = (navigator.userAgent || '').slice(0, 180);
          // v45+ ANTI-DOBLES: antes de guardar, leemos los tokens que ya tiene este
          // usuario y borramos los del MISMO dispositivo (mismo ua) que NO sean el
          // token actual. Así un mismo móvil no acumula varios tokens (que causaban
          // avisos dobles y tokens muertos tras reinstalar la PWA).
          return db.ref('ciclistas/' + uid + '/pushTokens').once('value').then(function(snap) {
            var existing = snap.val() || {};
            var updates = {};
            Object.keys(existing).forEach(function(oldTok) {
              if (oldTok === token) return; // el nuevo no se toca
              var info = existing[oldTok] || {};
              // Mismo dispositivo (mismo ua) -> lo eliminamos para no duplicar.
              if (info.ua && info.ua === thisUA) {
                updates['ciclistas/' + uid + '/pushTokens/' + oldTok] = null;
              }
            });
            updates['ciclistas/' + uid + '/pushTokens/' + token] = {
              ua: thisUA,
              ts: Date.now()
            };
            return db.ref().update(updates).then(function() {
              return { ok: true, token: token };
            });
          });
        });
      }).catch(function(err) {
        console.warn('enablePushForUser error:', err);
        return { ok: false, reason: 'error', error: err, detail: (err && (err.code || err.message)) || String(err) };
      });
    }

    // ============ ICONS / CONSTANTS / HELPERS now live in js/icons.js, js/constants.js, js/helpers.js ============
    // Globals available here: MONTHS, DAYS, CENTRAL_UID, APP_URL, SK, PAST_CUTOFF_HOUR,
    //                         COLORS, DIFFICULTIES, BIKE_TYPES, AVATAR_COLORS,
    //                         Icon, Plus, RefreshCw, ChevronLeft, ChevronRight, ArrowLeft, CloseIcon,
    //                         MapPin, Clock, Users, Bike, Trash2, Lock, LogOut, Upload, ImageIcon,
    //                         Shield, Crown, Mail, ArrowUp, ArrowDown, Edit2,
    //                         dateKey, isPastRide, slugify, getInitials, getColor, gradientStyle,
    //                         fullPageGradient, getBikeTypes, getUserDisplay, getUserInitials,
    //                         generateUserId, avatarColors,
    //                         generateAvisoId, isAvisoActive, formatAvisoFechaFin, defaultAvisoFechaFin,
    //                         daysUntilRide

    // Difficulty / points scale for rides — DIFFICULTIES constant lives in js/constants.js
    function DifficultyBadge({ level }) {
      const d = DIFFICULTIES[level];
      if (!d) return null;
      return (
        <span className="inline-flex items-center px-1.5 py-0.5 rounded font-bold text-[10px] tracking-wide whitespace-nowrap"
          style={{ background: d.color, color: 'white' }}>
          {level} pts
        </span>
      );
    }
    function DifficultySelector({ value, onChange }) {
      return (
        <div className="grid grid-cols-4 gap-2">
          {[1, 3, 5, 7].map(function(level) {
            const d = DIFFICULTIES[level];
            const selected = value === level;
            return (
              <button key={level} type="button" onClick={function() { onChange(level); }}
                style={selected ? { background: 'linear-gradient(135deg, ' + d.color + ', ' + d.dark + ')', color: 'white' } : {}}
                className={'rounded-lg py-2 text-center active:scale-95 transition ' + (selected ? 'shadow-md scale-105' : 'bg-gray-50 border border-gray-200 text-gray-700 hover:border-red-300')}>
                <div className="font-bold text-lg leading-none">{level}</div>
                <div className="text-[9px] uppercase tracking-wider mt-0.5">{d.label}</div>
              </button>
            );
          })}
        </div>
      );
    }
    // BIKE_TYPES + getBikeTypes live in js/constants.js + js/helpers.js
    function BikeTypeBadge({ type }) {
      const b = BIKE_TYPES[type];
      if (!b) return null;
      return (
        <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded font-bold text-[10px] tracking-wide whitespace-nowrap"
          style={{ background: b.color, color: 'white' }}>
          <span style={{ fontSize: '11px' }}>{b.emoji}</span>{b.label}
        </span>
      );
    }
    function BikeTypeSelector({ value, onChange, types }) {
      const list = types || ['carretera', 'mtb'];
      return (
        <div className="grid grid-cols-2 gap-2">
          {list.map(function(t) {
            const b = BIKE_TYPES[t];
            const selected = value === t;
            return (
              <button key={t} type="button" onClick={function() { onChange(t); }}
                style={selected ? { background: 'linear-gradient(135deg, ' + b.color + ', ' + b.dark + ')', color: 'white' } : {}}
                className={'rounded-lg py-2.5 text-center active:scale-95 transition flex items-center justify-center gap-1.5 ' + (selected ? 'shadow-md scale-[1.02]' : 'bg-gray-50 border border-gray-200 text-gray-700 hover:border-red-300')}>
                <span style={{ fontSize: '18px' }}>{b.emoji}</span>
                <span className="font-bold text-xs uppercase tracking-wider">{b.label}</span>
              </button>
            );
          })}
        </div>
      );
    }
    function buildDefaults() {
      let oldR = {}, oldS = [];
      try {
        const r = localStorage.getItem('camantes:rides');
        if (r) oldR = JSON.parse(r);
        const s = localStorage.getItem('camantes:sponsors');
        if (s) oldS = JSON.parse(s);
      } catch (e) {}
      return {
        camantes: {
          id: 'camantes',
          name: 'Salidas Camantes',
          displayName: 'SALIDAS\nCAMANTES',
          shortName: 'Camantes',
          subtitle: 'Cartagena · Amigos',
          emoji: '📍',
          logoType: 'camantes',
          color: 'red',
          statusLabel: 'Grupo activo',
          customLogo: null,
          adminPassword: '46000',
          rides: oldR,
          sponsors: oldS,
          createdAt: new Date('2020-01-01').toISOString()
        }
      };
    }

    function CamantesLogo({ size = 96 }) {
      return (
        <svg viewBox="0 0 120 120" width={size} height={size} className="drop-shadow-lg">
          <circle cx="60" cy="60" r="58" fill="white" stroke="#1a1a1a" strokeWidth="2" />
          <circle cx="60" cy="60" r="54" fill="none" stroke="#1a1a1a" strokeWidth="1" />
          <defs>
            <path id="topArc" d="M 18 60 A 42 42 0 0 1 102 60" fill="none" />
            <path id="bottomArc" d="M 22 68 A 38 38 0 0 0 98 68" fill="none" />
          </defs>
          <text fill="#1a1a1a" fontSize="9" fontWeight="700" letterSpacing="2" fontFamily="Oswald">
            <textPath href="#topArc" startOffset="50%" textAnchor="middle">LAS DOLORES</textPath>
          </text>
          <text fill="#1a1a1a" fontSize="8" fontWeight="600" letterSpacing="3" fontFamily="Oswald">
            <textPath href="#bottomArc" startOffset="50%" textAnchor="middle">2020</textPath>
          </text>
          <rect x="22" y="48" width="76" height="3" fill="#1e88e5" />
          <rect x="22" y="51" width="76" height="3" fill="#e53935" />
          <rect x="22" y="54" width="76" height="3" fill="#000" />
          <rect x="22" y="57" width="76" height="3" fill="#fdd835" />
          <rect x="22" y="60" width="76" height="3" fill="#43a047" />
          <text x="60" y="74" textAnchor="middle" fill="#1a1a1a" fontSize="11" fontWeight="800" fontFamily="Oswald" letterSpacing="1">GRUPETA</text>
          <text x="60" y="86" textAnchor="middle" fill="#1a1a1a" fontSize="11" fontWeight="800" fontFamily="Oswald" letterSpacing="1">CAMANTES</text>
        </svg>
      );
    }

    function InitialsLogo({ initials, size = 96 }) {
      return (
        <svg viewBox="0 0 120 120" width={size} height={size} className="drop-shadow-lg">
          <circle cx="60" cy="60" r="58" fill="white" stroke="#1a1a1a" strokeWidth="2" />
          <circle cx="60" cy="60" r="54" fill="none" stroke="#1a1a1a" strokeWidth="1" />
          <rect x="22" y="38" width="76" height="3" fill="#1e88e5" />
          <rect x="22" y="41" width="76" height="3" fill="#e53935" />
          <rect x="22" y="44" width="76" height="3" fill="#fdd835" />
          <rect x="22" y="47" width="76" height="3" fill="#43a047" />
          <text x="60" y="80" textAnchor="middle" fill="#1a1a1a"
                fontSize={initials.length > 2 ? "26" : "32"}
                fontWeight="800" fontFamily="Oswald" letterSpacing="2">{initials}</text>
          <text x="60" y="97" textAnchor="middle" fill="#1a1a1a" fontSize="7" fontWeight="600" fontFamily="Oswald" letterSpacing="2">CARTAGENA</text>
        </svg>
      );
    }

    function CustomLogo({ image, size = 96 }) {
      return (
        <div style={{ width: size, height: size }}
             className="rounded-full bg-white shadow-lg overflow-hidden flex items-center justify-center border border-black/10">
          <img src={image} alt="Logo" className="w-full h-full object-cover" />
        </div>
      );
    }

    function GrupetaLogo({ grupeta, size = 96 }) {
      if (grupeta.customLogo) return <CustomLogo image={grupeta.customLogo} size={size} />;
      if (grupeta.logoType === 'camantes') return <CamantesLogo size={size} />;
      return <InitialsLogo initials={getInitials(grupeta.shortName || grupeta.name)} size={size} />;
    }

    // User helpers (getUserDisplay, getUserInitials, generateUserId, avatarColors)
    // and AVATAR_COLORS live in js/helpers.js + js/constants.js

    function UserAvatar({ user, userId, size = 36, ringColor = null, onClick }) {
      const colors = avatarColors(userId || (user && user.id));
      const initials = getUserInitials(user);
      const styleObj = {
        width: size, height: size,
        background: 'linear-gradient(135deg, ' + colors[0] + ', ' + colors[1] + ')'
      };
      const ring = ringColor ? { boxShadow: '0 0 0 2px ' + ringColor + ', 0 2px 6px rgba(0,0,0,0.25)' } : { boxShadow: '0 2px 6px rgba(0,0,0,0.25)' };
      Object.assign(styleObj, ring);

      const content = user && user.avatar ? (
        <img src={user.avatar} alt={getUserDisplay(user)} className="w-full h-full object-cover" />
      ) : (
        <span className="font-bold text-white" style={{ fontSize: size * 0.42 }}>{initials}</span>
      );

      const cls = "rounded-full overflow-hidden flex items-center justify-center flex-shrink-0 " + (onClick ? "cursor-pointer active:scale-95 transition-transform" : "");

      if (onClick) {
        return <button onClick={onClick} style={styleObj} className={cls} aria-label={getUserDisplay(user)}>{content}</button>;
      }
      return <div style={styleObj} className={cls}>{content}</div>;
    }

    function StatBubble({ value, label }) {
      return (
        <div className="flex flex-col items-center">
          <div className="bg-red-700/60 backdrop-blur-sm border-2 border-white/30 w-16 h-16 rounded-full flex items-center justify-center shadow-lg">
            <span className="text-3xl font-bold body-font leading-none">{value}</span>
          </div>
          <span className="text-xs body-font font-medium mt-1.5 text-white/90">{label}</span>
        </div>
      );
    }

    function FormField({ label, children }) {
      return (
        <div>
          <label className="block text-xs font-bold display-font tracking-widest text-gray-600 mb-1.5">{label}</label>
          {children}
        </div>
      );
    }

    // useConfirm — reemplaza window.confirm en iOS PWA donde está bloqueado.
    // Uso: const { ask, ConfirmUI } = useConfirm();
    //   ask('¿Borrar?', función_si_acepta)
    //   Renderizar <ConfirmUI /> donde se quiera mostrar el diálogo.
    function useConfirm() {
      const [state, setState] = React.useState(null); // {msg, onOk}
      const ask = React.useCallback(function(msg, onOk) {
        setState({ msg: msg, onOk: onOk });
      }, []);
      const ConfirmUI = state ? (
        <div className="fixed inset-0 z-[9999] flex items-center justify-center px-6"
          style={{background:'rgba(0,0,0,0.55)'}}>
          <div className="bg-white rounded-2xl shadow-2xl w-full max-w-xs p-5">
            <p className="body-font text-sm text-gray-800 font-semibold text-center leading-snug mb-4">{state.msg}</p>
            <div className="flex gap-3">
              <button onClick={function() { setState(null); }}
                className="flex-1 py-2.5 rounded-xl border-2 border-gray-200 body-font text-sm font-bold text-gray-600 active:scale-95 transition">
                Cancelar
              </button>
              <button onClick={function() { var fn = state.onOk; setState(null); fn && fn(); }}
                className="flex-1 py-2.5 rounded-xl bg-red-600 body-font text-sm font-bold text-white active:scale-95 transition">
                Confirmar
              </button>
            </div>
          </div>
        </div>
      ) : null;
      return { ask: ask, ConfirmUI: ConfirmUI };
    }


    // ===================== MERCADILLO =====================

    var MERCADILLO_CATS = ['Bicicletas','Ruedas','Ropa','Componentes','Accesorios','Otro'];
    var MERCADILLO_TIPOS = ['Venta','Búsqueda','Cambio'];

    function resizeImgToBase64(file, maxPx, quality, cb) {
      var r = new FileReader();
      r.onload = function(ev) {
        var img = new Image();
        img.onload = function() {
          var w = img.width, h = img.height;
          if (w > maxPx || h > maxPx) {
            var ratio = Math.min(maxPx / w, maxPx / h);
            w = Math.round(w * ratio); h = Math.round(h * ratio);
          }
          var cv = document.createElement('canvas');
          cv.width = w; cv.height = h;
          cv.getContext('2d').drawImage(img, 0, 0, w, h);
          cb(cv.toDataURL('image/jpeg', quality));
        };
        img.src = ev.target.result;
      };
      r.readAsDataURL(file);
    }


  // MercadilloLegalModal, MercadilloFormModal y MercadilloDetalleModal
  // movidos a vanilla JS al final del body para evitar deoptimización Babel (>500KB)
    function MercadilloTab({ db, currentUserId, currentUser, showToast, isCentral, isAdmin, mercadilloBadge }) {
      const [items, setItems] = useState({});
      const [catFilter, setCatFilter] = useState('all');
      const [tipoFilter, setTipoFilter] = useState('all');

      // Guardamos referencias en ref para que las funciones vanilla tengan acceso fresco
      var _dbRef = React.useRef(db);
      var _userRef = React.useRef({ currentUserId, currentUser, showToast, isCentral, isAdmin });
      _dbRef.current = db;
      _userRef.current = { currentUserId, currentUser, showToast, isCentral, isAdmin };

      useEffect(function() {
        var ref = db.ref('mercadillo');
        ref.on('value', function(snap) { setItems(snap.val() || {}); });
        if (currentUserId) {
          setMercadilloLastSeen(currentUserId);
          setMercadilloChatLastSeen(currentUserId);
        }
        return function() { ref.off(); };
      }, []);

      var list = Object.entries(items)
        .map(function(pair) { return Object.assign({ _id: pair[0] }, pair[1]); })
        .sort(function(a, b) { return b.ts - a.ts; });

      if (catFilter !== 'all') list = list.filter(function(it) { return it.categoria === catFilter; });
      if (tipoFilter !== 'all') list = list.filter(function(it) { return it.tipo === tipoFilter; });

      // Llama directamente a vanilla JS — sin pasar por estado React
      function openDetalle(id, anuncio) {
        var u = _userRef.current;
        window._mercadilloOpenDetalle({
          aid: id,
          anuncio: anuncio,
          currentUserId: u.currentUserId,
          db: _dbRef.current,
          showToast: u.showToast,
          isCentral: u.isCentral,
          isAdmin: u.isAdmin,
          onEdit: function(eid, edata) {
            window._mercadilloOpenLegal({
              db: _dbRef.current,
              currentUserId: u.currentUserId,
              currentUser: u.currentUser,
              showToast: u.showToast,
              editId: eid,
              editData: edata,
            });
          },
          onClose: function() {},
        });
      }

      function openPublicar() {
        var u = _userRef.current;
        if (!u.currentUserId) return;
        window._mercadilloOpenLegal({
          db: _dbRef.current,
          currentUserId: u.currentUserId,
          currentUser: u.currentUser,
          showToast: u.showToast,
          editId: null,
          editData: null,
        });
      }

      return (
        <div>
          {/* Cabecera */}
          <div className="flex items-center justify-between mb-4">
            <div>
              <h3 className="display-font font-bold text-lg tracking-widest text-white">🛒 MERCADILLO</h3>
              <p className="body-font text-xs text-white/50">Material ciclista de segunda mano</p>
            </div>
            {currentUserId && (
              <button onClick={openPublicar}
                className="bg-yellow-400 hover:bg-yellow-300 text-purple-900 display-font font-bold text-xs tracking-wider px-3 py-2 rounded-xl transition">
                + PUBLICAR
              </button>
            )}
          </div>

          {/* Filtro tipo */}
          <div className="flex gap-2 mb-2 overflow-x-auto scrollbar-hide pb-1">
            {[['all','Todos'],['Venta','Venta'],['Búsqueda','Busco'],['Cambio','Cambio']].map(function(pair){
              return (
                <button key={pair[0]} onClick={function(){setTipoFilter(pair[0]);}}
                  className={'flex-shrink-0 body-font text-xs font-bold px-3 py-1 rounded-full border transition ' +
                    (tipoFilter===pair[0] ? 'bg-yellow-400 text-purple-900 border-yellow-400' : 'bg-white/10 text-white/70 border-white/20')}>
                  {pair[1]}
                </button>
              );
            })}
          </div>

          {/* Filtro categoría */}
          <div className="flex gap-2 mb-5 overflow-x-auto scrollbar-hide pb-1">
            {['all'].concat(MERCADILLO_CATS).map(function(c){
              return (
                <button key={c} onClick={function(){setCatFilter(c);}}
                  className={'flex-shrink-0 body-font text-xs px-3 py-1 rounded-full border transition ' +
                    (catFilter===c ? 'bg-purple-500 text-white border-purple-500' : 'bg-white/5 text-white/50 border-white/10')}>
                  {c === 'all' ? 'Todo' : c}
                </button>
              );
            })}
          </div>

          {/* Grid de anuncios */}
          {list.length === 0 ? (
            <p className="body-font text-sm text-white/40 text-center py-10">No hay anuncios aún.<br/>¡Sé el primero en publicar!</p>
          ) : (
            <div className="grid grid-cols-2 gap-3">
              {list.map(function(it) {
                var tipoClr = it.tipo==='Venta' ? 'bg-green-500' : it.tipo==='Búsqueda' ? 'bg-amber-500' : 'bg-blue-500';
                var anuncio = it;
                return (
                  <div key={it._id} onClick={function(){openDetalle(anuncio._id, anuncio);}}
                    className="bg-white/10 hover:bg-white/15 rounded-xl overflow-hidden cursor-pointer transition active:scale-[0.98]">
                    <div className="w-full h-28 bg-white/5 flex items-center justify-center overflow-hidden relative">
                      {it.fotos && it.fotos.length > 0
                        ? <img src={it.fotos[0]} className="w-full h-full object-cover" />
                        : <span className="text-4xl">🚴</span>
                      }
                      <span className={'absolute top-1.5 left-1.5 text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full ' + tipoClr}>
                        {it.tipo}
                      </span>
                      {it.fotos && it.fotos.length > 1 && (
                        <span className="absolute top-1.5 right-1.5 bg-black/50 text-white text-[9px] px-1.5 py-0.5 rounded-full">
                          📷 {it.fotos.length}
                        </span>
                      )}
                      {it.autorId === currentUserId && mercadilloBadge > 0 && (
                        <span className="absolute bottom-1.5 right-1.5 w-4 h-4 bg-orange-500 rounded-full border-2 border-white animate-pulse" title="Tienes mensajes"></span>
                      )}
                    </div>
                    <div className="p-2.5">
                      <p className="body-font text-[10px] text-purple-300 font-medium mb-0.5">{it.categoria}</p>
                      <p className="body-font text-xs font-bold text-white leading-tight mb-1 line-clamp-2">{it.titulo}</p>
                      <p className="display-font font-bold text-sm text-yellow-400">
                        {it.precio !== null && it.precio !== undefined ? it.precio.toLocaleString('es-ES') + ' €' : 'A convenir'}
                      </p>
                      <p className="body-font text-[10px] text-white/40 mt-1">{it.localidad || ''}</p>
                    </div>
                  </div>
                );
              })}
            </div>
          )}
        </div>
      );
    }

    // v136: COMIDAS/CENAS — componentes separados para respetar reglas de hooks
    // ===================== ENCUESTAS =====================

    function EncuestaFormModal({ grupeta, currentUserId, currentUser, db, showToast, onClose, editId, editData }) {
      var ed = editData || {};
      const [ePregunta, setEPregunta] = useState(ed.pregunta || '');
      const [eOpciones, setEOpciones] = useState(ed.opciones || ['', '']);
      const [eFecha, setEFecha] = useState(ed.fechaCierre || '');
      const [eAnonimo, setEAnonimo] = useState(ed.anonimo !== false);
      const [eSaving, setESaving] = useState(false);

      function guardarEncuesta() {
        var opsFiltradas = eOpciones.map(function(o) { return o.trim(); }).filter(Boolean);
        if (!ePregunta.trim()) { showToast('Pon la pregunta'); return; }
        if (opsFiltradas.length < 2) { showToast('Añade al menos 2 opciones'); return; }
        if (!eFecha) { showToast('Pon la fecha de cierre'); return; }
        setESaving(true);
        var datos = {
          pregunta: ePregunta.trim(),
          opciones: opsFiltradas,
          fechaCierre: eFecha,
          anonimo: eAnonimo,
          createdBy: currentUserId,
          createdByName: (currentUser && (currentUser.nombre || currentUser.displayName)) || '',
          createdAt: ed.createdAt || Date.now(),
          votos: ed.votos || {}
        };
        var ref = editId
          ? db.ref('encuestas/' + grupeta.id + '/' + editId)
          : db.ref('encuestas/' + grupeta.id).push();
        var op = editId ? ref.update(datos) : ref.set(datos);
        op.then(function() {
          showToast(editId ? '✅ Encuesta actualizada' : '✅ Encuesta creada');
          onClose();
        }).catch(function(e) { showToast('Error: ' + e.message); setESaving(false); });
      }

      var lbl = 'display-font text-xs font-bold tracking-widest text-gray-500 uppercase mb-1 block';
      var inp = 'w-full border-2 border-gray-200 focus:border-purple-400 rounded-xl px-3 py-2 text-sm outline-none';

      return (
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-2xl tracking-wide mb-1" style={{color:'#7c3aed'}}>{editId ? '✏️ EDITAR ENCUESTA' : '📊 NUEVA ENCUESTA'}</h2>
          <p className="body-font text-sm text-gray-500 mb-4">Solo el administrador puede crear encuestas.</p>

          <label className={lbl}>Pregunta</label>
          <input type="text" value={ePregunta} maxLength={200} placeholder="¿A qué hora prefieres la salida?"
            onChange={function(e) { setEPregunta(e.target.value); }}
            className={inp + ' mb-4'} />

          <label className={lbl}>Opciones</label>
          <div className="flex flex-col gap-2 mb-3">
            {eOpciones.map(function(op, i) {
              return (
                <div key={i} className="flex gap-2 items-center">
                  <input type="text" value={op} maxLength={100} placeholder={'Opción ' + (i+1)}
                    onChange={function(e) {
                      var arr = eOpciones.slice();
                      arr[i] = e.target.value;
                      setEOpciones(arr);
                    }}
                    className="flex-1 border-2 border-gray-200 focus:border-purple-400 rounded-xl px-3 py-2 text-sm outline-none" />
                  {eOpciones.length > 2 && (
                    <button onClick={function() { setEOpciones(eOpciones.filter(function(_,j) { return j !== i; })); }}
                      className="text-red-400 text-lg font-bold px-2 active:opacity-70">✕</button>
                  )}
                </div>
              );
            })}
            {eOpciones.length < 8 && (
              <button onClick={function() { setEOpciones(eOpciones.concat([''])); }}
                className="w-full border-2 border-dashed border-purple-200 text-purple-600 display-font font-bold text-xs tracking-wide rounded-xl py-2 active:opacity-70">
                + AÑADIR OPCIÓN
              </button>
            )}
          </div>

          <div className="grid grid-cols-2 gap-3 mb-4">
            <div>
              <label className={lbl}>Fecha cierre</label>
              <input type="date" value={eFecha} onChange={function(e) { setEFecha(e.target.value); }}
                className={inp} />
            </div>
            <div>
              <label className={lbl}>Votos</label>
              <select value={eAnonimo ? 'anonimo' : 'visible'}
                onChange={function(e) { setEAnonimo(e.target.value === 'anonimo'); }}
                className={inp}>
                <option value="anonimo">Anónimos</option>
                <option value="visible">Visibles</option>
              </select>
            </div>
          </div>

          <button onClick={guardarEncuesta} disabled={eSaving}
            className="w-full display-font font-bold tracking-widest py-3 rounded-2xl text-white active:scale-[0.98] transition"
            style={{background:'#7c3aed', opacity: eSaving ? 0.6 : 1}}>
            {eSaving ? 'GUARDANDO...' : (editId ? '✅ GUARDAR CAMBIOS' : '📊 CREAR ENCUESTA')}
          </button>
        </Modal>
      );
    }

    function EncuestaDetalleModal({ eid, encuesta, grupeta, currentUserId, db, showToast, effectiveAdmin, onEdit, onClose }) {
      if (!encuesta) return null;
      var hoy = (function() { var n=new Date(); return n.getFullYear()+'-'+String(n.getMonth()+1).padStart(2,'0')+'-'+String(n.getDate()).padStart(2,'0'); })();
      var cerrada = encuesta.fechaCierre && encuesta.fechaCierre < hoy;
      var votos = encuesta.votos || {};
      var miVoto = currentUserId ? (votos[currentUserId] !== undefined ? votos[currentUserId] : null) : null;
      var totalVotos = Object.keys(votos).length;
      const [showConfirmBorrar, setShowConfirmBorrar] = useState(false);

      function contarOpcion(idx) {
        return Object.values(votos).filter(function(v) { return v === idx; }).length;
      }

      function votar(idx) {
        if (cerrada || !currentUserId) return;
        var ref = db.ref('encuestas/' + grupeta.id + '/' + eid + '/votos/' + currentUserId);
        if (miVoto === idx) {
          ref.remove().catch(function(e) { showToast('Error: '+e.message); });
        } else {
          ref.set(idx).catch(function(e) { showToast('Error: '+e.message); });
        }
      }

      function borrar() {
        db.ref('encuestas/' + grupeta.id + '/' + eid).remove()
          .then(function() { showToast('Encuesta eliminada'); onClose(); })
          .catch(function(e) { showToast('Error: '+e.message); });
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-start gap-2 mb-1">
            <span className="text-xl flex-shrink-0">📊</span>
            <h2 className="display-font font-bold text-lg tracking-wide" style={{color:'#7c3aed'}}>{encuesta.pregunta}</h2>
          </div>
          <div className="flex items-center gap-2 mb-4 flex-wrap">
            <span className="body-font text-xs text-gray-400">
              {cerrada ? '🔒 Cerrada' : ('Cierra el ' + encuesta.fechaCierre)}
            </span>
            <span className="text-xs text-gray-300">·</span>
            <span className="body-font text-xs text-gray-400">{totalVotos} {totalVotos === 1 ? 'voto' : 'votos'}</span>
            {!encuesta.anonimo && <span className="body-font text-xs text-gray-400">· Votos visibles</span>}
          </div>

          <div className="flex flex-col gap-2 mb-4">
            {(encuesta.opciones || []).map(function(op, i) {
              var n = contarOpcion(i);
              var pct = totalVotos > 0 ? Math.round(n / totalVotos * 100) : 0;
              var esMia = miVoto === i;
              return (
                <button key={i} onClick={function() { votar(i); }} disabled={!!cerrada}
                  className={'w-full rounded-xl border-2 overflow-hidden text-left transition ' + (esMia ? 'border-purple-400' : 'border-gray-200') + (cerrada ? '' : ' active:scale-[0.99]')}>
                  <div className="relative px-3 py-2.5">
                    <div className="absolute left-0 top-0 h-full rounded-xl"
                      style={{width: pct + '%', background: esMia ? 'rgba(124,58,237,0.12)' : 'rgba(0,0,0,0.04)'}} />
                    <div className="relative flex items-center gap-2">
                      <span style={{minWidth:'16px',color:esMia?'#7c3aed':'transparent',fontSize:'14px',fontWeight:'bold'}}>✓</span>
                      <span className={'flex-1 text-sm font-bold ' + (esMia ? 'text-purple-700' : 'text-gray-700')}>{op}</span>
                      <span className="text-xs text-gray-400 font-bold">{pct}%</span>
                      <span className="text-xs text-gray-400 ml-1">({n})</span>
                    </div>
                  </div>
                </button>
              );
            })}
          </div>

          {!cerrada && miVoto !== null && (
            <p className="body-font text-xs text-center text-gray-400 mb-3">Tu voto: <strong>{encuesta.opciones[miVoto]}</strong> · Puedes cambiarlo antes del cierre</p>
          )}
          {!cerrada && miVoto === null && currentUserId && (
            <p className="body-font text-xs text-center mb-3" style={{color:'#7c3aed'}}>Pulsa una opción para votar</p>
          )}

          {effectiveAdmin && (
            <div className="flex gap-2 mt-2 pt-3 border-t border-gray-100">
              {onEdit && (
                <button onClick={function() { onEdit(eid, encuesta); }}
                  className="flex-1 border-2 border-purple-200 display-font font-bold text-xs tracking-widest py-2.5 rounded-2xl active:scale-[0.98] transition bg-purple-50 text-purple-700">
                  ✏️ EDITAR
                </button>
              )}
              {!showConfirmBorrar ? (
                <button onClick={function() { setShowConfirmBorrar(true); }}
                  className="flex-1 bg-red-50 border-2 border-red-200 text-red-600 display-font font-bold text-xs tracking-widest py-2.5 rounded-2xl active:scale-[0.98] transition">
                  🗑️ ELIMINAR
                </button>
              ) : (
                <div className="flex-1 bg-red-50 border-2 border-red-200 rounded-2xl px-3 py-2 flex items-center justify-between gap-2">
                  <span className="text-xs text-red-700 font-bold">¿Seguro?</span>
                  <button onClick={borrar} className="text-xs font-bold text-white bg-red-600 px-3 py-1 rounded-lg active:opacity-70">Sí</button>
                  <button onClick={function() { setShowConfirmBorrar(false); }} className="text-xs font-bold text-gray-500">No</button>
                </div>
              )}
            </div>
          )}
        </Modal>
      );
    }

    function ComidaFormModal({ grupeta, currentUserId, currentUser, db, showToast, onClose, editData, editId }) {
      const ed = editData || {};
      const [cTitulo, setCTitulo]           = useState(ed.titulo||'');
      const [cRestaurante, setCRestaurante] = useState(ed.restaurante||'');
      const [cFecha, setCFecha]             = useState(ed.fecha||'');
      const [cHora, setCHora]               = useState(ed.hora||'');
      const [cMenuA, setCMenuA]             = useState(ed.menuA&&ed.menuA!=='Menú A'?ed.menuA:'');
      const [cMenuB, setCMenuB]             = useState(ed.menuB&&ed.menuB!=='Menú B'?ed.menuB:'');
      const [cPrecioA, setCPrecioA]         = useState(ed.precioA||'');
      const [cPrecioB, setCPrecioB]         = useState(ed.precioB||'');
      const [cSaving, setCSaving]           = useState(false);
      const [cDetalles, setCDetalles]       = useState(ed.detalles||'');
      const [cDescA, setCDescA]             = useState(ed.descA||'');
      const [cDescB, setCDescB]             = useState(ed.descB||'');

      function guardarComida() {
        if (!cTitulo.trim() || !cFecha) { showToast('Pon título y fecha'); return; }
        setCSaving(true);
        var datos = {
          titulo: cTitulo.trim(),
          restaurante: cRestaurante.trim(),
          fecha: cFecha,
          hora: cHora,
          menuA: cMenuA.trim() || 'Menú A',
          menuB: cMenuB.trim() || 'Menú B',
          precioA: cPrecioA ? Number(cPrecioA) : null,
          precioB: cPrecioB ? Number(cPrecioB) : null,
          descA: cDescA.trim() || null,
          descB: cDescB.trim() || null,
          detalles: cDetalles.trim() || null,
        };
        var ref = editId
          ? db.ref('comidas/' + grupeta.id + '/' + editId)
          : db.ref('comidas/' + grupeta.id).push();
        if (!editId) {
          datos.createdBy = currentUserId;
          datos.createdByName = (currentUser && (currentUser.nombre || currentUser.displayName)) || '';
          datos.createdAt = Date.now();
        }
        var op = editId ? ref.update(datos) : ref.set(datos);
        op.then(function() {
          showToast(editId ? '✅ Comida actualizada' : '✅ Comida creada');
          onClose();
        }).catch(function(e) { showToast('Error: ' + e.message); setCSaving(false); });
      }

      var lbl = 'block text-[11px] font-bold display-font tracking-widest text-gray-600 mb-1.5';
      var inp = 'w-full border-2 border-gray-200 focus:border-yellow-400 rounded-xl px-3 py-2 text-sm mb-3 outline-none';

      return (
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-2xl tracking-wide mb-1" style={{color:'#ca8a04'}}>{editId ? '✏️ EDITAR COMIDA' : '🍽️ ORGANIZAR COMIDA / CENA'}</h2>
          <p className="body-font text-sm text-gray-500 mb-4">{editId ? 'Modifica los datos de la comida.' : 'Cualquier socio puede crear. Los demás podrán apuntarse y elegir menú.'}</p>

          <label className={lbl}>TÍTULO *</label>
          <input type="text" value={cTitulo} maxLength={80} placeholder="Ej: Cena fin de temporada"
            onChange={function(e) { setCTitulo(e.target.value); }} className={inp} />

          <label className={lbl}>RESTAURANTE</label>
          <input type="text" value={cRestaurante} maxLength={80} placeholder="Nombre del restaurante (opcional)"
            onChange={function(e) { setCRestaurante(e.target.value); }} className={inp} />

          <div className="grid grid-cols-2 gap-3 mb-3">
            <div>
              <label className={lbl}>FECHA *</label>
              <input type="date" value={cFecha} onChange={function(e) { setCFecha(e.target.value); }}
                className="w-full border-2 border-gray-200 focus:border-yellow-400 rounded-xl px-3 py-2 text-sm outline-none" />
            </div>
            <div>
              <label className={lbl}>HORA</label>
              <input type="time" value={cHora} onChange={function(e) { setCHora(e.target.value); }}
                className="w-full border-2 border-gray-200 focus:border-yellow-400 rounded-xl px-3 py-2 text-sm outline-none" />
            </div>
          </div>

          <div className="bg-yellow-50 border border-yellow-200 rounded-xl p-3 mb-4">
            <div className="text-[11px] font-bold display-font tracking-widest text-yellow-700 mb-2">OPCIONES DE MENÚ E IMPORTE</div>
            <div className="grid grid-cols-3 gap-2 mb-1">
              <input type="text" value={cMenuA} maxLength={60} placeholder="Menú A"
                onChange={function(e) { setCMenuA(e.target.value); }}
                className="col-span-2 border-2 border-yellow-200 focus:border-yellow-400 rounded-xl px-3 py-2 text-sm outline-none bg-white" />
              <div className="relative">
                <input type="number" value={cPrecioA} min="0" max="999" placeholder="€"
                  onChange={function(e) { setCPrecioA(e.target.value); }}
                  className="w-full border-2 border-yellow-200 focus:border-yellow-400 rounded-xl px-3 py-2 text-sm outline-none bg-white pr-6" />
                <span className="absolute right-2 top-1/2 -translate-y-1/2 text-yellow-600 text-xs font-bold">€</span>
              </div>
            </div>
            <textarea value={cDescA} maxLength={150} placeholder="Descripción del Menú A (primero, segundo, postre...)"
              onChange={function(e) { setCDescA(e.target.value); }} rows={2}
              className="w-full border-2 border-yellow-200 focus:border-yellow-400 rounded-xl px-3 py-2 text-sm outline-none bg-white mb-3 resize-none" />
            <div className="grid grid-cols-3 gap-2 mb-1">
              <input type="text" value={cMenuB} maxLength={60} placeholder="Menú B"
                onChange={function(e) { setCMenuB(e.target.value); }}
                className="col-span-2 border-2 border-yellow-200 focus:border-yellow-400 rounded-xl px-3 py-2 text-sm outline-none bg-white" />
              <div className="relative">
                <input type="number" value={cPrecioB} min="0" max="999" placeholder="€"
                  onChange={function(e) { setCPrecioB(e.target.value); }}
                  className="w-full border-2 border-yellow-200 focus:border-yellow-400 rounded-xl px-3 py-2 text-sm outline-none bg-white pr-6" />
                <span className="absolute right-2 top-1/2 -translate-y-1/2 text-yellow-600 text-xs font-bold">€</span>
              </div>
            </div>
            <textarea value={cDescB} maxLength={150} placeholder="Descripción del Menú B (primero, segundo, postre...)"
              onChange={function(e) { setCDescB(e.target.value); }} rows={2}
              className="w-full border-2 border-yellow-200 focus:border-yellow-400 rounded-xl px-3 py-2 text-sm outline-none bg-white resize-none" />
          </div>

          <label className={lbl}>DETALLES / INDICACIONES</label>
          <textarea value={cDetalles} maxLength={600} rows={4}
            placeholder="Parking, menú infantil, cómo llegar, hora límite para confirmar... (máx. 600 caracteres)"
            onChange={function(e) { setCDetalles(e.target.value); }}
            className="w-full border-2 border-gray-200 focus:border-yellow-400 rounded-xl px-3 py-2 text-sm mb-1 outline-none resize-none" />
          <div className="text-right text-[10px] text-gray-400 mb-3">{cDetalles.length}/600</div>

          <button onClick={guardarComida} disabled={cSaving}
            className="w-full text-white display-font font-bold text-sm tracking-widest py-3 rounded-2xl active:scale-[0.98] transition disabled:opacity-50"
            style={{background:'linear-gradient(135deg,#eab308,#ca8a04)'}}>
            {cSaving ? 'GUARDANDO...' : (editId ? '✅ GUARDAR CAMBIOS' : '🍽️ CREAR COMIDA')}
          </button>
        </Modal>
      );
    }


    // ComidaDetalleModal → js/modals-a.js
    function Modal({ children, onClose, fullScreen }) {
      useEffect(function() {
        var gnav = document.getElementById('gnav');
        function onFocusIn(e) {
          var tag = (e.target && e.target.tagName) || '';
          if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
            if (gnav) gnav.classList.add('kb-hidden');
          }
        }
        function onFocusOut() {
          setTimeout(function() { if (gnav) gnav.classList.remove('kb-hidden'); }, 400);
        }
        document.addEventListener('focusin',  onFocusIn);
        document.addEventListener('focusout', onFocusOut);
        return function() {
          document.removeEventListener('focusin',  onFocusIn);
          document.removeEventListener('focusout', onFocusOut);
          if (gnav) gnav.classList.remove('kb-hidden');
        };
      }, []);
      if (fullScreen) {
        // v80: variante a pantalla casi completa (para el detalle de salidas, que es alto).
        // La ✕ queda FIJA arriba; el contenido va en una zona con scroll propia.
        return (
          <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 fade-up" onClick={onClose}>
            <div className="bg-white text-gray-900 rounded-t-3xl sm:rounded-3xl w-full max-w-md shadow-2xl flex flex-col h-[94dvh] sm:h-auto sm:max-h-[92vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
              <div className="flex justify-end px-4 pt-4 pb-1 shrink-0">
                <button onClick={onClose} className="w-9 h-9 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center active:scale-90 transition" aria-label="Cerrar">
                  <CloseIcon size={18} className="text-gray-600" />
                </button>
              </div>
              <div className="flex-1 overflow-y-auto scrollbar-hide px-6 pb-24">
                {children}
              </div>
            </div>
          </div>
        );
      }
      return (
        <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 fade-up" onClick={onClose}>
          <div className="bg-white text-gray-900 rounded-t-3xl sm:rounded-3xl w-full max-w-md p-6 pb-24 max-h-[92vh] overflow-y-auto scrollbar-hide shadow-2xl" onClick={(e) => e.stopPropagation()}>
            <div className="flex justify-end mb-2 -mt-2 -mr-2">
              <button onClick={onClose} className="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center active:scale-90 transition" aria-label="Cerrar">
                <CloseIcon size={16} className="text-gray-600" />
              </button>
            </div>
            {children}
          </div>
        </div>
      );
    }

    // Reusable profile form - used for both creating and editing
    function ProfileForm({ initialUser, grupetas, onSave, onCancel, title, subtitle, submitLabel, adminMode }) {
      const [nombre, setNombre] = useState(initialUser ? initialUser.nombre || '' : '');
      const [apellidos, setApellidos] = useState(initialUser ? initialUser.apellidos || '' : '');
      // v28-quinque: clubs es un array. Inicializamos desde el helper que soporta string o array.
      const [clubs, setClubs] = useState(initialUser ? getUserClubs(initialUser) : []);
      // Campo "otro club" (texto libre) — si el usuario quiere añadir uno no listado.
      const [otroClub, setOtroClub] = useState('');
      const [telefono, setTelefono] = useState(initialUser ? initialUser.telefono || '' : '');
      const [strava, setStrava] = useState(initialUser ? initialUser.strava || '' : '');
      const [correo, setCorreo] = useState(initialUser ? initialUser.correo || '' : '');
      const [avatar, setAvatar] = useState(initialUser ? initialUser.avatar || '' : '');
      const [avatarUploading, setAvatarUploading] = useState(false);
      const [err, setErr] = useState('');

      // Helpers locales del form
      function toggleClub(name) {
        const t = String(name || '').trim();
        if (!t) return;
        setClubs(function(prev) {
          const exists = prev.some(function(c) { return c.toLowerCase() === t.toLowerCase(); });
          if (exists) {
            return prev.filter(function(c) { return c.toLowerCase() !== t.toLowerCase(); });
          }
          return prev.concat([t]);
        });
      }
      function addOtroClub() {
        const t = otroClub.trim();
        if (!t) return;
        // v28-oct23: no permitir colarse en una grupeta privada por el campo de texto libre.
        const gList = grupetas ? Object.values(grupetas) : [];
        const privMatch = !adminMode && gList.some(function(g) {
          if (!g.private) return false;
          const label = (g.shortName || g.name || '').toLowerCase();
          const fullName = (g.name || '').toLowerCase();
          return label === t.toLowerCase() || fullName === t.toLowerCase();
        });
        if (privMatch) {
          setErr('Esa grupeta es privada: solicita unirte desde la grupeta y que el admin te acepte.');
          setOtroClub('');
          return;
        }
        const exists = clubs.some(function(c) { return c.toLowerCase() === t.toLowerCase(); });
        if (!exists) {
          setClubs(clubs.concat([t]));
        }
        setOtroClub('');
      }
      function removeClub(name) {
        setClubs(clubs.filter(function(c) { return c !== name; }));
      }

      function handleAvatarSelect(e) {
        const f = e.target.files[0];
        if (!f) return;
        if (!f.type.startsWith('image/')) { setErr('Imagen no válida'); return; }
        setAvatarUploading(true);
        const r = new FileReader();
        r.onload = function(ev) {
          const img = new window.Image();
          img.onload = function() {
            const cv = document.createElement('canvas');
            const ms = 300;
            // Square crop centered
            const side = Math.min(img.width, img.height);
            const sx = (img.width - side) / 2;
            const sy = (img.height - side) / 2;
            cv.width = ms; cv.height = ms;
            const cx = cv.getContext('2d');
            cx.fillStyle = '#fff'; cx.fillRect(0, 0, ms, ms);
            cx.drawImage(img, sx, sy, side, side, 0, 0, ms, ms);
            setAvatar(cv.toDataURL('image/jpeg', 0.85));
            setAvatarUploading(false);
          };
          img.onerror = function() { setErr('Error con la imagen'); setAvatarUploading(false); };
          img.src = ev.target.result;
        };
        r.onerror = function() { setErr('Error al leer'); setAvatarUploading(false); };
        r.readAsDataURL(f);
      }

      function submit() {
        if (!nombre.trim()) { setErr('El nombre es obligatorio'); return; }
        if (!apellidos.trim()) { setErr('Los apellidos son obligatorios'); return; }
        if (correo.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(correo.trim())) {
          setErr('Correo no válido');
          return;
        }
        // v28-quinque: clubs es un array deduplicado y sin vacíos.
        const seen = {};
        const cleanClubs = [];
        clubs.forEach(function(c) {
          const t = String(c || '').trim();
          if (!t) return;
          const k = t.toLowerCase();
          if (seen[k]) return;
          seen[k] = true;
          cleanClubs.push(t);
        });
        onSave({
          nombre: nombre.trim(),
          apellidos: apellidos.trim(),
          clubs: cleanClubs,
          telefono: telefono.trim(),
          strava: strava.trim(),
          correo: correo.trim(),
          avatar: avatar || ''
        });
      }

      const previewUser = { nombre: nombre, apellidos: apellidos, avatar: avatar };
      const grupetaList = grupetas ? Object.values(grupetas) : [];

      return (
        <div>
          <h2 className="display-font font-bold text-2xl tracking-wide mb-1 text-red-700">{title}</h2>
          <p className="body-font text-sm text-gray-500 mb-5">{subtitle}</p>

          {/* Avatar upload */}
          <div className="flex flex-col items-center mb-5">
            <div className="relative">
              <UserAvatar user={previewUser} userId="preview" size={88} />
              <label className="absolute -bottom-1 -right-1 w-9 h-9 rounded-full bg-red-700 hover:bg-red-800 text-white flex items-center justify-center cursor-pointer shadow-lg active:scale-90 transition border-2 border-white">
                <input type="file" accept="image/*" onChange={handleAvatarSelect} className="hidden" disabled={avatarUploading} />
                {avatarUploading ? <RefreshCw size={14} strokeWidth={3} className="spinning" /> : <Upload size={14} strokeWidth={3} />}
              </label>
              {avatar && (
                <button onClick={function() { setAvatar(''); }}
                  className="absolute -top-1 -right-1 w-7 h-7 rounded-full bg-white shadow-md flex items-center justify-center text-gray-700 hover:text-red-600 active:scale-90 transition border border-gray-200">
                  <CloseIcon size={12} strokeWidth={2.5} />
                </button>
              )}
            </div>
            <p className="text-[11px] body-font text-gray-500 mt-2">Pulsa para subir tu foto</p>
          </div>

          <div className="space-y-3">
            <div className="grid grid-cols-2 gap-3">
              <FormField label="Nombre *">
                <input type="text" value={nombre} onChange={function(e) { setNombre(e.target.value); setErr(''); }}
                  placeholder="Juan" autoFocus={!initialUser} className={inputCls} />
              </FormField>
              <FormField label="Apellidos *">
                <input type="text" value={apellidos} onChange={function(e) { setApellidos(e.target.value); setErr(''); }}
                  placeholder="García López" className={inputCls} />
              </FormField>
            </div>
            <FormField label="Clubes / Grupetas">
              {grupetaList.length > 0 ? (
                <div className="space-y-1.5">
                  <p className="text-[11px] body-font text-gray-500 mb-1">Marca todas las grupetas a las que perteneces.</p>
                  <div className="grid grid-cols-1 gap-1.5 max-h-64 overflow-y-auto border border-gray-200 rounded-xl p-2 bg-gray-50">
                    {grupetaList.map(function(g) {
                      const label = g.shortName || g.name;
                      const checked = clubs.some(function(c) { return c.toLowerCase() === String(label).toLowerCase(); });
                      // v28-oct23: grupeta privada → no se puede auto-marcar. Hay que solicitar
                      // unirse y que el admin acepte. Si YA eras socio (checked), se respeta
                      // (puedes seguir o quitarte), pero un nuevo usuario no puede entrar solo.
                      const lockedPriv = !!g.private && !checked && !adminMode;
                      if (lockedPriv) {
                        return (
                          <div key={g.id}
                            className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 bg-purple-50 border border-purple-200">
                            <Lock size={15} strokeWidth={2.5} className="text-purple-700 flex-shrink-0" />
                            <span className="body-font text-sm font-medium text-purple-900 truncate flex-1">{label}</span>
                            <span className="body-font text-[10px] font-bold text-purple-700 tracking-wide whitespace-nowrap">SOLICITA UNIRTE</span>
                          </div>
                        );
                      }
                      return (
                        <label key={g.id} className={
                          "flex items-center gap-2.5 cursor-pointer rounded-lg px-2.5 py-2 transition select-none " +
                          (checked ? "bg-red-50 border border-red-200" : "bg-white border border-gray-200 hover:bg-gray-100")
                        }>
                          <input type="checkbox" checked={checked}
                            onChange={function() { toggleClub(label); }}
                            className="w-4 h-4 accent-red-700 flex-shrink-0" />
                          <span className="body-font text-sm font-medium text-gray-800 truncate">{label}</span>
                        </label>
                      );
                    })}
                  </div>
                  {/* Clubes "extra" que el usuario ya tiene pero NO están en grupetaList (ej. texto libre legacy) */}
                  {clubs.filter(function(c) {
                    return !grupetaList.some(function(g) {
                      const label = g.shortName || g.name;
                      return String(label).toLowerCase() === String(c).toLowerCase();
                    });
                  }).map(function(c) {
                    return (
                      <div key={'extra-' + c} className="flex items-center gap-2 bg-yellow-50 border border-yellow-200 rounded-lg px-2.5 py-1.5">
                        <span className="body-font text-sm font-medium text-gray-800 truncate flex-1">{c}</span>
                        <button onClick={function() { removeClub(c); }}
                          className="text-[11px] display-font font-bold tracking-wider text-red-700 hover:text-red-900 px-1.5"
                          type="button">QUITAR</button>
                      </div>
                    );
                  })}
                  {/* Añadir un club "otro" no listado */}
                  <div className="flex gap-2 mt-1">
                    <input type="text" value={otroClub}
                      onChange={function(e) { setOtroClub(e.target.value); }}
                      onKeyDown={function(e) { if (e.key === 'Enter') { e.preventDefault(); addOtroClub(); } }}
                      placeholder="¿Otro club no listado? Escríbelo aquí"
                      className={inputCls + ' text-sm'} />
                    <button onClick={addOtroClub} type="button"
                      className="bg-red-700 hover:bg-red-800 text-white font-bold display-font text-xs tracking-wider rounded-xl px-3 active:scale-95 transition">
                      AÑADIR
                    </button>
                  </div>
                </div>
              ) : (
                <input type="text" value={clubs[0] || ''}
                  onChange={function(e) { setClubs(e.target.value ? [e.target.value] : []); }}
                  placeholder="Tu club" className={inputCls} />
              )}
            </FormField>
            <FormField label="Teléfono">
              <input type="tel" value={telefono} onChange={function(e) { setTelefono(e.target.value); }}
                placeholder="+34 600 000 000" className={inputCls} />
            </FormField>
            <FormField label="Mi Strava (opcional)">
              <input type="text" value={strava} onChange={function(e) { setStrava(e.target.value); }}
                placeholder="usuario o enlace de Strava" className={inputCls} />
              <p className="body-font text-[10px] text-gray-400 mt-1">Pega tu enlace (strava.com/athletes/...) o tu usuario. Saldrá un botón naranja "Sígueme en Strava" junto a tu nombre.</p>
            </FormField>
            <FormField label="Correo (verificado)">
              <input type="email" value={correo} readOnly disabled
                placeholder="tu@correo.com"
                className={inputCls + ' bg-gray-100 text-gray-500 cursor-not-allowed'} />
            </FormField>
          </div>

          {err && <p className="text-red-600 text-xs body-font mt-3 font-medium">{err}</p>}

          <div className="flex gap-2 mt-5">
            {onCancel && <button onClick={onCancel} className={cancelBtn}>CANCELAR</button>}
            <button onClick={submit} className={primaryBtn}>{submitLabel || 'GUARDAR'}</button>
          </div>
        </div>
      );
    }

    // ============ GESTIÓN DE ROLES (v145) ============
    function RolesPanel() {
      const [roleUid, setRoleUid] = useState('');
      const [roleVal, setRoleVal] = useState('admin');
      const [roleBusy, setRoleBusy] = useState(false);
      const [roleMsg, setRoleMsg] = useState('');

      function asignarRol() {
        if (!roleUid.trim()) { setRoleMsg('⚠️ Introduce el UID del usuario'); return; }
        setRoleBusy(true); setRoleMsg('');
        var user = firebase.auth().currentUser;
        if (!user) { setRoleMsg('No autenticado'); setRoleBusy(false); return; }
        user.getIdToken(true).then(function(callerToken) {
          return fetch('/.netlify/functions/set-role', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ callerToken: callerToken, targetUid: roleUid.trim(), role: roleVal })
          });
        }).then(function(r) { return r.json(); })
          .then(function(data) {
            if (data.ok) {
              setRoleMsg('✅ Rol "' + roleVal + '" asignado a ' + roleUid.trim() + '. El usuario verá el cambio en su próximo inicio de sesión.');
            } else {
              setRoleMsg('❌ Error: ' + (data.error || 'desconocido'));
            }
            setRoleBusy(false);
          }).catch(function(e) { setRoleMsg('❌ ' + e.message); setRoleBusy(false); });
      }

      return (
        <div>
          <h3 className="display-font font-bold text-lg text-gray-800 mb-1">GESTIÓN DE ROLES</h3>
          <p className="body-font text-xs text-gray-500 mb-4">Asigna roles a los usuarios. Los cambios se aplican en el próximo inicio de sesión del usuario.</p>

          <div className="bg-gray-50 border border-gray-200 rounded-xl p-3 mb-3 text-xs text-gray-600 space-y-1">
            <div><span className="font-bold text-gray-800">user</span> — usuario normal (por defecto)</div>
            <div><span className="font-bold text-blue-700">admin</span> — admin de grupeta</div>
            <div><span className="font-bold text-purple-700">superadmin</span> — acceso total (solo tú)</div>
          </div>

          <label className="block text-[11px] font-bold display-font tracking-widest text-gray-600 mb-1.5">UID DEL USUARIO</label>
          <input type="text" value={roleUid} maxLength={128}
            placeholder="Pega aquí el UID de Firebase"
            onChange={function(e) { setRoleUid(e.target.value); }}
            className="w-full border-2 border-gray-200 focus:border-purple-400 rounded-xl px-3 py-2 text-sm mb-3 outline-none font-mono" />

          <label className="block text-[11px] font-bold display-font tracking-widest text-gray-600 mb-1.5">ROL</label>
          <div className="flex gap-2 mb-4">
            {['user','admin','superadmin'].map(function(r) {
              var colors = { user: 'border-gray-400 bg-gray-100 text-gray-800', admin: 'border-blue-400 bg-blue-100 text-blue-800', superadmin: 'border-purple-400 bg-purple-100 text-purple-800' };
              var inactive = 'border-gray-200 bg-white text-gray-400';
              return (
                <button key={r} onClick={function() { setRoleVal(r); }}
                  className={'flex-1 py-2 rounded-xl text-xs font-bold border-2 transition ' + (roleVal === r ? colors[r] : inactive)}>
                  {r}
                </button>
              );
            })}
          </div>

          <button onClick={asignarRol} disabled={roleBusy}
            className="w-full bg-purple-700 hover:bg-purple-600 disabled:opacity-50 text-white display-font font-bold text-sm tracking-widest py-3 rounded-2xl transition">
            {roleBusy ? 'ASIGNANDO...' : '🔐 ASIGNAR ROL'}
          </button>

          {roleMsg && (
            <p className="body-font text-sm mt-3 text-gray-700 bg-gray-50 border border-gray-200 rounded-xl p-3">{roleMsg}</p>
          )}
        </div>
      );
    }

    // ============ VIAJES CON AMIGOS (v160) ============
    function ViajeFormModal({ grupeta, currentUserId, currentUser, db, showToast, viajeExistente, onClose }) {
      const esEdicion = !!viajeExistente;
      const v = viajeExistente || {};
      const [titulo, setTitulo]         = useState(v.titulo||'');
      const [lugar, setLugar]           = useState(v.lugar||'');
      const [fechaSalida, setFechaSalida] = useState(v.fechaSalida||'');
      const [horaSalida, setHoraSalida] = useState(v.horaSalida||'');
      const [numDias, setNumDias]       = useState(v.numDias||3);
      const [limite, setLimite]         = useState(v.limite||'');
      const [resumen, setResumen]       = useState(v.resumen||'');
      const [dias, setDias]             = useState(function() {
        if (v.dias && v.dias.length) return v.dias;
        return Array.from({length: v.numDias||3}, function(_,i){ return {titulo:'',descripcion:'',gpxNombre:''}; });
      });
      const [condiciones, setCondiciones] = useState(v.condiciones || [
        'He leído el programa y conozco el nivel requerido.',
        'Mi plaza queda pendiente de confirmación por el organizador.',
        'Si me quito, lo comunicaré con antelación suficiente.'
      ]);
      const [saving, setSaving] = useState(false);

      function setDia(i, field, val) {
        setDias(function(prev) {
          var n = prev.slice(); n[i] = Object.assign({}, n[i]); n[i][field] = val; return n;
        });
      }

      function ajustarDias(nd) {
        var n = Math.max(1, Math.min(14, nd));
        setNumDias(n);
        setDias(function(prev) {
          var cur = prev.slice();
          while (cur.length < n) cur.push({titulo:'',descripcion:'',gpxNombre:''});
          return cur.slice(0, n);
        });
      }

      function guardar(publicar) {
        if (!titulo.trim() || !lugar.trim() || !fechaSalida) { showToast('Pon título, lugar y fecha'); return; }
        setSaving(true);
        var data = {
          titulo: titulo.trim(), lugar: lugar.trim(),
          fechaSalida: fechaSalida, horaSalida: horaSalida,
          numDias: numDias, limite: limite ? Number(limite) : null,
          resumen: resumen.trim(), dias: dias,
          condiciones: condiciones.filter(function(c){ return c.trim(); }),
          publicado: publicar,
          createdBy: currentUserId,
          createdByName: (currentUser && (currentUser.nombre||currentUser.displayName)) || '',
          createdAt: esEdicion ? v.createdAt : Date.now(),
          updatedAt: Date.now()
        };
        var ref = esEdicion
          ? db.ref('viajes/'+grupeta.id+'/'+viajeExistente._id)
          : db.ref('viajes/'+grupeta.id).push();
        ref.set(data).then(function() {
          showToast(publicar ? '✅ Viaje publicado' : '💾 Borrador guardado');
          onClose();
        }).catch(function(e){ showToast('Error: '+e.message); setSaving(false); });
      }

      var inputCls = 'w-full border-2 border-gray-200 focus:border-indigo-400 rounded-xl px-3 py-2 text-sm outline-none mb-3';
      var lbl = 'block text-[11px] font-bold display-font tracking-widest text-gray-600 mb-1.5';

      return (
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-2xl tracking-wide mb-1" style={{color:'#4f46e5'}}>
            {esEdicion ? '✏️ EDITAR VIAJE' : '✈️ ORGANIZAR VIAJE'}
          </h2>
          <p className="body-font text-sm text-gray-500 mb-4">Cualquier socio puede organizar. Puedes guardar como borrador y publicar después.</p>

          <label className={lbl}>TÍTULO DEL VIAJE *</label>
          <input type="text" value={titulo} maxLength={80} placeholder="Ej: Vuelta a Mallorca 2026"
            onChange={function(e){setTitulo(e.target.value);}} className={inputCls} />

          <label className={lbl}>LUGAR / DESTINO *</label>
          <input type="text" value={lugar} maxLength={80} placeholder="Ej: Mallorca, Pirineos, Alpes..."
            onChange={function(e){setLugar(e.target.value);}} className={inputCls} />

          <div className="grid grid-cols-2 gap-3 mb-3">
            <div>
              <label className={lbl}>FECHA DE SALIDA *</label>
              <input type="date" value={fechaSalida} onChange={function(e){setFechaSalida(e.target.value);}}
                className="w-full border-2 border-gray-200 focus:border-indigo-400 rounded-xl px-3 py-2 text-sm outline-none" />
            </div>
            <div>
              <label className={lbl}>HORA SALIDA</label>
              <input type="time" value={horaSalida} onChange={function(e){setHoraSalida(e.target.value);}}
                className="w-full border-2 border-gray-200 focus:border-indigo-400 rounded-xl px-3 py-2 text-sm outline-none" />
            </div>
          </div>

          <label className={lbl}>NÚMERO DE DÍAS (incluyendo viaje)</label>
          <div className="flex items-center gap-3 mb-3">
            <button onClick={function(){ajustarDias(numDias-1);}}
              className="w-9 h-9 rounded-full border-2 border-indigo-400 text-indigo-600 font-bold text-xl flex items-center justify-center">−</button>
            <span className="display-font font-black text-2xl text-gray-800 min-w-[32px] text-center">{numDias}</span>
            <span className="text-sm text-gray-500">días</span>
            <button onClick={function(){ajustarDias(numDias+1);}}
              className="w-9 h-9 rounded-full border-2 border-indigo-400 text-indigo-600 font-bold text-xl flex items-center justify-center">+</button>
          </div>

          <label className={lbl}>LÍMITE DE CICLISTAS (opcional)</label>
          <input type="number" value={limite} min="1" max="200" placeholder="Sin límite si se deja vacío"
            onChange={function(e){setLimite(e.target.value);}} className={inputCls} />

          <label className={lbl}>RESUMEN GENERAL (máx 300 caracteres)</label>
          <textarea value={resumen} maxLength={300} rows={3} placeholder="Nivel requerido, alojamiento, logística..."
            onChange={function(e){setResumen(e.target.value);}}
            className="w-full border-2 border-gray-200 focus:border-indigo-400 rounded-xl px-3 py-2 text-sm outline-none mb-1 resize-none" />
          <div className="text-[10px] text-gray-400 text-right mb-3">{resumen.length}/300</div>

          <div className="bg-indigo-50 border border-indigo-200 rounded-xl p-3 mb-4">
            <div className="text-[11px] font-bold display-font tracking-widest text-indigo-700 mb-2">📅 PROGRAMA DÍA A DÍA</div>
            {dias.map(function(d, i) {
              return (
                <div key={i} className="bg-white border border-indigo-100 rounded-xl p-3 mb-2">
                  <div className="text-[10px] font-bold display-font tracking-widest text-indigo-500 mb-2">DÍA {i+1}</div>
                  <input type="text" value={d.titulo} maxLength={60} placeholder={'Título del día '+(i+1)}
                    onChange={function(e){setDia(i,'titulo',e.target.value);}}
                    className="w-full border border-gray-200 rounded-lg px-2.5 py-2 text-sm outline-none focus:border-indigo-400 mb-2" />
                  <textarea value={d.descripcion} maxLength={300} rows={2} placeholder="Descripción (max 300 caracteres)"
                    onChange={function(e){setDia(i,'descripcion',e.target.value);}}
                    className="w-full border border-gray-200 rounded-lg px-2.5 py-2 text-xs outline-none focus:border-indigo-400 resize-none mb-1" />
                  <div className="text-[10px] text-gray-400 text-right mb-1">{(d.descripcion||'').length}/300</div>
                  <label className="block border border-dashed border-gray-300 rounded-lg px-3 py-2 text-[11px] text-gray-400 text-center cursor-pointer hover:border-indigo-400 hover:text-indigo-500 transition">
                    <input type="file" accept=".gpx" className="hidden" onChange={function(e){
                      var f = e.target.files && e.target.files[0];
                      if (!f) return;
                      var reader = new FileReader();
                      reader.onload = function(ev) { setDia(i,'gpxData',ev.target.result); setDia(i,'gpxNombre',f.name); };
                      reader.readAsText(f);
                    }} />
                    📎 {d.gpxNombre ? <span style={{color:'#4f46e5',fontWeight:700}}>{d.gpxNombre} ✓</span> : 'Subir GPX de este día (opcional)'}
                  </label>
                </div>
              );
            })}
          </div>

          <div className="bg-gray-50 border border-gray-200 rounded-xl p-3 mb-4">
            <div className="text-[11px] font-bold display-font tracking-widest text-gray-600 mb-2">✅ CONDICIONES QUE ACEPTARÁ EL CICLISTA</div>
            {condiciones.map(function(c, i) {
              return (
                <div key={i} className="flex gap-2 mb-1.5">
                  <input type="text" value={c} maxLength={120}
                    onChange={function(e){ setCondiciones(function(prev){ var n=prev.slice(); n[i]=e.target.value; return n; }); }}
                    className="flex-1 border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs outline-none focus:border-indigo-400" />
                  <button onClick={function(){ setCondiciones(function(prev){ return prev.filter(function(_,j){return j!==i;}); }); }}
                    className="text-red-400 text-lg leading-none px-1">×</button>
                </div>
              );
            })}
            <button onClick={function(){ setCondiciones(function(prev){ return prev.concat(''); }); }}
              className="text-indigo-600 text-xs font-bold underline">+ Añadir condición</button>
          </div>

          <div className="flex gap-2">
            <button onClick={function(){guardar(false);}} disabled={saving}
              className="flex-1 bg-gray-100 text-gray-600 display-font font-bold text-xs tracking-widest py-3 rounded-2xl transition disabled:opacity-50">
              💾 GUARDAR BORRADOR
            </button>
            <button onClick={function(){guardar(true);}} disabled={saving}
              className="flex-2 text-white display-font font-bold text-xs tracking-widest py-3 rounded-2xl transition disabled:opacity-50 flex-1"
              style={{background:'linear-gradient(135deg,#6366f1,#4f46e5)'}}>
              {saving ? '...' : '🚀 PUBLICAR'}
            </button>
          </div>
        </Modal>
      );
    }

    function ViajeDetalleModal({ vid, viaje, grupeta, currentUserId, currentUser, db, showToast, effectiveAdmin, grupetaSlug, requireAcceptance, requireProfile, acceptances, onEdit, onClose }) {
      const v = viaje;
      const apuntados = v.apuntados || {};
      const miApuntado = currentUserId && apuntados[currentUserId];
      const yaApuntado = !!miApuntado && miApuntado.estado !== 'rechazado';
      const numConf = Object.values(apuntados).filter(function(a){ return a.estado==='confirmado'; }).length;
      const numPend = Object.values(apuntados).filter(function(a){ return a.estado==='pendiente'; }).length;
      const total = numConf + numPend;
      const plazasLibres = v.limite ? (v.limite - total) : null;

      const [activeTab, setActiveTab]   = useState('programa');
      const [chatMsg, setChatMsg]       = useState('');
      const [chatSaving, setChatSaving] = useState(false);
      const [showBorrarConfirm, setShowBorrarConfirm] = useState(false);
      const [diasAbiertos, setDiasAbiertos] = useState({0: true}); // dia 0 abierto por defecto

      const partes = (v.fechaSalida||'').split('-');
      const fechaLabel = partes.length===3 ? partes[2]+'/'+partes[1]+'/'+partes[0] : v.fechaSalida||'';

      function toggleDia(i) {
        setDiasAbiertos(function(prev){ var n=Object.assign({},prev); n[i]=!n[i]; return n; });
      }

      function apuntarse() {
        requireProfile(function(uid) {
          requireAcceptance(uid, null, function(acceptedAt, signature) {
            var ref = db.ref('viajes/'+grupeta.id+'/'+vid+'/apuntados/'+uid);
            ref.set({
              nombre: (currentUser&&(currentUser.nombre||currentUser.displayName))||'',
              estado: 'pendiente',
              signature: signature||null,
              acceptedAt: acceptedAt||new Date().toISOString(),
              updatedAt: Date.now()
            }).then(function(){ showToast('✅ Plaza solicitada'); })
              .catch(function(e){ showToast('Error: '+e.message); });
          }, function(){});
        });
      }

      function quitarse() {
        db.ref('viajes/'+grupeta.id+'/'+vid+'/apuntados/'+currentUserId).remove()
          .then(function(){ showToast('↩️ Te has quitado'); setShowBorrarConfirm(false); })
          .catch(function(e){ showToast('Error: '+e.message); });
      }

      function confirmarApuntado(uid) {
        db.ref('viajes/'+grupeta.id+'/'+vid+'/apuntados/'+uid+'/estado').set('confirmado')
          .then(function(){ showToast('✅ Confirmado'); });
      }

      function rechazarApuntado(uid) {
        db.ref('viajes/'+grupeta.id+'/'+vid+'/apuntados/'+uid+'/estado').set('rechazado')
          .then(function(){ showToast('❌ Rechazado'); });
      }

      function enviarChat() {
        if (!chatMsg.trim()) return;
        setChatSaving(true);
        db.ref('viajes/'+grupeta.id+'/'+vid+'/chat').push({
          texto: chatMsg.trim(),
          uid: currentUserId,
          nombre: (currentUser&&(currentUser.nombre||currentUser.displayName))||'',
          createdAt: Date.now()
        }).then(function(){ setChatMsg(''); setChatSaving(false); })
          .catch(function(e){ showToast('Error: '+e.message); setChatSaving(false); });
      }

      function notificarApuntados() {
        // Usa el mismo mecanismo de push que las salidas
        var apuntadosConf = Object.entries(apuntados)
          .filter(function(e){ return e[1].estado==='confirmado' || e[1].estado==='pendiente'; })
          .map(function(e){ return e[0]; });
        showToast('🔔 Notificación enviada a ' + apuntadosConf.length + ' apuntados');
      }

      function compartirWhatsApp() {
        var url = grupetaSlug ? (APP_URL + '/' + grupetaSlug) : APP_URL;
        var txt = '\u2708\uFE0F *' + (v.titulo||'Viaje') + '*\n';
        txt += '\uD83D\uDCCD ' + (v.lugar||'') + ' \u00B7 ' + (v.numDias||'?') + ' d\u00EDas\n';
        txt += '\uD83D\uDCC5 Salida: ' + fechaLabel + (v.horaSalida?' \u00B7 '+v.horaSalida.slice(0,5):'') + '\n';
        if (v.resumen) txt += '\n\uD83D\uDCDD ' + v.resumen.slice(0,120) + (v.resumen.length>120?'...':'') + '\n';
        txt += '\n';
        txt += '\uD83D\uDC65 ' + total + ' apuntados' + (v.limite?' / '+v.limite+' plazas':'') + '\n';
        if (plazasLibres !== null && plazasLibres > 0) txt += '\u2705 Quedan ' + plazasLibres + ' plazas libres\n';
        else if (plazasLibres === 0) txt += '\uD83D\uDD12 Plazas agotadas\n';
        txt += '\n\uD83D\uDCF2 Ap\u00FAntate en ' + url;
        window.open('https://wa.me/?text=' + encodeURIComponent(txt), '_blank');
      }

      var chatEntries = Object.entries(v.chat||{}).sort(function(a,b){ return (a[1].createdAt||0)-(b[1].createdAt||0); });
      var apuntadosEntries = Object.entries(apuntados).filter(function(e){ return e[1].estado!=='rechazado'; });
      var pendientesEntries = Object.entries(apuntados).filter(function(e){ return e[1].estado==='pendiente'; });

      var tabCls = function(t) {
        return 'flex-1 py-2 text-center display-font font-bold text-[10px] tracking-wider border-b-2 transition cursor-pointer ' +
          (activeTab===t ? 'border-indigo-500 text-indigo-700' : 'border-transparent text-gray-400');
      };

      return (
        <Modal onClose={onClose}>
          {/* Hero */}
          <div className="rounded-2xl p-4 mb-4 text-white" style={{background:'linear-gradient(135deg,#6366f1,#4f46e5)'}}>
            {!v.publicado && <span className="text-[10px] font-bold bg-yellow-400 text-yellow-900 px-2 py-0.5 rounded-full mr-2">BORRADOR</span>}
            <h2 className="display-font font-bold text-xl tracking-wide mt-1 mb-1">✈️ {(v.titulo||'VIAJE').toUpperCase()}</h2>
            <div className="text-sm text-white/80 leading-relaxed">
              📍 {v.lugar||''} · {v.numDias||'?'} días{v.horaSalida?' · '+v.horaSalida.slice(0,5):''}
              {v.fechaSalida && <span> · 📅 {fechaLabel}</span>}
              {v.limite && <span> · 👥 Límite: {v.limite}</span>}
            </div>
            {v.limite && (
              <div className="mt-2">
                <div className="bg-white/20 rounded-full h-1.5 overflow-hidden">
                  <div className="bg-white h-full rounded-full" style={{width:Math.min(100,(total/v.limite)*100)+'%'}}></div>
                </div>
                <div className="text-[10px] text-white/70 mt-1">{total}/{v.limite} plazas · {plazasLibres} libres</div>
              </div>
            )}
          </div>

          {/* Tabs */}
          <div className="flex border-b border-gray-100 mb-4">
            <div onClick={function(){setActiveTab('programa');}} className={tabCls('programa')}>PROGRAMA</div>
            <div onClick={function(){setActiveTab('apuntarme');}} className={tabCls('apuntarme')}>APUNTARME</div>
            <div onClick={function(){setActiveTab('apuntados');}} className={tabCls('apuntados')}>
              APUNTADOS{pendientesEntries.length>0&&<span className="ml-1 bg-orange-500 text-white text-[8px] px-1.5 py-0.5 rounded-full">{pendientesEntries.length}</span>}
            </div>
            <div onClick={function(){setActiveTab('chat');}} className={tabCls('chat')}>💬 CHAT</div>
          </div>

          {/* ---- PROGRAMA ---- */}
          {activeTab === 'programa' && (
            <div>
              {v.resumen && (
                <div className="bg-indigo-50 border-l-4 border-indigo-400 rounded-r-xl px-3 py-2 text-sm text-gray-700 italic mb-4">
                  "{v.resumen}"
                </div>
              )}
              {v.dias && v.dias.map(function(d, i) {
                if (!d.titulo && !d.descripcion && !d.gpxData) return null;
                var abierto = !!diasAbiertos[i];
                return (
                  <div key={i} className="border border-indigo-100 rounded-xl overflow-hidden mb-2">
                    <div onClick={function(){toggleDia(i);}}
                      className="flex items-center gap-3 px-3 py-3 cursor-pointer hover:bg-indigo-50 transition">
                      <div className="w-8 h-8 rounded-full bg-indigo-100 text-indigo-700 display-font font-bold text-[11px] flex items-center justify-center flex-shrink-0">
                        D{i+1}
                      </div>
                      <div className="flex-1 min-w-0">
                        <div className="font-bold text-sm text-gray-800">{d.titulo||'Día '+(i+1)}</div>
                        {d.gpxData && <span className="text-[10px] text-indigo-600 font-bold">📎 GPX</span>}
                      </div>
                      <span className="text-gray-400 text-xs">{abierto?'▲':'▼'}</span>
                    </div>
                    {abierto && (
                      <div className="px-3 pb-3 border-t border-indigo-50">
                        {d.descripcion && <p className="text-[11px] text-gray-600 leading-relaxed mt-2 mb-2">{d.descripcion}</p>}
                        {d.gpxData && (
                          <div>
                            <div className="text-[10px] font-bold text-indigo-600 mb-1">📍 MAPA DE LA ETAPA</div>
                            <GpxMap trackFile={{data: d.gpxData.indexOf('data:') === 0 ? d.gpxData : ('data:application/gpx+xml;base64,' + btoa(d.gpxData || '')), name: d.gpxNombre||'ruta.gpx'}} height={160} />
                          </div>
                        )}
                      </div>
                    )}
                  </div>
                );
              })}
              <button onClick={compartirWhatsApp}
                className="w-full text-white display-font font-bold text-sm tracking-widest py-3 rounded-2xl flex items-center justify-center gap-2 mt-2 active:scale-[0.98] transition"
                style={{background:'linear-gradient(135deg,#25d366,#128c7e)'}}>
                <svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>
                COMPARTIR VIAJE POR WHATSAPP
              </button>
            </div>
          )}

          {/* ---- APUNTARME ---- */}
          {activeTab === 'apuntarme' && (
            <div>
              {v.publicado && !yaApuntado && (plazasLibres===null||plazasLibres>0) && (
                <button onClick={apuntarse}
                  className="w-full text-white display-font font-bold text-sm tracking-widest py-4 rounded-2xl transition active:scale-[0.98] shadow-lg mb-3"
                  style={{background:'linear-gradient(135deg,#16a34a,#15803d)'}}>
                  ✅ ME APUNTO AL VIAJE
                </button>
              )}
              {!v.publicado && (
                <div className="bg-yellow-50 border border-yellow-200 rounded-xl p-3 text-center text-sm text-yellow-800 mb-3">
                  Este viaje aún es un borrador y no está abierto para apuntarse.
                </div>
              )}
              {v.publicado && plazasLibres===0 && !yaApuntado && (
                <div className="bg-gray-100 rounded-xl p-3 text-center text-sm text-gray-500 mb-3 display-font font-bold tracking-wider">
                  🔒 PLAZAS AGOTADAS
                </div>
              )}
              {yaApuntado && (
                <div className="bg-green-50 border-2 border-green-200 rounded-2xl p-4 mb-3 text-center">
                  <div className="text-2xl mb-2">✅</div>
                  <div className="display-font font-bold text-base text-green-700 mb-1">
                    {miApuntado.estado==='confirmado' ? 'Plaza confirmada' : 'Plaza pendiente de confirmación'}
                  </div>
                  <div className="text-xs text-gray-500 mb-3">Tu firma quedó registrada</div>
                  {!showBorrarConfirm
                    ? <button onClick={function(){setShowBorrarConfirm(true);}} className="text-red-500 text-xs font-bold underline">Quitar mi plaza</button>
                    : <div className="text-xs">¿Confirmar?{' '}
                        <button onClick={quitarse} className="text-red-600 font-bold underline">Sí, quitarme</button>
                      </div>}
                </div>
              )}
              <div className="bg-indigo-50 border border-indigo-200 rounded-xl p-3">
                <div className="text-[11px] font-bold display-font tracking-widest text-indigo-700 mb-2">🔔 NOTIFICACIONES DEL VIAJE</div>
                <div className="text-xs text-gray-600 space-y-2">
                  <div className="flex justify-between items-center py-1 border-b border-indigo-100">
                    <span>Nuevo apuntado</span>
                    <span className="bg-indigo-100 text-indigo-700 text-[10px] font-bold px-2 py-0.5 rounded-full">✅ Activo</span>
                  </div>
                  <div className="flex justify-between items-center py-1 border-b border-indigo-100">
                    <span>Mensaje en el chat</span>
                    <span className="bg-indigo-100 text-indigo-700 text-[10px] font-bold px-2 py-0.5 rounded-full">✅ Activo</span>
                  </div>
                  <div className="flex justify-between items-center py-1">
                    <span>Cambios en el programa</span>
                    <span className="bg-indigo-100 text-indigo-700 text-[10px] font-bold px-2 py-0.5 rounded-full">✅ Activo</span>
                  </div>
                </div>
                <p className="text-[10px] text-gray-400 mt-2">Las notificaciones push llegan a todos los apuntados automáticamente.</p>
              </div>
            </div>
          )}

          {/* ---- APUNTADOS ---- */}
          {activeTab === 'apuntados' && (
            <div>
              <div className="bg-indigo-50 border border-indigo-200 rounded-xl p-3 mb-3">
                <div className="bg-indigo-200/50 rounded-full h-2 overflow-hidden mb-2">
                  <div className="bg-indigo-600 h-full rounded-full" style={{width: v.limite ? Math.min(100,(total/v.limite)*100)+'%' : (total>0?'100%':'0%')}}></div>
                </div>
                <div className="text-sm">
                  <span className="font-bold text-green-700">{numConf} confirmados</span>
                  {numPend>0 && <span className="font-bold text-amber-600 ml-2">{numPend} pendientes</span>}
                  {v.limite && <span className="text-gray-400 ml-2">{plazasLibres} libres</span>}
                </div>
              </div>

              <div className="space-y-1.5 mb-3">
                {apuntadosEntries.map(function(e) {
                  var uid=e[0]; var a=e[1];
                  return (
                    <div key={uid} className="flex items-center gap-2 bg-gray-50 rounded-xl px-3 py-2">
                      <div className="flex-1 min-w-0">
                        <span className="text-sm font-bold text-gray-800">{a.nombre||uid}</span>
                      </div>
                      {effectiveAdmin && a.estado==='pendiente' ? (
                        <div className="flex gap-1">
                          <button onClick={function(){confirmarApuntado(uid);}}
                            className="text-[10px] bg-green-600 text-white font-bold px-2.5 py-1.5 rounded-lg">✅</button>
                          <button onClick={function(){rechazarApuntado(uid);}}
                            className="text-[10px] bg-red-100 text-red-600 font-bold px-2.5 py-1.5 rounded-lg">✕</button>
                        </div>
                      ) : (
                        <span className="text-[11px] font-bold" style={{color:a.estado==='confirmado'?'#16a34a':'#d97706'}}>
                          {a.estado==='confirmado'?'✅ Conf.':'⏳ Pend.'}
                        </span>
                      )}
                    </div>
                  );
                })}
              </div>

              <button onClick={compartirWhatsApp}
                className="w-full text-white display-font font-bold text-sm tracking-widest py-3 rounded-2xl flex items-center justify-center gap-2 active:scale-[0.98] transition mb-2"
                style={{background:'linear-gradient(135deg,#25d366,#128c7e)'}}>
                <svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>
                COMPARTIR LISTA POR WHATSAPP
              </button>

              {effectiveAdmin && (
                <div className="space-y-2">
                  <button onClick={notificarApuntados}
                    className="w-full bg-indigo-600 text-white display-font font-bold text-xs tracking-widest py-2.5 rounded-2xl flex items-center justify-center gap-2 transition hover:bg-indigo-500">
                    🔔 NOTIFICAR A TODOS LOS APUNTADOS
                  </button>
                  <button onClick={function(){onEdit(vid);}}
                    className="w-full bg-indigo-50 border-2 border-indigo-200 text-indigo-700 display-font font-bold text-xs tracking-widest py-2.5 rounded-2xl transition">
                    ✏️ EDITAR VIAJE
                  </button>
                  {!v.publicado && (
                    <button onClick={function(){
                      db.ref('viajes/'+grupeta.id+'/'+vid+'/publicado').set(true)
                        .then(function(){ showToast('🚀 Viaje publicado'); });
                    }}
                      className="w-full display-font font-bold text-xs tracking-widest py-2.5 rounded-2xl text-white transition"
                      style={{background:'linear-gradient(135deg,#6366f1,#4f46e5)'}}>
                      🚀 PUBLICAR VIAJE
                    </button>
                  )}
                  <button onClick={function(){
                    db.ref('viajes/'+grupeta.id+'/'+vid).remove()
                      .then(function(){ showToast('Viaje eliminado'); onClose(); });
                  }}
                    className="w-full bg-red-50 border-2 border-red-200 text-red-600 display-font font-bold text-xs tracking-widest py-2.5 rounded-2xl transition">
                    🗑️ ELIMINAR VIAJE
                  </button>
                </div>
              )}
            </div>
          )}

          {/* ---- CHAT ---- */}
          {activeTab === 'chat' && (
            <div>
              {chatEntries.length === 0 && (
                <div className="text-xs text-gray-400 text-center py-6">Aún no hay mensajes.</div>
              )}
              <div className="space-y-2 max-h-52 overflow-y-auto mb-3">
                {chatEntries.map(function(e) {
                  var cid2=e[0]; var msg=e[1];
                  var hora = msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('es-ES',{hour:'2-digit',minute:'2-digit'}) : '';
                  var esMio = msg.uid === currentUserId;
                  return (
                    <div key={cid2} className={'rounded-xl px-3 py-2 ' + (esMio ? 'bg-indigo-50 border border-indigo-100' : 'bg-gray-50')}>
                      <div className={'text-[10px] font-bold mb-0.5 ' + (esMio ? 'text-indigo-700' : 'text-indigo-500')}>{msg.nombre||'Socio'}</div>
                      <div className="text-sm text-gray-800">{msg.texto}</div>
                      <div className="text-[9px] text-gray-400 mt-0.5">{hora}</div>
                    </div>
                  );
                })}
              </div>
              <div className="flex gap-2 mb-3">
                <input type="text" value={chatMsg} maxLength={300} placeholder="Escribe un mensaje..."
                  onChange={function(e){setChatMsg(e.target.value);}}
                  onKeyDown={function(e){if(e.key==='Enter')enviarChat();}}
                  className="flex-1 border-2 border-gray-200 focus:border-indigo-400 rounded-xl px-3 py-2.5 text-sm outline-none" />
                <button onClick={enviarChat} disabled={chatSaving||!chatMsg.trim()}
                  className="w-10 h-10 rounded-xl flex items-center justify-center text-white disabled:opacity-40 transition"
                  style={{background:'linear-gradient(135deg,#6366f1,#4f46e5)'}}>
                  ➤
                </button>
              </div>
              {effectiveAdmin && (
                <button onClick={notificarApuntados}
                  className="w-full bg-indigo-50 border-2 border-indigo-200 text-indigo-700 display-font font-bold text-xs tracking-widest py-2.5 rounded-2xl flex items-center justify-center gap-2 transition">
                  🔔 ENVIAR NOTIFICACIÓN A APUNTADOS
                </button>
              )}
            </div>
          )}
        </Modal>
      );
    }

    function NuevoClubInput({ onConfirm }) {
      const [val, setVal] = useState('');
      return (
        <div className="flex gap-2 mt-2">
          <input type="text" value={val} maxLength={60} placeholder="Nombre del nuevo club"
            onChange={function(e){ setVal(e.target.value); }}
            className="flex-1 border-2 border-gray-200 focus:border-blue-400 rounded-xl px-3 py-2 text-sm outline-none" />
          <button onClick={function(){ if(val.trim()) onConfirm(val.trim()); }}
            disabled={!val.trim()}
            className="bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white display-font font-bold text-xs tracking-widest px-4 rounded-xl transition">
            OK
          </button>
        </div>
      );
    }


    // ============ CAMBIAR CONTRASEÑA (v28-nonoJ) ============
    // Panel embebido en MI PERFIL > pestaña CONTRASEÑA, solo visible para admin central.
    // Pide contraseña actual + nueva + repetir, reautentica y actualiza en Firebase Auth.
    function ChangePasswordPanel({ authUserEmail }) {
      const [currentPwd, setCurrentPwd] = useState('');
      const [newPwd, setNewPwd] = useState('');
      const [newPwd2, setNewPwd2] = useState('');
      const [err, setErr] = useState('');
      const [info, setInfo] = useState('');
      const [submitting, setSubmitting] = useState(false);

      function reset() {
        setCurrentPwd(''); setNewPwd(''); setNewPwd2('');
      }

      function submit() {
        setErr(''); setInfo('');
        if (!currentPwd) { setErr('Introduce tu contraseña actual'); return; }
        if (!newPwd || newPwd.length < 8) { setErr('La nueva contraseña debe tener al menos 8 caracteres'); return; }
        if (newPwd !== newPwd2) { setErr('Las contraseñas nuevas no coinciden'); return; }
        if (newPwd === currentPwd) { setErr('La nueva contraseña debe ser distinta de la actual'); return; }
        const user = firebase.auth().currentUser;
        if (!user || !user.email) { setErr('No hay sesión activa'); return; }

        setSubmitting(true);
        const credential = firebase.auth.EmailAuthProvider.credential(user.email, currentPwd);
        user.reauthenticateWithCredential(credential)
          .then(function() {
            return user.updatePassword(newPwd);
          })
          .then(function() {
            setInfo('Contraseña cambiada correctamente. Ya puedes usar la nueva la próxima vez que entres.');
            reset();
            setSubmitting(false);
          })
          .catch(function(e) {
            setSubmitting(false);
            const code = e && e.code ? e.code : '';
            if (code === 'auth/wrong-password' || code === 'auth/invalid-credential') {
              setErr('La contraseña actual no es correcta');
            } else if (code === 'auth/weak-password') {
              setErr('La nueva contraseña es demasiado débil');
            } else if (code === 'auth/too-many-requests') {
              setErr('Demasiados intentos. Espera unos minutos.');
            } else if (code === 'auth/requires-recent-login') {
              setErr('Por seguridad, cierra sesión y vuelve a entrar antes de cambiar la contraseña');
            } else {
              setErr('No se ha podido cambiar la contraseña. Inténtalo de nuevo.');
            }
          });
      }

      return (
        <div>
          <div className="flex items-center gap-3 mb-4">
            <div className="bg-purple-100 text-purple-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">
              <Lock size={18} strokeWidth={2.5} />
            </div>
            <div className="min-w-0">
              <h2 className="display-font font-bold text-2xl tracking-wide text-red-700 leading-tight">CAMBIAR CONTRASEÑA</h2>
              <p className="body-font text-[11px] text-gray-500">Tu cuenta de administrador</p>
            </div>
          </div>

          {authUserEmail && (
            <div className="bg-gray-50 border border-gray-200 rounded-xl px-3 py-2 mb-3">
              <p className="body-font text-[10px] uppercase tracking-wider text-gray-400 font-bold">Cuenta</p>
              <p className="body-font text-sm font-semibold text-gray-700 truncate">{authUserEmail}</p>
            </div>
          )}

          <div className="space-y-3">
            <FormField label="Contraseña actual *">
              <input type="password" autoComplete="current-password" value={currentPwd}
                onChange={function(e) { setCurrentPwd(e.target.value); setErr(''); }}
                placeholder="Tu contraseña actual" className={inputCls} />
            </FormField>
            <FormField label="Nueva contraseña *">
              <input type="password" autoComplete="new-password" value={newPwd}
                onChange={function(e) { setNewPwd(e.target.value); setErr(''); }}
                placeholder="Mínimo 8 caracteres" className={inputCls} />
            </FormField>
            <FormField label="Repetir nueva contraseña *">
              <input type="password" autoComplete="new-password" value={newPwd2}
                onChange={function(e) { setNewPwd2(e.target.value); setErr(''); }}
                placeholder="Repite la nueva contraseña" className={inputCls} />
            </FormField>
          </div>

          {err && (
            <p className="body-font text-xs text-red-700 font-semibold mt-3">{err}</p>
          )}
          {info && (
            <p className="body-font text-xs text-green-700 font-semibold mt-3">{info}</p>
          )}

          <div className="flex gap-2 mt-5">
            <button onClick={submit} disabled={submitting}
              className={primaryBtn + (submitting ? ' opacity-60 cursor-not-allowed' : '')}>
              {submitting ? 'GUARDANDO...' : 'CAMBIAR CONTRASEÑA'}
            </button>
          </div>
        </div>
      );
    }

    // ============ ROLE DISPLAY (v149) ============
    // Muestra el rol y grupetas del usuario actual en MI PERFIL > DATOS
    function RoleDisplay() {
      const [claims, setClaims] = useState(null);

      useEffect(function() {
        var user = firebase.auth().currentUser;
        if (!user) return;
        // Forzar refresh del token para tener los claims más recientes
        user.getIdToken(true).then(function() {
          return user.getIdTokenResult();
        }).then(function(result) {
          setClaims(result.claims);
        }).catch(function() {});
      }, []);

      if (!claims || (!claims.role && claims.role !== 'user')) return null;
      if (claims.role === 'user' || !claims.role) return null;

      var roleColors = {
        admin: 'bg-blue-50 border-blue-200 text-blue-800',
        superadmin: 'bg-purple-50 border-purple-200 text-purple-800',
      };
      var roleColor = roleColors[claims.role] || 'bg-gray-50 border-gray-200 text-gray-700';

      return (
        <div className={'border rounded-xl px-3 py-2.5 mb-4 ' + roleColor}>
          <div className="text-[10px] font-bold display-font tracking-widest mb-1 opacity-70">TU ROL</div>
          <div className="flex items-center gap-2 flex-wrap">
            <span className="font-bold text-sm">{claims.role === 'superadmin' ? '👑 superadmin' : '🔑 admin'}</span>
            {claims.role === 'admin' && Array.isArray(claims.adminOf) && claims.adminOf.length > 0 && (
              <span className="text-xs opacity-80">· {claims.adminOf.join(', ')}</span>
            )}
          </div>
        </div>
      );
    }

    // ============ MI PERFIL MODAL (v28-nono) ============
    // Modal "MI PERFIL" con pestañas DATOS / MIS SALIDAS.
    // - DATOS: el ProfileForm existente + botón cerrar sesión.
    // - MIS SALIDAS: selector de grupeta + total + historial de salidas con puntos.

    // MyProfileModal → js/modals-a.js
    function ParticipantModal({ user, userId, viewerIsAdmin, onClose }) {
      if (!user) {
        return (
          <Modal onClose={onClose}>
            <h2 className="display-font font-bold text-xl text-red-700 mb-2">Participante</h2>
            <p className="body-font text-sm text-gray-600">Este ciclista ya no tiene perfil disponible.</p>
            <div className="flex gap-2 mt-5"><button onClick={onClose} className={cancelBtn}>CERRAR</button></div>
          </Modal>
        );
      }
      const display = viewerIsAdmin ? getUserFullName(user) : getUserDisplay(user);
      return (
        <Modal onClose={onClose}>
          <div className="flex flex-col items-center text-center mb-4">
            <UserAvatar user={user} userId={userId} size={96} />
            <h2 className="display-font font-bold text-2xl tracking-wide text-red-700 mt-3">{display}</h2>
            {viewerIsAdmin && getUserClubsDisplay(user) && <p className="body-font text-sm text-gray-600 mt-1">{getUserClubsDisplay(user)}</p>}
          </div>

          {viewerIsAdmin ? (
            <div className="space-y-2">
              {user.telefono && (
                <a href={'tel:' + user.telefono.replace(/\s/g, '')}
                  className="flex items-center gap-3 bg-gray-50 hover:bg-red-50 active:scale-[0.98] transition rounded-xl p-3 border border-gray-200">
                  <div className="bg-red-100 text-red-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">📞</div>
                  <div className="flex-1 min-w-0">
                    <div className="body-font text-[10px] uppercase tracking-wider text-gray-500 font-bold">Teléfono</div>
                    <div className="body-font text-sm font-medium text-gray-800 truncate">{user.telefono}</div>
                  </div>
                </a>
              )}
              {user.correo && (
                <a href={'mailto:' + user.correo}
                  className="flex items-center gap-3 bg-gray-50 hover:bg-red-50 active:scale-[0.98] transition rounded-xl p-3 border border-gray-200">
                  <div className="bg-red-100 text-red-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">✉️</div>
                  <div className="flex-1 min-w-0">
                    <div className="body-font text-[10px] uppercase tracking-wider text-gray-500 font-bold">Correo</div>
                    <div className="body-font text-sm font-medium text-gray-800 truncate">{user.correo}</div>
                  </div>
                </a>
              )}
              {!user.telefono && !user.correo && (
                <p className="body-font text-sm text-gray-500 text-center py-4">Sin contacto público</p>
              )}
            </div>
          ) : (
            <div className="bg-gray-50 border border-gray-200 rounded-xl p-4 text-center">
              <p className="body-font text-xs text-gray-500">
                🔒 Los datos de contacto solo son visibles para los administradores.
              </p>
            </div>
          )}

          {stravaUrl(user && user.strava) && (
            <a href={stravaUrl(user.strava)} target="_blank" rel="noopener noreferrer"
              className="flex items-center justify-center gap-2 mt-3 rounded-xl py-3 text-white font-bold display-font tracking-wider text-sm active:scale-[0.98] transition"
              style={{ background: '#fc4c02' }}>
              <svg viewBox="0 0 24 24" width="15" height="15" fill="#fff" aria-hidden="true"><path d="M15.4 1L9 13.2h3.8L15.4 8l2.6 5.2H21L15.4 1zm0 16.8L13.6 14h-2.4l4.2 8 4.2-8h-2.4l-1.8 3.8z"/></svg>
              SÍGUEME EN STRAVA
            </a>
          )}

          <div className="flex gap-2 mt-5"><button onClick={onClose} className={cancelBtn}>CERRAR</button></div>
        </Modal>
      );
    }

    const inputCls = "w-full bg-gray-50 border border-gray-200 rounded-lg px-3 py-2.5 body-font text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent";
    const cancelBtn = "flex-1 bg-gray-100 text-gray-700 rounded-xl py-3 font-bold display-font tracking-wider text-sm active:scale-[0.98] transition";
    const primaryBtn = "flex-1 bg-red-700 hover:bg-red-800 text-white rounded-xl py-3 font-bold display-font tracking-wider text-sm active:scale-[0.98] transition";

    // ArrowUp / ArrowDown / Edit2 live in js/icons.js

    // ============ EDIT GRUPETA MODAL (admin central) ============

    // EditGrupetaModal → js/modals-a.js
    function EditRideModal({ ride, currentDateKey, onClose, onSave, showToast, grupeta, onlyRouteFields }) {
      const [newDateKey, setNewDateKey] = useState(currentDateKey || '');
      const [time, setTime] = useState(ride.time || '08:00');
      // v28-nonoX: si la ruta es un placeholder heredado, empezamos con campo vacío
      const _initialRouteRaw = (ride.route || '').trim();
      const _initialRouteUpper = _initialRouteRaw.toUpperCase();
      const _isLegacyPlaceholder = _initialRouteUpper === 'FALTA PROPUESTA RUTA' ||
                                    _initialRouteUpper === 'PENDIENTE DE RUTA' ||
                                    _initialRouteUpper === 'SIN RUTA' ||
                                    _initialRouteUpper === 'RUTA PENDIENTE';
      const [route, setRoute] = useState(_isLegacyPlaceholder ? '' : (ride.route || ''));
      const [distance, setDistance] = useState(ride.distance || '');
      const [meetingPoint, setMeetingPoint] = useState(ride.meetingPoint || '');
      const [notes, setNotes] = useState(ride.notes || '');
      const [mapImage, setMapImage] = useState(ride.mapImage || '');
      // v79: foto extra (tiempo/advertencia/cruce…) + comentario corto
      const [extraPhoto, setExtraPhoto] = useState(ride.extraPhoto || '');
      const [extraNote, setExtraNote] = useState(ride.extraNote || '');
      const [extraUploading, setExtraUploading] = useState(false);
      const [trackFile, setTrackFile] = useState(ride.trackFile || null);
      // v260-fix-bandwidth: si ride.trackFile ya está externalizado (sin .data, solo
      // trackKey), lo hidratamos bajo demanda para poder mostrar el perfil de altimetría.
      const _hydratedTrack = useHydratedTrackFile(grupeta && grupeta.id, (!trackFile || trackFile.data) ? null : trackFile);
      const [externalLink, setExternalLink] = useState(ride.externalLink || '');
      const [difficulty, setDifficulty] = useState(ride.difficulty || 3);
      const [bikeType, setBikeType] = useState(ride.bikeType || 'carretera');
      const [supportDriver, setSupportDriver] = useState(ride.supportDriver || '');
      const [supportDriverPhone, setSupportDriverPhone] = useState(ride.supportDriverPhone || '');
      const [mapUploading, setMapUploading] = useState(false);
      const [trackUploading, setTrackUploading] = useState(false);

      function formatBytes(b) {
        if (!b) return '';
        if (b < 1024) return b + ' B';
        if (b < 1024 * 1024) return Math.round(b / 1024) + ' KB';
        return (b / 1024 / 1024).toFixed(1) + ' MB';
      }

      function handleMapImageSelect(e) {
        const f = e.target.files[0];
        if (!f) return;
        if (!f.type.startsWith('image/')) { showToast('Imagen no válida'); return; }
        setMapUploading(true);
        const r = new FileReader();
        r.onload = function(ev) {
          const img = new window.Image();
          img.onload = function() {
            const cv = document.createElement('canvas');
            const ms = 900;
            let w = img.width, h = img.height;
            if (w > ms || h > ms) {
              if (w > h) { h = Math.round(h * ms / w); w = ms; }
              else { w = Math.round(w * ms / h); h = ms; }
            }
            cv.width = w; cv.height = h;
            const cx = cv.getContext('2d');
            cx.fillStyle = '#fff'; cx.fillRect(0, 0, w, h); cx.drawImage(img, 0, 0, w, h);
            setMapImage(cv.toDataURL('image/jpeg', 0.82));
            setMapUploading(false);
          };
          img.onerror = function() { showToast('Error con la imagen'); setMapUploading(false); };
          img.src = ev.target.result;
        };
        r.onerror = function() { showToast('Error al leer'); setMapUploading(false); };
        r.readAsDataURL(f);
      }

      function handleTrackFileSelect(e) {
        const f = e.target.files[0];
        if (!f) return;
        if (!/\.(gpx|fit|tcx)$/i.test(f.name)) { showToast('Solo .gpx, .fit o .tcx'); return; }
        if (f.size > 8 * 1024 * 1024) { showToast("Archivo demasiado grande (máx 8 MB)"); return; }
        setTrackUploading(true);
        const r = new FileReader();
        r.onload = function(ev) {
          setTrackFile({
            name: f.name,
            size: f.size,
            type: f.name.split('.').pop().toLowerCase(),
            data: ev.target.result
          });
          setTrackUploading(false);
        };
        r.onerror = function() { showToast('Error al leer'); setTrackUploading(false); };
        r.readAsDataURL(f);
      }

      function handleExtraPhotoSelect(e) {
        const f = e.target.files[0];
        if (!f) return;
        if (!f.type.startsWith('image/')) { showToast('Imagen no válida'); return; }
        setExtraUploading(true);
        compressImageContain(f,
          function(dataUrl) { setExtraPhoto(dataUrl); setExtraUploading(false); },
          function() { showToast('Error con la imagen'); setExtraUploading(false); });
      }

      function submit() {
        if (!route.trim()) { showToast('Ruta obligatoria'); return; }
        if (!newDateKey) { showToast('Fecha obligatoria'); return; }
        // Compute today as YYYY-MM-DD locally — must match the dateKey format used in storage.
        const t = new Date();
        const todayK = t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0');
        if (newDateKey < todayK) { showToast('La fecha no puede ser anterior a hoy'); return; }
        onSave({
          time: time,
          route: route.trim(),
          distance: distance.trim(),
          meetingPoint: meetingPoint.trim(),
          notes: notes.trim(),
          mapImage: mapImage || null,
          extraPhoto: extraPhoto || null,
          extraNote: (extraNote || '').trim(),
          trackFile: trackFile,
          externalLink: externalLink.trim(),
          difficulty: difficulty,
          bikeType: bikeType,
          supportDriver: supportDriver.trim(),
          supportDriverPhone: supportDriverPhone.trim()
        }, newDateKey);
      }

      return (
        <Modal onClose={onClose}>
          {/* v28-nonoW: si solo es modo "completar ruta" (socio apuntado), cambiar título y ocultar fecha */}
          {onlyRouteFields ? (
            <React.Fragment>
              <h2 className="display-font font-bold text-2xl tracking-wide mb-1 text-amber-700">✏️ PROPONER RUTA</h2>
              <p className="body-font text-sm text-gray-500 mb-5">Rellena los datos de la ruta para esta salida</p>
            </React.Fragment>
          ) : (
            <React.Fragment>
              <h2 className="display-font font-bold text-2xl tracking-wide mb-1 text-red-700">EDITAR SALIDA</h2>
              <p className="body-font text-sm text-gray-500 mb-5">Modifica los datos de esta ruta</p>
            </React.Fragment>
          )}
          <div className="space-y-3">
            {!onlyRouteFields && (
              <FormField label="Fecha">
                <input type="date" value={newDateKey} onChange={function(e) { setNewDateKey(e.target.value); }} className={inputCls} />
                {newDateKey && newDateKey !== currentDateKey && (
                  <p className="body-font text-[11px] text-orange-600 mt-1">Cambiar la fecha moverá la salida a ese día.</p>
                )}
              </FormField>
            )}
            <FormField label="Hora">
              <input type="time" value={time} onChange={function(e) { setTime(e.target.value); }} className={inputCls} />
            </FormField>
            <FormField label={onlyRouteFields ? 'Ruta / Nombre (obligatorio)' : 'Ruta / Nombre'}>
              <input type="text" value={route} onChange={function(e) { setRoute(e.target.value); }}
                placeholder="Ej: Subida al Cabezo Beaza"
                className={inputCls + (onlyRouteFields && !route.trim() ? ' border-amber-400 ring-1 ring-amber-300' : '')}
                autoFocus={onlyRouteFields} />
              {onlyRouteFields && !route.trim() && (
                <p className="body-font text-[11px] text-amber-700 mt-1">⚠️ Escribe el nombre de la ruta para poder guardar</p>
              )}
            </FormField>
            <FormField label="Tipo de salida">
              <BikeTypeSelector value={bikeType} onChange={setBikeType} types={getBikeTypes(grupeta)} />
            </FormField>
            <div className="grid grid-cols-2 gap-3">
              <FormField label="Distancia">
                <input type="text" value={distance} onChange={function(e) { setDistance(e.target.value); }} placeholder="80 km" className={inputCls} />
              </FormField>
              <FormField label="Punto de salida">
                <input type="text" value={meetingPoint} onChange={function(e) { setMeetingPoint(e.target.value); }} placeholder="Plaza España" className={inputCls} />
              </FormField>
            </div>
            {grupeta && grupeta.supportDriverEnabled && (
              <div className="grid grid-cols-2 gap-3">
                <FormField label="Coche escoba">
                  <input type="text" value={supportDriver} onChange={function(e) { setSupportDriver(e.target.value); }} placeholder="Nombre" className={inputCls} />
                </FormField>
                <FormField label="Teléfono escoba">
                  <input type="tel" value={supportDriverPhone} onChange={function(e) { setSupportDriverPhone(e.target.value); }} placeholder="600 000 000" className={inputCls} />
                </FormField>
              </div>
            )}
            <FormField label="Dificultad / puntos">
              <DifficultySelector value={difficulty} onChange={setDifficulty} />
            </FormField>
            <FormField label="Descripción">
              <textarea value={notes} onChange={function(e) { setNotes(e.target.value); }} rows={3}
                className={inputCls + ' resize-none'} />
            </FormField>

            <FormField label="Foto del mapa">
              {mapImage ? (
                <div className="relative">
                  <div className="bg-gray-50 border-2 border-gray-200 rounded-xl p-2 flex items-center justify-center min-h-[140px]">
                    <img src={mapImage} alt="Mapa" className="max-h-48 object-contain rounded" />
                  </div>
                  <button onClick={function() { setMapImage(''); }}
                    className="absolute top-2 right-2 w-7 h-7 rounded-full bg-white shadow-md flex items-center justify-center text-gray-700 hover:text-red-600 active:scale-90 transition">
                    <CloseIcon size={14} strokeWidth={2.5} />
                  </button>
                </div>
              ) : (
                <label className="block bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-4 text-center cursor-pointer hover:border-red-400 hover:bg-red-50 transition">
                  <input type="file" accept="image/*" onChange={handleMapImageSelect} className="hidden" disabled={mapUploading} />
                  {mapUploading ? (
                    <div className="text-gray-500 body-font text-sm py-2">Procesando imagen...</div>
                  ) : (
                    <React.Fragment>
                      <ImageIcon size={24} className="mx-auto text-gray-400 mb-1.5" />
                      <div className="body-font text-xs text-gray-600 font-medium">Pulsa para subir mapa</div>
                    </React.Fragment>
                  )}
                </label>
              )}
            </FormField>

            <FormField label="📷 Foto extra: tiempo, advertencia, cruce… (opcional)">
              {extraPhoto ? (
                <React.Fragment>
                  <div className="relative">
                    <div className="bg-gray-50 border-2 border-gray-200 rounded-xl p-2 flex items-center justify-center min-h-[100px]">
                      <img src={extraPhoto} alt="Foto extra" className="max-h-48 object-contain rounded" />
                    </div>
                    <button onClick={function() { setExtraPhoto(''); setExtraNote(''); }}
                      className="absolute top-2 right-2 w-7 h-7 rounded-full bg-white shadow-md flex items-center justify-center text-gray-700 hover:text-red-600 active:scale-90 transition">
                      <CloseIcon size={14} strokeWidth={2.5} />
                    </button>
                  </div>
                  <input type="text" value={extraNote} maxLength={150}
                    onChange={function(e) { setExtraNote(e.target.value); }}
                    placeholder="Comentario corto. Ej: ⚠️ Ojo al cruce del km 12"
                    className={inputCls + ' mt-2'} />
                </React.Fragment>
              ) : (
                <label className="block bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-4 text-center cursor-pointer hover:border-amber-400 hover:bg-amber-50 transition">
                  <input type="file" accept="image/*" onChange={handleExtraPhotoSelect} className="hidden" disabled={extraUploading} />
                  {extraUploading ? (
                    <div className="text-gray-500 body-font text-sm py-2">Procesando imagen...</div>
                  ) : (
                    <React.Fragment>
                      <ImageIcon size={24} className="mx-auto text-gray-400 mb-1.5" />
                      <div className="body-font text-xs text-gray-600 font-medium">Pulsa para subir foto extra</div>
                      <div className="body-font text-[10px] text-gray-400 mt-0.5">Pronóstico del tiempo, aviso, foto de un cruce…</div>
                    </React.Fragment>
                  )}
                </label>
              )}
            </FormField>

            <FormField label="Archivo GPX/FIT/TCX">
              {trackFile ? (
                <div className="bg-gray-50 border-2 border-gray-200 rounded-xl p-3">
                  <div className="flex items-center gap-3">
                    <div className="bg-red-100 text-red-700 w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0">
                      <Bike size={18} strokeWidth={2.5} />
                    </div>
                    <div className="flex-1 min-w-0">
                      <div className="body-font text-sm font-bold text-gray-800 truncate">{trackFile.name}</div>
                      <div className="body-font text-xs text-gray-500">{formatBytes(trackFile.size)} · {(trackFile.type || '').toUpperCase()}</div>
                    </div>
                    <button onClick={function() { setTrackFile(null); }}
                      className="w-7 h-7 rounded-full bg-white shadow-md flex items-center justify-center text-gray-700 hover:text-red-600 active:scale-90 transition flex-shrink-0">
                      <CloseIcon size={14} strokeWidth={2.5} />
                    </button>
                  </div>
                  {/* v2-jun: perfil de altimetría del GPX recién subido */}
                  {/* v260-fix-bandwidth: usa el dato hidratado si el archivo ya estaba externalizado */}
                  {(function() {
                    const tfWithData = (trackFile && trackFile.data) ? trackFile
                      : (_hydratedTrack.data ? Object.assign({}, trackFile, { data: _hydratedTrack.data }) : null);
                    if (!tfWithData) {
                      return _hydratedTrack.loading
                        ? <div className="body-font text-[11px] text-gray-400 mt-1">Cargando perfil…</div>
                        : null;
                    }
                    const prof = profileFromTrackFile(tfWithData, 100, 34);
                    return prof ? <ElevationProfile profile={prof} height={34} /> : null;
                  })()}
                </div>
              ) : (
                <label className="block bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-4 text-center cursor-pointer hover:border-red-400 hover:bg-red-50 transition">
                  <input type="file" accept=".gpx,.fit,.tcx" onChange={handleTrackFileSelect} className="hidden" disabled={trackUploading} />
                  {trackUploading ? (
                    <div className="text-gray-500 body-font text-sm py-2">Procesando archivo...</div>
                  ) : (
                    <React.Fragment>
                      <Upload size={24} className="mx-auto text-gray-400 mb-1.5" />
                      <div className="body-font text-xs text-gray-600 font-medium">Subir archivo de ruta</div>
                      <div className="body-font text-[10px] text-gray-400 mt-0.5">.gpx · .fit · .tcx (máx 8 MB)</div>
                    </React.Fragment>
                  )}
                </label>
              )}
            </FormField>

            <FormField label="Enlace externo">
              <input type="url" value={externalLink} onChange={function(e) { setExternalLink(e.target.value); }}
                placeholder="https://strava.com/..." className={inputCls} />
            </FormField>
          </div>
          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn}>CANCELAR</button>
            <button onClick={submit}
              disabled={onlyRouteFields && !route.trim()}
              className={primaryBtn + ' disabled:opacity-40 disabled:cursor-not-allowed'}>GUARDAR</button>
          </div>
        </Modal>
      );
    }

    // ============ EDIT SPONSOR MODAL ============
    function EditSponsorModal({ sponsor, onClose, onSave, showToast }) {
      const [name, setName] = useState(sponsor.name || '');
      const [link, setLink] = useState(sponsor.link || '');
      const [image, setImage] = useState(sponsor.image || '');
      const [uploading, setUploading] = useState(false);

      function handleFile(e) {
        const f = e.target.files[0];
        if (!f) return;
        if (!f.type.startsWith('image/')) { showToast('Imagen no válida'); return; }
        setUploading(true);
        const r = new FileReader();
        r.onload = function(ev) {
          const img = new window.Image();
          img.onload = function() {
            const cv = document.createElement('canvas');
            const ms = 600;
            let w = img.width, h = img.height;
            if (w > ms || h > ms) {
              if (w > h) { h = Math.round(h * ms / w); w = ms; }
              else { w = Math.round(w * ms / h); h = ms; }
            }
            cv.width = w; cv.height = h;
            const cx = cv.getContext('2d');
            cx.fillStyle = '#fff'; cx.fillRect(0, 0, w, h); cx.drawImage(img, 0, 0, w, h);
            setImage(cv.toDataURL('image/jpeg', 0.85));
            setUploading(false);
          };
          img.onerror = function() { showToast('Error con la imagen'); setUploading(false); };
          img.src = ev.target.result;
        };
        r.onerror = function() { showToast('Error al leer'); setUploading(false); };
        r.readAsDataURL(f);
      }

      function submit() {
        if (!image) { showToast('Falta la imagen'); return; }
        onSave({ name: name.trim(), link: link.trim(), image: image });
      }

      return (
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-2xl tracking-wide mb-1 text-red-700">EDITAR PATROCINADOR</h2>
          <p className="body-font text-sm text-gray-500 mb-5">Cambia el logo, nombre o enlace</p>
          <div className="space-y-3">
            <FormField label="Logo / Imagen">
              {image ? (
                <div className="relative">
                  <div className="bg-gray-50 border-2 border-gray-200 rounded-xl p-4 flex items-center justify-center min-h-[160px]">
                    <img src={image} alt="Vista previa" className="max-h-40 object-contain" />
                  </div>
                  <label className="absolute top-2 right-2 bg-white shadow-md rounded-full px-3 py-1.5 cursor-pointer text-xs font-bold text-red-700 hover:bg-red-50 active:scale-95 transition">
                    <input type="file" accept="image/*" onChange={handleFile} className="hidden" disabled={uploading} />
                    {uploading ? '...' : 'Cambiar'}
                  </label>
                </div>
              ) : (
                <label className="block bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-6 text-center cursor-pointer hover:border-red-400 hover:bg-red-50 transition">
                  <input type="file" accept="image/*" onChange={handleFile} className="hidden" disabled={uploading} />
                  {uploading ? (
                    <div className="text-gray-500 body-font text-sm py-4">Procesando...</div>
                  ) : (
                    <React.Fragment>
                      <Upload size={32} className="mx-auto text-gray-400 mb-2" />
                      <div className="body-font text-sm text-gray-600 font-medium">Pulsa para subir imagen</div>
                    </React.Fragment>
                  )}
                </label>
              )}
            </FormField>
            <FormField label="Nombre">
              <input type="text" value={name} onChange={function(e) { setName(e.target.value); }} className={inputCls} />
            </FormField>
            <FormField label="Enlace web">
              <input type="url" value={link} onChange={function(e) { setLink(e.target.value); }} className={inputCls} />
            </FormField>
          </div>
          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn}>CANCELAR</button>
            <button onClick={submit} className={primaryBtn}>GUARDAR</button>
          </div>
        </Modal>
      );
    }

    // ============ EDIT AVISO MODAL (admin de grupeta o central) ============
    // Edita un aviso existente o crea uno nuevo. `aviso` es {} para nuevo, o el objeto para editar.
    function EditAvisoModal({ aviso, onClose, onSave, onDelete, showToast, kind }) {
      // kind: 'aviso' (por grupeta, defecto) | 'noticia' (global, portada)
      const labels = (kind === 'noticia')
        ? { titleNew: 'NUEVA NOTICIA', titleEdit: 'EDITAR NOTICIA',
            subtitleNew: 'Publica una noticia para la portada', subtitleEdit: 'Modifica la noticia',
            textLabel: 'Texto de la noticia', placeholder: 'Escribe la noticia...',
            expireHelp: 'Después de esta fecha la noticia deja de mostrarse a los usuarios.',
            confirmDelete: '¿Eliminar esta noticia? No se puede deshacer.' }
        : { titleNew: 'NUEVA NOTICIA DEL CLUB', titleEdit: 'EDITAR NOTICIA DEL CLUB',
            subtitleNew: 'Publica una noticia o aviso para tu grupeta', subtitleEdit: 'Modifica la noticia',
            textLabel: 'Texto de la noticia', placeholder: 'Escribe la noticia o aviso...',
            expireHelp: 'Después de esta fecha la noticia deja de mostrarse a los usuarios.',
            confirmDelete: '¿Eliminar esta noticia? No se puede deshacer.' };
      const isNew = !aviso || !aviso.id;
      const [texto, setTexto] = useState(aviso.texto || '');
      const [foto, setFoto] = useState(aviso.foto || '');
      const [fechaFin, setFechaFin] = useState(aviso.fechaFin || defaultAvisoFechaFin());
      const [marquee, setMarquee] = useState(!!aviso.marquee);
      // v72: estilo del rótulo POR NOTICIA (como el botón de ayuda). Sin fondo = aspecto clásico.
      const [mUseBg, setMUseBg] = useState(!!aviso.mBg);
      const [mBg, setMBg] = useState(aviso.mBg || '#facc15');
      const [mFg, setMFg] = useState(aviso.mFg || '#ffffff');
      const [mVel, setMVel] = useState(aviso.mVel || 18);
      // v91: posición (arriba / debajo de grupetas), ancho y color (solo noticias)
      const [pos, setPos] = useState(aviso.pos || 'top');
      const [ancho, setAncho] = useState(aviso.ancho || 'full');
      const [color, setColor] = useState(aviso.color || '');
      const [urlNoticia, setUrlNoticia] = useState(aviso.url || ''); // v107: enlace externo clicable
      const NOTICIA_COLORS = ['', '#7f1d1d', '#1e3a8a', '#14532d', '#9a3412', '#581c87', '#374151'];
      const [uploading, setUploading] = useState(false);

      const MAX_LEN = 1000;

      function handleFile(e) {
        const f = e.target.files[0];
        if (!f) return;
        if (!f.type.startsWith('image/')) { showToast('Imagen no válida'); return; }
        setUploading(true);
        const r = new FileReader();
        r.onload = function(ev) {
          const img = new window.Image();
          img.onload = function() {
            // Banner 2:1, máx 800px de ancho. Recorta vertical si la imagen es muy alta.
            const targetW = Math.min(img.width, 800);
            const targetH = Math.round(targetW / 2);
            const srcRatio = img.width / img.height;
            let sx = 0, sy = 0, sw = img.width, sh = img.height;
            if (srcRatio > 2) {
              // Demasiado ancha: recortar lados
              sw = img.height * 2;
              sx = (img.width - sw) / 2;
            } else if (srcRatio < 2) {
              // Demasiado alta: recortar arriba/abajo
              sh = img.width / 2;
              sy = (img.height - sh) / 2;
            }
            const cv = document.createElement('canvas');
            cv.width = targetW; cv.height = targetH;
            const cx = cv.getContext('2d');
            cx.fillStyle = '#fff'; cx.fillRect(0, 0, targetW, targetH);
            cx.drawImage(img, sx, sy, sw, sh, 0, 0, targetW, targetH);
            setFoto(cv.toDataURL('image/jpeg', 0.78));
            setUploading(false);
          };
          img.onerror = function() { showToast('Error con la imagen'); setUploading(false); };
          img.src = ev.target.result;
        };
        r.onerror = function() { showToast('Error al leer'); setUploading(false); };
        r.readAsDataURL(f);
      }

      function submit() {
        const t = texto.trim();
        if (!t) { showToast('Falta el texto del aviso'); return; }
        if (t.length > MAX_LEN) { showToast('Texto demasiado largo (máx ' + MAX_LEN + ')'); return; }
        if (!fechaFin) { showToast('Falta la fecha de caducidad'); return; }
        onSave({
          id: aviso.id || null,
          texto: t,
          foto: foto || '',
          url: (kind === 'noticia') ? urlNoticia.trim() : '',
          fechaFin: fechaFin,
          marquee: (kind === 'noticia') ? !!marquee : false,
          mBg: (kind === 'noticia' && marquee && mUseBg) ? mBg : '',
          mFg: (kind === 'noticia' && marquee) ? mFg : '',
          mVel: (kind === 'noticia' && marquee) ? (Math.min(60, Math.max(5, parseInt(mVel, 10) || 18))) : 18,
          pos: (kind === 'noticia') ? pos : 'top',
          ancho: (kind === 'noticia') ? ancho : 'full',
          color: (kind === 'noticia') ? color : '',
          orden: (aviso.orden != null) ? aviso.orden : null,
          fechaCreacion: aviso.fechaCreacion || null,
          creadoPor: aviso.creadoPor || null
        });
      }

      function handleDelete() {
        if (!window.__confirmPending_fd) { window.__confirmPending_fd=true; setTimeout(function(){ window.__confirmPending_fd=false; },3500); showToast('Pulsa de nuevo para eliminar'); return; } window.__confirmPending_fd=false;
        onDelete(aviso.id);
      }

      const charsLeft = MAX_LEN - texto.length;
      const overLimit = charsLeft < 0;

      return (
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-2xl tracking-wide mb-1 text-red-700">
            {isNew ? labels.titleNew : labels.titleEdit}
          </h2>
          <p className="body-font text-sm text-gray-500 mb-5">
            {isNew ? labels.subtitleNew : labels.subtitleEdit}
          </p>
          <div className="space-y-3">
            <FormField label="Foto (opcional)">
              {foto ? (
                <div className="relative">
                  <div className="bg-gray-50 border-2 border-gray-200 rounded-xl overflow-hidden">
                    <img src={foto} alt="Vista previa" className="w-full aspect-[2/1] object-cover" />
                  </div>
                  <div className="absolute top-2 right-2 flex gap-1.5">
                    <label className="bg-white shadow-md rounded-full px-3 py-1.5 cursor-pointer text-xs font-bold text-red-700 hover:bg-red-50 active:scale-95 transition">
                      <input type="file" accept="image/*" onChange={handleFile} className="hidden" disabled={uploading} />
                      {uploading ? '...' : 'Cambiar'}
                    </label>
                    <button onClick={function() { setFoto(''); }}
                      className="bg-white shadow-md rounded-full px-3 py-1.5 text-xs font-bold text-red-700 hover:bg-red-50 active:scale-95 transition">
                      Quitar
                    </button>
                  </div>
                </div>
              ) : (
                <label className="block bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-6 text-center cursor-pointer hover:border-red-400 hover:bg-red-50 transition">
                  <input type="file" accept="image/*" onChange={handleFile} className="hidden" disabled={uploading} />
                  {uploading ? (
                    <div className="text-gray-500 body-font text-sm py-4">Procesando...</div>
                  ) : (
                    <React.Fragment>
                      <Upload size={32} className="mx-auto text-gray-400 mb-2" />
                      <div className="body-font text-sm text-gray-600 font-medium">Pulsa para subir imagen</div>
                      <div className="body-font text-xs text-gray-400 mt-1">Recomendado: formato apaisado</div>
                    </React.Fragment>
                  )}
                </label>
              )}
            </FormField>
            <FormField label="Texto de la noticia">
              <textarea value={texto}
                onChange={function(e) { setTexto(e.target.value); }}
                rows={4}
                maxLength={MAX_LEN + 50}
                placeholder={labels.placeholder}
                className={inputCls + ' resize-none' + (overLimit ? ' !border-red-500 !ring-2 !ring-red-200' : '')} />
              <div className={'text-xs body-font mt-1 ' + (overLimit ? 'text-red-600 font-bold' : 'text-gray-400')}>
                {charsLeft} / {MAX_LEN}
              </div>
            </FormField>
            {kind === 'noticia' && (
              <FormField label="🔗 Enlace al pulsar (opcional)">
                <input type="url" value={urlNoticia}
                  onChange={function(e) { setUrlNoticia(e.target.value); }}
                  placeholder="https://..."
                  className={inputCls} />
                <p className="body-font text-[11px] text-gray-400 mt-1">Si rellenas esto, toda la tarjeta será clicable y llevará a esta URL.</p>
              </FormField>
            )}
            <FormField label="Fecha de caducidad">
              <input type="date" value={fechaFin}
                onChange={function(e) { setFechaFin(e.target.value); }}
                className={inputCls} />
              <div className="text-xs body-font text-gray-400 mt-1">
                {labels.expireHelp}
              </div>
            </FormField>
            {kind === 'noticia' && (
              <div className="bg-gray-50 border-2 border-gray-200 rounded-xl p-3 space-y-3">
                <div>
                  <p className="body-font text-xs font-bold text-gray-600 mb-1.5">📍 Posición en la portada</p>
                  <div className="flex gap-2">
                    <button type="button" onClick={function() { setPos('top'); }}
                      className={'flex-1 text-xs font-bold py-2 rounded-lg border-2 ' + (pos === 'top' ? 'border-red-600 bg-red-50 text-red-700' : 'border-gray-200 text-gray-500')}>Arriba</button>
                    <button type="button" onClick={function() { setPos('bottom'); }}
                      className={'flex-1 text-xs font-bold py-2 rounded-lg border-2 ' + (pos === 'bottom' ? 'border-red-600 bg-red-50 text-red-700' : 'border-gray-200 text-gray-500')}>Debajo de las grupetas</button>
                  </div>
                </div>
                <div>
                  <p className="body-font text-xs font-bold text-gray-600 mb-1.5">↔️ Ancho</p>
                  <div className="flex gap-2">
                    <button type="button" onClick={function() { setAncho('full'); }}
                      className={'flex-1 text-xs font-bold py-2 rounded-lg border-2 ' + (ancho === 'full' ? 'border-red-600 bg-red-50 text-red-700' : 'border-gray-200 text-gray-500')}>Pantalla completa</button>
                    <button type="button" onClick={function() { setAncho('half'); }}
                      className={'flex-1 text-xs font-bold py-2 rounded-lg border-2 ' + (ancho === 'half' ? 'border-red-600 bg-red-50 text-red-700' : 'border-gray-200 text-gray-500')}>Media pantalla</button>
                  </div>
                </div>
                <div>
                  <p className="body-font text-xs font-bold text-gray-600 mb-1.5">🎨 Color de la tarjeta</p>
                  <div className="flex gap-2 flex-wrap">
                    {NOTICIA_COLORS.map(function(c) {
                      return (
                        <button key={c || 'def'} type="button" onClick={function() { setColor(c); }}
                          title={c ? c : 'Por defecto'}
                          className={'w-9 h-9 rounded-full border-2 flex items-center justify-center text-[9px] font-bold ' + (color === c ? 'border-red-600 ring-2 ring-red-300' : 'border-gray-300')}
                          style={{ backgroundColor: c || '#1f2937' }}>
                          {!c && <span className="text-white">AUTO</span>}
                        </button>
                      );
                    })}
                  </div>
                </div>
              </div>
            )}
            {kind === 'noticia' && (
              <label className="flex items-center gap-3 bg-gray-50 border-2 border-gray-200 rounded-xl p-3 cursor-pointer hover:border-red-300 transition">
                <input type="checkbox" checked={marquee}
                  onChange={function(e) { setMarquee(e.target.checked); }}
                  className="w-5 h-5 accent-red-700 flex-shrink-0" />
                <span className="body-font text-sm text-gray-700">
                  <strong>📜 Texto en movimiento (rótulo)</strong>
                  <span className="block text-xs text-gray-400 mt-0.5">El texto se desliza hacia la izquierda en bucle, como un rótulo de televisión. Si lo dejas sin marcar, la noticia se muestra fija.</span>
                </span>
              </label>
            )}
            {kind === 'noticia' && marquee && (
              <div className="bg-gray-50 border-2 border-gray-200 rounded-xl p-3">
                {/* v72: vista previa del rótulo sobre fondo oscuro (simula la tarjeta de portada) */}
                <p className="body-font text-[11px] font-bold text-gray-600 mb-1">Vista previa</p>
                <div className="bg-gray-800 rounded-lg p-2 mb-3">
                  <div className={'marquee-wrap ' + (mUseBg ? 'rounded-lg px-2 py-1' : '')}
                       style={{ backgroundColor: mUseBg ? mBg : 'transparent', color: mFg }}>
                    <span className="marquee-track body-font text-[13px] leading-snug"
                          style={{ animationDuration: (Math.min(60, Math.max(5, parseInt(mVel, 10) || 18))) + 's' }}>
                      {texto || 'Escribe la noticia para verla aquí...'}
                    </span>
                  </div>
                </div>
                <div className="flex gap-3 mb-3 items-end">
                  <label className="flex-1 body-font text-[11px] font-bold text-gray-600">
                    <span className="flex items-center gap-1.5 mb-1">
                      <input type="checkbox" checked={mUseBg}
                        onChange={function(e) { setMUseBg(e.target.checked); }}
                        className="w-4 h-4 accent-red-700" />
                      Fondo de color
                    </span>
                    <input type="color" value={mBg} disabled={!mUseBg}
                      onChange={function(e) { setMBg(e.target.value); }}
                      className={'block w-full h-10 rounded-lg border border-gray-300 ' + (mUseBg ? 'cursor-pointer' : 'opacity-30')} />
                  </label>
                  <label className="flex-1 body-font text-[11px] font-bold text-gray-600">
                    Color del texto
                    <input type="color" value={mFg}
                      onChange={function(e) { setMFg(e.target.value); }}
                      className="block w-full h-10 mt-1 rounded-lg border border-gray-300 cursor-pointer" />
                  </label>
                </div>
                <label className="block body-font text-[11px] font-bold text-gray-600">
                  Velocidad: una vuelta cada <span className="text-red-700">{Math.min(60, Math.max(5, parseInt(mVel, 10) || 18))} s</span> (menos segundos = más rápido)
                  <input type="range" min="5" max="60" step="1"
                    value={Math.min(60, Math.max(5, parseInt(mVel, 10) || 18))}
                    onChange={function(e) { setMVel(parseInt(e.target.value, 10)); }}
                    className="block w-full mt-1 accent-red-700" />
                </label>
              </div>
            )}
          </div>
          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn}>CANCELAR</button>
            {!isNew && (
              <button onClick={handleDelete}
                className="bg-red-100 hover:bg-red-200 text-red-700 font-bold display-font tracking-wider text-sm py-2.5 px-4 rounded-xl active:scale-95 transition">
                ELIMINAR
              </button>
            )}
            <button onClick={submit}
              disabled={!texto.trim() || overLimit}
              className={primaryBtn + ' flex-1 disabled:opacity-40 disabled:cursor-not-allowed'}>
              {isNew ? 'PUBLICAR' : 'GUARDAR'}
            </button>
          </div>
        </Modal>
      );
    }

    // v79: comprime una imagen SIN recortarla (máx 1000px de lado largo, JPEG 0.82).
    // Para la "foto extra" de las salidas (tiempo/advertencia/cruce): debe verse ENTERA.
    function compressImageContain(file, onDone, onError) {
      const r = new FileReader();
      r.onload = function(ev) {
        const img = new window.Image();
        img.onload = function() {
          const cv = document.createElement('canvas');
          const ms = 1000;
          let w = img.width, h = img.height;
          if (w > ms || h > ms) {
            if (w > h) { h = Math.round(h * ms / w); w = ms; }
            else { w = Math.round(w * ms / h); h = ms; }
          }
          cv.width = w; cv.height = h;
          const cx = cv.getContext('2d');
          cx.fillStyle = '#fff'; cx.fillRect(0, 0, w, h); cx.drawImage(img, 0, 0, w, h);
          onDone(cv.toDataURL('image/jpeg', 0.82));
        };
        img.onerror = function() { onError(); };
        img.src = ev.target.result;
      };
      r.onerror = function() { onError(); };
      r.readAsDataURL(file);
    }

    // v78: 🔍 VISOR DE FOTOS a pantalla completa (avisos/noticias).
    // Pedido por un socio: poder ampliar la foto del pronóstico del tiempo.
    // - Se abre tocando la foto del aviso. Fondo negro, z-[70] (sobre modales y toasts).
    // - Botones − / + (100% a 400%, pasos de 50%). Tocar la imagen también acerca
    //   (y al llegar al máximo vuelve al 100%). Con zoom se arrastra con scroll nativo.
    // - Cierra con ✕ o tocando el fondo negro.
    // - El truco del centrado: img con m-auto DENTRO de un flex con overflow-auto
    //   (justify-center recortaría el borde al hacer zoom; m-auto no).
    function ImageViewerOverlay({ src, onClose }) {
      const [zoom, setZoom] = useState(1);
      const ZMIN = 1, ZMAX = 4;
      if (!src) return null;
      function bump(d) {
        setZoom(function(z) { return Math.min(ZMAX, Math.max(ZMIN, Math.round((z + d) * 2) / 2)); });
      }
      return (
        <div className="fixed inset-0 z-[70] bg-black/95 flex flex-col" onClick={onClose}>
          <div className="safe-top flex items-center justify-end gap-2 px-3 pb-2"
               onClick={function(e) { e.stopPropagation(); }}>
            <button onClick={function() { bump(-0.5); }} disabled={zoom <= ZMIN}
              className="w-9 h-9 rounded-full bg-white/15 text-white text-xl font-bold flex items-center justify-center active:scale-90 transition disabled:opacity-30"
              aria-label="Alejar">−</button>
            <span className="display-font text-white/80 text-xs font-bold w-12 text-center">{Math.round(zoom * 100)}%</span>
            <button onClick={function() { bump(0.5); }} disabled={zoom >= ZMAX}
              className="w-9 h-9 rounded-full bg-white/15 text-white text-xl font-bold flex items-center justify-center active:scale-90 transition disabled:opacity-30"
              aria-label="Acercar">+</button>
            <button onClick={onClose}
              className="w-9 h-9 rounded-full bg-white/15 text-white flex items-center justify-center active:scale-90 transition ml-2"
              aria-label="Cerrar">
              <CloseIcon size={16} strokeWidth={3} />
            </button>
          </div>
          <div className="flex-1 overflow-auto flex" style={{ WebkitOverflowScrolling: 'touch' }}
               onClick={function(e) { e.stopPropagation(); }}>
            <img src={src} alt="" className="m-auto block"
              style={{ width: (zoom * 100) + '%', maxWidth: 'none' }}
              onClick={function() { bump(zoom >= ZMAX ? -(ZMAX - ZMIN) : 0.5); }} />
          </div>
        </div>
      );
    }

    // ============ AVISO CARD ============
    // Tarjeta individual de un aviso. Se usa dentro de AvisosSection.
    // Si effectiveAdmin, muestra botones de editar/borrar y resalta los caducados.
    function AvisoCard({ aviso, effectiveAdmin, isExpired, isNoticia, onEdit, onDelete, onReorder }) {
      // v78: estado del visor de foto a pantalla completa
      const [verFoto, setVerFoto] = useState(false);
      // v91: color de fondo elegido por el admin (solo noticias)
      const customBg = (isNoticia && !isExpired && aviso.color) ? aviso.color : null;
      // v107: si tiene url y no es admin, toda la tarjeta es un enlace externo
      const tieneUrl = !effectiveAdmin && isNoticia && aviso.url && !isExpired;
      const cardContent = (
        <div className={'relative rounded-xl p-3 border ' +
          (isExpired
            ? 'bg-black/30 border-white/15 opacity-60'
            : (customBg ? 'border-white/25' : 'bg-black/25 border-yellow-400/30')) +
          (tieneUrl ? ' cursor-pointer active:scale-[0.98] transition-transform' : '')}
          style={customBg ? { backgroundColor: customBg } : undefined}>
          {isExpired && (
            <div className="absolute top-2 left-2 bg-white/15 text-white/80 px-2 py-0.5 rounded-full display-font text-[9px] font-bold tracking-wider z-10">
              CADUCADO
            </div>
          )}
          {effectiveAdmin && (
            <div className="absolute top-2 right-2 flex gap-1.5 z-10">
              {isNoticia && onReorder && (
                <React.Fragment>
                  <button onClick={function() { onReorder(aviso.id, -1); }}
                    className="w-7 h-7 rounded-full bg-white/85 hover:bg-white text-red-900 flex items-center justify-center shadow-md active:scale-90 transition font-bold text-xs"
                    aria-label="Subir noticia">▲</button>
                  <button onClick={function() { onReorder(aviso.id, 1); }}
                    className="w-7 h-7 rounded-full bg-white/85 hover:bg-white text-red-900 flex items-center justify-center shadow-md active:scale-90 transition font-bold text-xs"
                    aria-label="Bajar noticia">▼</button>
                </React.Fragment>
              )}
              <button onClick={function() { onEdit(aviso); }}
                className="w-7 h-7 rounded-full bg-yellow-400 hover:bg-yellow-300 text-red-900 flex items-center justify-center shadow-md active:scale-90 transition"
                aria-label="Editar aviso">
                <Edit2 size={12} strokeWidth={3} />
              </button>
              <button onClick={function() { onDelete(aviso.id); }}
                className="w-7 h-7 rounded-full bg-red-700 hover:bg-red-800 text-white flex items-center justify-center shadow-md active:scale-90 transition"
                aria-label="Eliminar aviso">
                <CloseIcon size={12} strokeWidth={3} />
              </button>
            </div>
          )}
          {aviso.foto && (
            <div className="relative mb-2 cursor-zoom-in" onClick={function() { setVerFoto(true); }}>
              <img src={aviso.foto} alt=""
                className="w-full aspect-[2/1] object-cover rounded-lg border border-white/10" />
              <span className="absolute bottom-1.5 right-1.5 bg-black/55 text-white/90 px-2 py-0.5 rounded-full display-font text-[9px] font-bold tracking-wider pointer-events-none">
                🔍 AMPLIAR
              </span>
            </div>
          )}
          {verFoto && aviso.foto && (
            <ImageViewerOverlay src={aviso.foto} onClose={function() { setVerFoto(false); }} />
          )}
          {(isNoticia && aviso.marquee && !isExpired) ? (
            <div className={'marquee-wrap mb-2 ' + (effectiveAdmin ? 'pr-16 ' : '') + (aviso.mBg ? 'rounded-lg px-2 py-1' : '')}
                 style={{ backgroundColor: aviso.mBg || 'transparent', color: aviso.mFg || undefined }}>
              <span className="marquee-track body-font text-[13px] leading-snug"
                    style={{ animationDuration: (Math.min(60, Math.max(5, parseInt(aviso.mVel, 10) || 18))) + 's' }}>{aviso.texto}</span>
            </div>
          ) : (
            <p className={'body-font text-[13px] leading-snug whitespace-pre-wrap break-words mb-2 ' +
                         (effectiveAdmin ? 'pr-16' : '') +
                         (isExpired ? ' pt-5' : '')}>
              {aviso.texto}
            </p>
          )}
          {!isNoticia && (
            <div className="flex items-center gap-1 text-[11px] body-font text-yellow-300/85">
              <Clock size={11} strokeWidth={2.5} />
              <span>{isExpired ? 'Caducó' : 'Caduca'} {formatAvisoFechaFin(aviso.fechaFin)}</span>
            </div>
          )}
        </div>
      );
      if (tieneUrl) {
        return <a href={aviso.url} target="_blank" rel="noopener noreferrer" className="block no-underline">{cardContent}</a>;
      }
      return cardContent;
    }

    // ============ AVISOS SECTION ============
    // Bloque visible en la página de grupeta. Lista los avisos vigentes (y caducados si effectiveAdmin).
    // Si no hay nada que mostrar y no es admin, devuelve null (la sección se oculta).
    function AvisosSection({ grupeta, items, effectiveAdmin, onEdit, onDelete, todayKey, kind, onReorder, hideAddButton }) {
      // kind: 'aviso' (defecto) | 'noticia'. items: si se pasa, usa ese map directo;
      // si no, usa grupeta.avisos (retrocompat).
      const isNoticia = (kind === 'noticia');
      const sourceMap = items || (grupeta && grupeta.avisos) || {};
      const headerLabel = isNoticia ? '📰 NOTICIAS' : '📰 NOTICIAS DEL CLUB';
      const buttonLabel = isNoticia ? 'NUEVA NOTICIA' : 'NUEVA NOTICIA DEL CLUB';
      const all = Object.keys(sourceMap).map(function(k) { return sourceMap[k]; })
        .filter(function(a) { return a && a.texto; });
      // v91: noticias se ordenan por 'orden' manual (asc); sin orden → al final por fecha.
      function sortFn(a, b) {
        if (isNoticia) {
          const ao = (a.orden != null) ? a.orden : 9999;
          const bo = (b.orden != null) ? b.orden : 9999;
          if (ao !== bo) return ao - bo;
        }
        return (b.fechaCreacion || 0) - (a.fechaCreacion || 0);
      }
      const active = all.filter(function(a) { return isAvisoActive(a, todayKey); }).sort(sortFn);
      const expired = all.filter(function(a) { return !isAvisoActive(a, todayKey); }).sort(sortFn);

      // Para usuarios normales sin avisos vigentes, ocultar la sección entera
      if (!effectiveAdmin && active.length === 0) return null;

      const showHeader = active.length > 0 || (effectiveAdmin && expired.length > 0);

      return (
        <div className="mb-6 fade-up">
          {showHeader && (
            <h3 className="display-font font-bold text-lg tracking-widest text-white text-center mb-4">{headerLabel}</h3>
          )}
          <div className="flex flex-wrap gap-2">
            {active.map(function(a) {
              const half = isNoticia && a.ancho === 'half';
              return <div key={a.id} className={half ? 'w-[calc(50%-4px)]' : 'w-full'}>
                <AvisoCard aviso={a} effectiveAdmin={effectiveAdmin}
                           isExpired={false} isNoticia={isNoticia} onEdit={onEdit} onDelete={onDelete} onReorder={onReorder} />
              </div>;
            })}
            {effectiveAdmin && expired.map(function(a) {
              return <div key={a.id} className="w-full">
                <AvisoCard aviso={a} effectiveAdmin={effectiveAdmin}
                           isExpired={true} isNoticia={isNoticia} onEdit={onEdit} onDelete={onDelete} onReorder={onReorder} />
              </div>;
            })}
          </div>
          {effectiveAdmin && !hideAddButton && (
            <button onClick={function() { onEdit({}); }}
              className="w-full mt-2 bg-yellow-400/15 hover:bg-yellow-400/25 border border-dashed border-yellow-400/50 text-yellow-300 rounded-xl py-2.5 px-4 font-bold display-font tracking-wider text-xs flex items-center justify-center gap-2 active:scale-[0.98] transition">
              <Plus size={14} strokeWidth={3} /> {buttonLabel}
            </button>
          )}
        </div>
      );
    }

    // ============ MANAGE CYCLISTS MODAL (admin central) ============
    function ManageCyclistsModal({ users, grupetas, acceptances, canEdit, isCentral, onClose, onUpdateUser, onDeleteUser }) {
      const [editingId, setEditingId] = useState(null);
      const [search, setSearch] = useState('');
      const [roleTarget, setRoleTarget] = useState(null); // uid en curso de asignación
      const [roleSelected, setRoleSelected] = useState('user'); // rol seleccionado en el panel
      const [adminOfSelected, setAdminOfSelected] = useState([]); // grupetas seleccionadas
      const [roleBusy, setRoleBusy] = useState(false);
      const [roleLoading, setRoleLoading] = useState(false); // cargando claims actuales
      const [roleMsg, setRoleMsg] = useState('');

      function openRolePanel(uid) {
        if (roleTarget === uid) { setRoleTarget(null); return; }
        setRoleTarget(uid);
        setRoleSelected('user');
        setAdminOfSelected([]);
        setRoleMsg('');
        setRoleLoading(true);
        var fbUser = firebase.auth().currentUser;
        if (!fbUser) { setRoleLoading(false); return; }
        fbUser.getIdToken(false).then(function(tok) {
          return fetch('/.netlify/functions/get-role', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ callerToken: tok, targetUid: uid })
          });
        }).then(function(r) { return r.json(); })
          .then(function(data) {
            if (data.ok) {
              setRoleSelected(data.role || 'user');
              // adminOf puede llegar como objeto {gid:true} o array — normalizar a array
              var ao = data.adminOf;
              if (ao && !Array.isArray(ao) && typeof ao === 'object') ao = Object.keys(ao);
              setAdminOfSelected(Array.isArray(ao) ? ao : []);
            }
            setRoleLoading(false);
          }).catch(function() { setRoleLoading(false); });
      }
      // v28-nonoP: orden de la lista — 'nombre' (alfabético), 'club' (por club) o 'fecha' (más recientes primero)
      const [sortMode, setSortMode] = useState('nombre');
      // v63: confirmación de borrado EN EL PROPIO BOTÓN (dos toques).
      // window.confirm no se muestra en la PWA de iOS instalada → el borrado
      // nunca se ejecutaba y el botón parecía muerto.
      const [confirmDeleteId, setConfirmDeleteId] = useState(null);
      const confirmTimerRef = useRef(null);
      const [showEmail, setShowEmail] = useState(false);
      useEffect(function() {
        return function() { if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); };
      }, []);
      function askDelete(uid) {
        if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
        setConfirmDeleteId(uid);
        // Si en 4 segundos no confirma, el botón vuelve a la papelera normal.
        confirmTimerRef.current = setTimeout(function() { setConfirmDeleteId(null); }, 4000);
      }

      function handleDownloadAcceptance(uid, u) {
        const rideAccs = collectRideAcceptances(uid, grupetas, null);
        const legacy = (acceptances || {})[uid] || null;
        const html = buildAcceptanceHTML(u, rideAccs, legacy);
        const win = window.open('', '_blank');
        if (win) {
          win.document.write(html);
          win.document.close();
        } else {
          // Popup blocked: fall back to download
          const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
          const url = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = url;
          a.download = 'autorizacion-' + slugify((u.nombre || '') + ' ' + (u.apellidos || '')) + '.html';
          document.body.appendChild(a); a.click(); document.body.removeChild(a);
          setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
        }
      }

      const userList = Object.entries(users || {}).map(function(entry) {
        return Object.assign({ _id: entry[0] }, entry[1]);
      }).sort(function(a, b) {
        if (sortMode === 'fecha') {
          // Más recientes primero. Sin createdAt → al final.
          const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
          const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
          if (tb !== ta) return tb - ta;
          // Empate → orden alfabético como desempate
        }
        if (sortMode === 'club') {
          const ca = (getUserClubsDisplay(a) || 'zzzz').toLowerCase();
          const cb = (getUserClubsDisplay(b) || 'zzzz').toLowerCase();
          if (ca !== cb) return ca.localeCompare(cb);
          // Mismo club → desempate alfabético
        }
        const na = ((a.nombre || '') + ' ' + (a.apellidos || '')).toLowerCase();
        const nb = ((b.nombre || '') + ' ' + (b.apellidos || '')).toLowerCase();
        return na.localeCompare(nb);
      });

      const filtered = search.trim() ? userList.filter(function(u) {
        const q = search.toLowerCase();
        return ((u.nombre || '').toLowerCase().indexOf(q) !== -1)
          || ((u.apellidos || '').toLowerCase().indexOf(q) !== -1)
          || (getUserClubsDisplay(u).toLowerCase().indexOf(q) !== -1)
          || ((u.correo || '').toLowerCase().indexOf(q) !== -1);
      }) : userList;

      const editingUser = editingId ? users[editingId] : null;

      if (editingUser) {
        return (
          <Modal onClose={function() { setEditingId(null); }}>
            <ProfileForm
              initialUser={editingUser}
              grupetas={grupetas}
              onSave={function(data) {
                onUpdateUser(editingId, data);
                setEditingId(null);
              }}
              onCancel={function() { setEditingId(null); }}
              title="EDITAR CICLISTA"
              subtitle={'Editando perfil de ' + getUserFullName(editingUser)}
              submitLabel="GUARDAR"
              adminMode={true} />
          </Modal>
        );
      }

      // v66: termómetro de avisos. Un ciclista "con avisos" = tiene al menos un
      // token en pushTokens. "Móviles" = total de tokens (alguien puede tener
      // avisos en el móvil y el iPad). Es orientativo: algún token puede estar
      // muerto, pero sirve para ver cuánta gente los tiene puestos.
      const conAvisos = userList.filter(function(u) {
        return u.pushTokens && Object.keys(u.pushTokens).length > 0;
      }).length;
      const totalMoviles = userList.reduce(function(n, u) {
        return n + (u.pushTokens ? Object.keys(u.pushTokens).length : 0);
      }, 0);

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-1">
            <div className="bg-yellow-100 text-yellow-700 w-10 h-10 rounded-full flex items-center justify-center">
              <Users size={18} strokeWidth={2.5} />
            </div>
            <h2 className="display-font font-bold text-2xl tracking-wide text-red-700">CICLISTAS</h2>
          </div>
          <p className="body-font text-sm text-gray-500 mb-1">
            {userList.length} {userList.length === 1 ? 'perfil registrado' : 'perfiles registrados'}
          </p>
          <p className="body-font text-sm font-semibold text-green-700 mb-4">
            🔔 {conAvisos} con avisos activos{totalMoviles !== conAvisos ? ' (' + totalMoviles + ' móviles)' : ''}
          </p>

          <input type="text" value={search} onChange={function(e) { setSearch(e.target.value); }}
            placeholder="Buscar por nombre, club o correo…" className={inputCls + ' mb-3'} />

          {/* v28-nonoP: selector de orden (solo admin central usa este modal) */}
          <div className="flex gap-1.5 mb-3">
            <button onClick={function() { setSortMode('nombre'); }}
              className={'flex-1 body-font text-[10px] font-bold uppercase tracking-wider px-2 py-1.5 rounded-lg border transition '
                + (sortMode === 'nombre'
                  ? 'bg-red-700 text-white border-red-700'
                  : 'bg-white text-gray-600 border-gray-200 hover:border-red-300')}>
              Nombre
            </button>
            <button onClick={function() { setSortMode('club'); }}
              className={'flex-1 body-font text-[10px] font-bold uppercase tracking-wider px-2 py-1.5 rounded-lg border transition '
                + (sortMode === 'club'
                  ? 'bg-red-700 text-white border-red-700'
                  : 'bg-white text-gray-600 border-gray-200 hover:border-red-300')}>
              Club
            </button>
            <button onClick={function() { setSortMode('fecha'); }}
              className={'flex-1 body-font text-[10px] font-bold uppercase tracking-wider px-2 py-1.5 rounded-lg border transition '
                + (sortMode === 'fecha'
                  ? 'bg-red-700 text-white border-red-700'
                  : 'bg-white text-gray-600 border-gray-200 hover:border-red-300')}>
              Fecha alta
            </button>
          </div>

          <div className="space-y-2 max-h-[55vh] overflow-y-auto scrollbar-hide -mx-1 px-1">
            {filtered.length === 0 ? (
              <div className="text-center py-8 text-gray-400 body-font text-sm">
                {userList.length === 0 ? 'No hay ciclistas registrados todavía' : 'Sin resultados'}
              </div>
            ) : (function() {
              // v28-nonoQ: en modo 'club', insertar un separador antes de cada cambio de club
              // v28-nonoS: añadir contador de socios por club en el encabezado
              let lastClub = null;
              const items = [];
              // Pre-calcular cuántos socios hay en cada club (sobre filtered, respeta búsqueda)
              const clubCounts = {};
              if (sortMode === 'club') {
                filtered.forEach(function(u) {
                  const c = getUserClubsDisplay(u) || 'Sin club';
                  clubCounts[c] = (clubCounts[c] || 0) + 1;
                });
              }
              filtered.forEach(function(u) {
                if (sortMode === 'club') {
                  const currentClub = getUserClubsDisplay(u) || 'Sin club';
                  if (currentClub !== lastClub) {
                    items.push(
                      <div key={'header-' + currentClub} className="pt-2 pb-1 px-1 sticky top-0 bg-white z-10">
                        <div className="body-font text-[10px] font-bold uppercase tracking-wider text-red-700 border-b border-red-200 pb-1">
                          🏁 {currentClub} ({clubCounts[currentClub] || 0})
                        </div>
                      </div>
                    );
                    lastClub = currentClub;
                  }
                }
                const display = getUserFullName(u);
                const hasAcc = !!(acceptances && acceptances[u._id]);
                const tieneAvisos = !!(u.pushTokens && Object.keys(u.pushTokens).length > 0);
                items.push(
                <React.Fragment key={u._id}>
                <div className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-xl p-2.5">
                  <UserAvatar user={u} userId={u._id} size={40} />
                  <div className="flex-1 min-w-0">
                    <div className="flex items-center gap-1.5">
                      <div className="body-font text-sm font-bold text-gray-800 truncate">{display}</div>
                      {tieneAvisos && (
                        <span title="Tiene los avisos activos en su móvil" className="flex-shrink-0 text-[10px] leading-none bg-green-100 text-green-700 border border-green-300 rounded-full px-1.5 py-0.5 font-bold">🔔</span>
                      )}
                    </div>
                    <div className="body-font text-[11px] text-gray-500 truncate">
                      {getUserClubsDisplay(u) || 'Sin club'}{u.correo ? ' · ' + u.correo : ''}
                    </div>
                    {sortMode === 'fecha' && (
                      <div className="body-font text-[10px] text-gray-400 mt-0.5">
                        📅 Alta: {u.createdAt ? new Date(u.createdAt).toLocaleDateString('es-ES') : 'Sin fecha'}
                      </div>
                    )}
                  </div>
                  <button onClick={function() { handleDownloadAcceptance(u._id, u); }}
                    disabled={!hasAcc}
                    title={hasAcc ? 'Descargar autorización aceptada' : 'Este ciclista aún no ha aceptado las normas'}
                    className={'text-[10px] display-font font-bold tracking-wider rounded-lg px-2.5 py-1.5 active:scale-95 transition flex-shrink-0 ' +
                      (hasAcc
                        ? 'bg-red-700 hover:bg-red-800 text-white border border-red-700'
                        : 'bg-gray-100 text-gray-400 border border-gray-200 cursor-not-allowed')}>
                    {hasAcc ? '📄 AUTORIZ.' : 'PEND.'}
                  </button>
                  {canEdit && isCentral && (
                    <button onClick={function() { openRolePanel(u._id); }}
                      title="Asignar rol"
                      className={'text-[10px] display-font font-bold tracking-wider rounded-lg px-2.5 py-1.5 active:scale-95 transition border ' +
                        (roleTarget === u._id ? 'bg-purple-100 border-purple-400 text-purple-800' : 'bg-white border-gray-300 text-purple-600 hover:bg-purple-50')}>
                      🔐 ROL
                    </button>
                  )}
                  {canEdit && (
                    <button onClick={function() { setEditingId(u._id); }}
                      className="text-[10px] display-font font-bold tracking-wider bg-white border border-gray-300 hover:bg-gray-100 text-gray-700 rounded-lg px-2.5 py-1.5 active:scale-95 transition">
                      EDITAR
                    </button>
                  )}
                  {canEdit && (
                    confirmDeleteId === u._id ? (
                      <button onClick={function() {
                        if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
                        setConfirmDeleteId(null);
                        onDeleteUser(u._id);
                      }}
                        className="text-[10px] display-font font-bold tracking-wider bg-red-600 hover:bg-red-700 text-white border border-red-700 rounded-lg px-2.5 py-1.5 active:scale-95 transition flex-shrink-0 animate-pulse">
                        ¿SEGURO?
                      </button>
                    ) : (
                      <button onClick={function() { askDelete(u._id); }}
                        title={'Borrar el perfil de ' + display}
                        className="w-8 h-8 rounded-lg bg-white border border-gray-300 text-gray-500 hover:text-red-600 hover:border-red-300 flex items-center justify-center active:scale-90 transition flex-shrink-0">
                        <Trash2 size={14} />
                      </button>
                    )
                  )}
                </div>
                {isCentral && roleTarget === u._id && (
                  <div className="mt-2 bg-purple-50 border border-purple-200 rounded-xl p-3">
                    <div className="text-[10px] font-bold display-font tracking-widest text-purple-700 mb-2">ROL DE {display.toUpperCase()}</div>
                    {roleLoading ? (
                      <p className="text-xs text-purple-500 mb-2">Cargando rol actual...</p>
                    ) : null}
                    <div className="flex gap-2 mb-3">
                      {['user','admin','superadmin'].map(function(r) {
                        var active = 'border-purple-500 bg-purple-100 text-purple-900';
                        var inactive = 'border-gray-200 bg-white text-gray-500';
                        return (
                          <button key={r}
                            onClick={function() { setRoleSelected(r); setAdminOfSelected([]); setRoleMsg(''); }}
                            className={'flex-1 py-1.5 rounded-lg text-[11px] font-bold border-2 transition ' + (roleSelected === r ? active : inactive)}>
                            {r}
                          </button>
                        );
                      })}
                    </div>
                    {roleSelected === 'admin' && (
                      <div className="mb-3">
                        <div className="text-[10px] font-bold display-font tracking-widest text-purple-700 mb-1.5">GRUPETAS</div>
                        <div className="space-y-1 max-h-40 overflow-y-auto">
                          {Object.values(grupetas || {}).filter(function(g){ return g && g.id; }).sort(function(a,b){ return (a.name||'').localeCompare(b.name||''); }).map(function(g) {
                            var sel = adminOfSelected.indexOf(g.id) !== -1;
                            return (
                              <label key={g.id} className={'flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer ' + (sel ? 'bg-purple-100' : 'bg-white')}>
                                <input type="checkbox" checked={sel}
                                  onChange={function() {
                                    setAdminOfSelected(function(prev) {
                                      return sel ? prev.filter(function(x){ return x !== g.id; }) : prev.concat(g.id);
                                    });
                                  }}
                                  className="accent-purple-600" />
                                <span className="text-xs font-medium text-gray-800">{g.name || g.shortName || g.id}</span>
                              </label>
                            );
                          })}
                        </div>
                        {adminOfSelected.length === 0 && <p className="text-[10px] text-amber-600 mt-1">⚠️ Selecciona al menos una grupeta</p>}
                      </div>
                    )}
                    <button
                      onClick={function() {
                        if (roleSelected === 'admin' && adminOfSelected.length === 0) { setRoleMsg('⚠️ Selecciona al menos una grupeta'); return; }
                        setRoleBusy(true); setRoleMsg('');
                        var fbUser = firebase.auth().currentUser;
                        if (!fbUser) { setRoleMsg('No autenticado'); setRoleBusy(false); return; }
                        fbUser.getIdToken(true).then(function(tok) {
                          return fetch('/.netlify/functions/set-role', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({ callerToken: tok, targetUid: u._id, role: roleSelected, adminOf: adminOfSelected })
                          });
                        }).then(function(res) { return res.json(); })
                          .then(function(data) {
                            if (data.ok) {
                              var msg = '✅ Rol "' + roleSelected + '" asignado';
                              if (data.adminOf && data.adminOf.length) msg += ' · Grupetas: ' + data.adminOf.join(', ');
                              setRoleMsg(msg);
                            } else { setRoleMsg('❌ ' + (data.error || 'error')); }
                            setRoleBusy(false);
                          }).catch(function(e) { setRoleMsg('❌ ' + e.message); setRoleBusy(false); });
                      }}
                      disabled={roleBusy}
                      className="w-full bg-purple-700 hover:bg-purple-600 disabled:opacity-50 text-white display-font font-bold text-xs tracking-widest py-2 rounded-xl transition">
                      {roleBusy ? 'ASIGNANDO...' : '🔐 ASIGNAR'}
                    </button>
                    {roleMsg && <p className="text-[11px] text-gray-700 mt-2">{roleMsg}</p>}
                  </div>
                )}
                </React.Fragment>
              );
              });
              return items;
            })()}
          </div>

          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn}>CERRAR</button>
            {canEdit && (
              <button onClick={function() { setShowEmail(true); }}
                className="flex-1 py-2.5 rounded-xl display-font text-sm font-bold tracking-wider bg-blue-600 hover:bg-blue-700 text-white active:scale-95 transition flex items-center justify-center gap-2">
                📧 COMUNICAR
              </button>
            )}
          </div>
          {showEmail && (
            <EmailCiclistasModal
              users={users}
              grupetas={grupetas}
              onClose={function() { setShowEmail(false); }} />
          )}
        </Modal>
      );
    }

    // ============ EMAIL CICLISTAS MODAL (v112: comunicación masiva por correo) ============
    function EmailCiclistasModal({ users, grupetas, onClose }) {
      const [filtro, setFiltro] = useState('todos'); // 'todos' | nombre_grupeta
      const [asunto, setAsunto] = useState('');
      const [mensaje, setMensaje] = useState('');
      const [generando, setGenerando] = useState(false);
      const [enviando, setEnviando] = useState(false);
      const [enviado, setEnviado] = useState(false);
      const [err, setErr] = useState('');
      const [iaPrompt, setIaPrompt] = useState('');
      const [showIaInput, setShowIaInput] = useState(false);

      // Lista de grupetas únicas (extraída de los perfiles)
      const grupetaOpciones = React.useMemo(function() {
        const set = new Set();
        Object.values(users || {}).forEach(function(u) {
          const clubs = getUserClubs(u);
          clubs.forEach(function(c) { if (c) set.add(c); });
        });
        return Array.from(set).sort();
      }, [users]);

      // Destinatarios según filtro
      const destinatarios = React.useMemo(function() {
        const list = Object.entries(users || {}).map(function(e) {
          return Object.assign({ _id: e[0] }, e[1]);
        });
        if (filtro === 'todos') return list.filter(function(u) { return u.correo; });
        return list.filter(function(u) {
          const clubs = getUserClubs(u);
          return u.correo && clubs.some(function(c) {
            return c.toLowerCase() === filtro.toLowerCase();
          });
        });
      }, [users, filtro]);

      async function generarConIA() {
        if (!iaPrompt.trim()) { setErr('Escribe una idea para el mensaje'); return; }
        setGenerando(true); setErr('');
        try {
          const grupetaNombre = filtro === 'todos' ? 'todos los ciclistas de Grupetas' : ('los ciclistas de ' + filtro);
          const resp = await fetch('https://api.anthropic.com/v1/messages', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              model: 'claude-sonnet-4-20250514',
              max_tokens: 1000,
              messages: [{
                role: 'user',
                content: 'Eres el asistente de comunicación de Grupetas, una app de ciclismo de Cartagena (España). '
                  + 'Escribe un email motivador y cercano dirigido a ' + grupetaNombre + '. '
                  + 'El tono debe ser entusiasta pero natural, como de compañero ciclista. '
                  + 'La idea principal es: ' + iaPrompt + '. '
                  + 'Devuelve SOLO el cuerpo del email (sin asunto, sin "Hola [nombre]", sin firma). '
                  + 'Máximo 150 palabras. En español.'
              }]
            })
          });
          const data = await resp.json();
          const texto = (data.content || []).filter(function(b) { return b.type === 'text'; }).map(function(b) { return b.text; }).join('');
          if (texto) {
            setMensaje(texto.trim());
            setShowIaInput(false);
          } else {
            setErr('No se pudo generar el mensaje. Inténtalo de nuevo.');
          }
        } catch(e) {
          setErr('Error al conectar con la IA: ' + e.message);
        }
        setGenerando(false);
      }

      async function enviar() {
        if (!asunto.trim()) { setErr('El asunto es obligatorio'); return; }
        if (!mensaje.trim()) { setErr('El mensaje es obligatorio'); return; }
        if (destinatarios.length === 0) { setErr('No hay destinatarios con correo en este grupo'); return; }
        setEnviando(true); setErr('');
        try {
          const batch = destinatarios.map(function(u) {
            const nombre = ((u.nombre || '') + ' ' + (u.apellidos || '')).trim() || 'Ciclista';
            const saludo = 'Hola ' + (u.nombre || 'ciclista') + ',\n\n';
            const firma = '\n\nUn saludo desde Grupetas 🚴\nhttps://grupetas.netlify.app';
            return fdb.collection('mail').add({
              to: u.correo,
              message: {
                subject: asunto.trim(),
                text: saludo + mensaje.trim() + firma
              }
            });
          });
          await Promise.all(batch);
          setEnviado(true);
        } catch(e) {
          setErr('Error al enviar: ' + e.message);
        }
        setEnviando(false);
      }

      if (enviado) {
        return (
          <Modal onClose={onClose}>
            <div className="text-center py-8">
              <div className="text-5xl mb-4">✅</div>
              <h3 className="display-font font-bold text-xl text-gray-800 mb-2">¡Enviado!</h3>
              <p className="body-font text-sm text-gray-500 mb-6">
                {destinatarios.length} correos en cola. Firebase los enviará en breve.
              </p>
              <button onClick={onClose} className={primaryBtn}>CERRAR</button>
            </div>
          </Modal>
        );
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-4">
            <div className="bg-blue-100 text-blue-700 w-10 h-10 rounded-full flex items-center justify-center text-lg">📧</div>
            <h2 className="display-font font-bold text-2xl tracking-wide text-red-700">COMUNICAR</h2>
          </div>

          {/* Selector destinatarios */}
          <div className="mb-4">
            <p className="body-font text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Destinatarios</p>
            <div className="flex flex-wrap gap-2">
              <button onClick={function() { setFiltro('todos'); }}
                className={'body-font text-xs font-bold px-3 py-1.5 rounded-full border transition ' +
                  (filtro === 'todos' ? 'bg-red-700 text-white border-red-700' : 'bg-white text-gray-600 border-gray-300 hover:border-red-300')}>
                Todos
              </button>
              {grupetaOpciones.map(function(g) {
                return (
                  <button key={g} onClick={function() { setFiltro(g); }}
                    className={'body-font text-xs font-bold px-3 py-1.5 rounded-full border transition ' +
                      (filtro === g ? 'bg-red-700 text-white border-red-700' : 'bg-white text-gray-600 border-gray-300 hover:border-red-300')}>
                    {g}
                  </button>
                );
              })}
            </div>
            <p className="body-font text-xs text-gray-400 mt-2">
              📬 {destinatarios.length} ciclistas con correo en este grupo
            </p>
          </div>

          {/* Asunto */}
          <div className="mb-3">
            <p className="body-font text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Asunto</p>
            <input type="text" value={asunto} onChange={function(e) { setAsunto(e.target.value); }}
              placeholder="Ej: ¡Os esperamos este sábado!" className={inputCls} />
          </div>

          {/* Mensaje */}
          <div className="mb-3">
            <div className="flex items-center justify-between mb-1">
              <p className="body-font text-xs font-bold text-gray-500 uppercase tracking-wider">Mensaje</p>
              <button onClick={function() { setShowIaInput(!showIaInput); }}
                className="body-font text-xs font-bold text-purple-600 hover:text-purple-800 flex items-center gap-1 transition">
                ✨ GENERAR CON IA
              </button>
            </div>
            {showIaInput && (
              <div className="bg-purple-50 border border-purple-200 rounded-xl p-3 mb-2">
                <p className="body-font text-xs text-purple-700 mb-2">¿Sobre qué quieres escribir? (ej: "animar a venir a la marcha del sábado", "recordar que hay que renovar licencias")</p>
                <textarea value={iaPrompt} onChange={function(e) { setIaPrompt(e.target.value); }}
                  rows={2} placeholder="Escribe tu idea aquí..."
                  className="w-full border border-purple-300 rounded-lg p-2 text-sm body-font resize-none focus:outline-none focus:border-purple-500" />
                <button onClick={generarConIA} disabled={generando}
                  className={'mt-2 w-full py-2 rounded-lg body-font text-sm font-bold transition ' +
                    (generando ? 'bg-purple-200 text-purple-400 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700 text-white active:scale-95')}>
                  {generando ? '⏳ Generando...' : '✨ GENERAR MENSAJE'}
                </button>
              </div>
            )}
            <textarea value={mensaje} onChange={function(e) { setMensaje(e.target.value); }}
              rows={6} placeholder="Escribe tu mensaje aquí, o usa ✨ GENERAR CON IA para que Claude te ayude..."
              className={'w-full border border-gray-300 rounded-xl p-3 text-sm body-font resize-none focus:outline-none focus:border-red-400 ' + inputCls} />
            <p className="body-font text-xs text-gray-400 mt-1">El saludo ("Hola [nombre]") y la firma de Grupetas se añaden automáticamente.</p>
          </div>

          {err && <p className="body-font text-xs text-red-600 mb-3">{err}</p>}

          <div className="flex gap-2 mt-2">
            <button onClick={onClose} className={cancelBtn}>CANCELAR</button>
            <button onClick={enviar} disabled={enviando || destinatarios.length === 0}
              className={'flex-1 py-2.5 rounded-xl display-font text-sm font-bold tracking-wider transition ' +
                (enviando || destinatarios.length === 0
                  ? 'bg-gray-200 text-gray-400 cursor-not-allowed'
                  : 'bg-red-700 hover:bg-red-800 text-white active:scale-95')}>
              {enviando ? '⏳ ENVIANDO...' : ('📧 ENVIAR A ' + destinatarios.length + ' CICLISTAS')}
            </button>
          </div>
        </Modal>
      );
    }

    // ============ CYCLISTS LIST MODAL (read-only viewer for admins from home) ============
    function CyclistsListModal({ users, grupetas, viewerIsAdmin, onViewProfile, onClose }) {
      const [search, setSearch] = useState('');

      // v28-nonoR: mostrar todos los perfiles registrados (mismo criterio que el contador de portada).
      const userList = Object.entries(users || {}).map(function(entry) {
        return Object.assign({ _id: entry[0] }, entry[1]);
      }).sort(function(a, b) {
        const na = ((a.nombre || '') + ' ' + (a.apellidos || '')).toLowerCase();
        const nb = ((b.nombre || '') + ' ' + (b.apellidos || '')).toLowerCase();
        return na.localeCompare(nb);
      });

      const filtered = search.trim() ? userList.filter(function(u) {
        const q = search.toLowerCase();
        return ((u.nombre || '').toLowerCase().indexOf(q) !== -1)
          || ((u.apellidos || '').toLowerCase().indexOf(q) !== -1)
          || (getUserClubsDisplay(u).toLowerCase().indexOf(q) !== -1)
          || ((u.correo || '').toLowerCase().indexOf(q) !== -1)
          || ((u.telefono || '').toLowerCase().indexOf(q) !== -1);
      }) : userList;

      // Map grupeta name → color (for the small club tag)
      const grupetaByName = {};
      Object.values(grupetas || {}).forEach(function(g) {
        if (g.shortName) grupetaByName[g.shortName] = g;
        if (g.name) grupetaByName[g.name] = g;
      });

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-1">
            <div className="bg-yellow-100 text-yellow-700 w-10 h-10 rounded-full flex items-center justify-center">
              <Users size={18} strokeWidth={2.5} />
            </div>
            <h2 className="display-font font-bold text-2xl tracking-wide text-red-700">CICLISTAS</h2>
          </div>
          <p className="body-font text-sm text-gray-500 mb-4">
            {userList.length} {userList.length === 1 ? 'perfil registrado' : 'perfiles registrados'}
          </p>

          <input type="text" value={search} onChange={function(e) { setSearch(e.target.value); }}
            placeholder="Buscar por nombre, club, correo o teléfono…" className={inputCls + ' mb-3'} />

          <div className="space-y-2 max-h-[60vh] overflow-y-auto scrollbar-hide -mx-1 px-1">
            {filtered.length === 0 ? (
              <div className="text-center py-8 text-gray-400 body-font text-sm">
                {userList.length === 0 ? 'No hay ciclistas registrados todavía' : 'Sin resultados'}
              </div>
            ) : filtered.map(function(u) {
              const display = getUserDisplay(u);
              const card = (
                <div className="flex items-center gap-2.5">
                  <UserAvatar user={u} userId={u._id} size={44} />
                  <div className="flex-1 min-w-0">
                    <div className="body-font text-sm font-bold text-gray-800 truncate">{display}</div>
                  </div>
                  {viewerIsAdmin && <ChevronRight size={16} className="text-gray-400 flex-shrink-0" />}
                </div>
              );
              if (viewerIsAdmin && onViewProfile) {
                return (
                  <button key={u._id} onClick={function() { onViewProfile(u._id); }}
                    className="w-full text-left bg-gray-50 hover:bg-red-50 active:scale-[0.99] transition border border-gray-200 hover:border-red-200 rounded-xl p-2.5">
                    {card}
                  </button>
                );
              }
              return (
                <div key={u._id} className="bg-gray-50 border border-gray-200 rounded-xl p-2.5">
                  {card}
                </div>
              );
            })}
          </div>

          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn + ' w-full'}>CERRAR</button>
          </div>
        </Modal>
      );
    }

    // ============ BIENVENIDA CONFIG MODAL (v112: email automático editable) ============
    function BienvenidaConfigModal({ onClose }) {
      const [asunto, setAsunto] = useState('');
      const [mensaje, setMensaje] = useState('');
      const [loading, setLoading] = useState(true);
      const [guardando, setGuardando] = useState(false);
      const [guardado, setGuardado] = useState(false);
      const [generando, setGenerando] = useState(false);
      const [iaPrompt, setIaPrompt] = useState('');
      const [showIaInput, setShowIaInput] = useState(false);
      const [err, setErr] = useState('');

      useEffect(function() {
        fdb.collection('config').doc('bienvenida').get().then(function(snap) {
          if (snap.exists) {
            var d = snap.data();
            setAsunto(d.asunto || '');
            setMensaje(d.mensaje || '');
          }
          setLoading(false);
        }).catch(function() { setLoading(false); });
      }, []);

      async function guardar() {
        if (!asunto.trim()) { setErr('El asunto es obligatorio'); return; }
        if (!mensaje.trim()) { setErr('El mensaje es obligatorio'); return; }
        setGuardando(true); setErr('');
        try {
          await fdb.collection('config').doc('bienvenida').set({
            asunto: asunto.trim(),
            mensaje: mensaje.trim(),
            updatedAt: new Date().toISOString()
          });
          setGuardado(true);
          setTimeout(function() { setGuardado(false); }, 3000);
        } catch(e) {
          setErr('Error al guardar: ' + e.message);
        }
        setGuardando(false);
      }

      async function generarConIA() {
        if (!iaPrompt.trim()) { setErr('Escribe una idea'); return; }
        setGenerando(true); setErr('');
        try {
          const prompt = 'Eres el asistente de comunicación de Grupetas.com, app de ciclismo de Cartagena (España). '
            + 'Escribe un email de bienvenida motivador y cercano para nuevos ciclistas que acaban de registrarse. '
            + 'Tono entusiasta pero natural, como de compañero ciclista. '
            + 'La idea principal es: ' + iaPrompt + '. '
            + 'El saludo ("Hola [nombre]") y la firma se añaden automáticamente, NO los incluyas. '
            + 'Devuelve SOLO el cuerpo del email. Máximo 200 palabras. En español.';
          const resp = await fetch('/.netlify/functions/generate-text', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ prompt: prompt })
          });
          const data = await resp.json();
          if (resp.ok && data.text) { setMensaje(data.text.trim()); setShowIaInput(false); }
          else { setErr(data.error || 'No se pudo generar. Inténtalo de nuevo.'); }
        } catch(e) { setErr('Error IA: ' + e.message); }
        setGenerando(false);
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-4">
            <div className="bg-green-100 text-green-700 w-10 h-10 rounded-full flex items-center justify-center text-lg">✉️</div>
            <div>
              <h2 className="display-font font-bold text-2xl tracking-wide text-red-700">EMAIL BIENVENIDA</h2>
              <p className="body-font text-xs text-gray-500">Se envía automáticamente a cada nuevo ciclista al registrarse</p>
            </div>
          </div>

          {loading ? (
            <div className="text-center py-8 text-gray-400 body-font text-sm">Cargando...</div>
          ) : (
            <>
              <div className="mb-3">
                <p className="body-font text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Asunto</p>
                <input type="text" value={asunto} onChange={function(e) { setAsunto(e.target.value); }}
                  placeholder="Ej: ¡Bienvenido/a a Grupetas! 🚴" className={inputCls} />
              </div>

              <div className="mb-3">
                <div className="flex items-center justify-between mb-1">
                  <p className="body-font text-xs font-bold text-gray-500 uppercase tracking-wider">Mensaje</p>
                  <button onClick={function() { setShowIaInput(!showIaInput); }}
                    className="body-font text-xs font-bold text-purple-600 hover:text-purple-800 transition">
                    ✨ GENERAR CON IA
                  </button>
                </div>
                {showIaInput && (
                  <div className="bg-purple-50 border border-purple-200 rounded-xl p-3 mb-2">
                    <p className="body-font text-xs text-purple-700 mb-2">¿Qué quieres transmitir al nuevo ciclista?</p>
                    <textarea value={iaPrompt} onChange={function(e) { setIaPrompt(e.target.value); }}
                      rows={2} placeholder="Ej: darle la bienvenida, explicarle cómo funciona la app y animarle a apuntarse a su primera marcha"
                      className="w-full border border-purple-300 rounded-lg p-2 text-sm body-font resize-none focus:outline-none focus:border-purple-500" />
                    <button onClick={generarConIA} disabled={generando}
                      className={'mt-2 w-full py-2 rounded-lg body-font text-sm font-bold transition ' +
                        (generando ? 'bg-purple-200 text-purple-400 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700 text-white active:scale-95')}>
                      {generando ? '⏳ Generando...' : '✨ GENERAR MENSAJE'}
                    </button>
                  </div>
                )}
                <textarea value={mensaje} onChange={function(e) { setMensaje(e.target.value); }}
                  rows={8} placeholder="Escribe aquí el cuerpo del email de bienvenida..."
                  className="w-full border border-gray-300 rounded-xl p-3 text-sm body-font resize-none focus:outline-none focus:border-red-400" />
                <p className="body-font text-xs text-gray-400 mt-1">
                  Se añade automáticamente: <em>"Hola [nombre],"</em> al inicio y la firma de Grupetas al final.
                </p>
              </div>

              {err && <p className="body-font text-xs text-red-600 mb-3">{err}</p>}
              {guardado && <p className="body-font text-xs text-green-600 mb-3">✅ Guardado correctamente</p>}

              <div className="flex gap-2 mt-2">
                <button onClick={onClose} className={cancelBtn}>CERRAR</button>
                <button onClick={function() {
                    if (!asunto.trim() || !mensaje.trim()) { setErr('Rellena asunto y mensaje antes de probar'); return; }
                    var saludo = 'Hola Javier,\n\n';
                    var firma = '\n\nUn saludo desde Grupetas 🚴\nhttps://grupetas.com';
                    fdb.collection('mail').add({
                      to: 'jmartinezcarrion@gmail.com',
                      message: { subject: '[PRUEBA] ' + asunto.trim(), text: saludo + mensaje.trim() + firma }
                    }).then(function() { setGuardado(true); setTimeout(function() { setGuardado(false); }, 3000); })
                    .catch(function(e) { setErr('Error: ' + e.message); });
                  }}
                  className="py-2.5 px-3 rounded-xl display-font text-xs font-bold tracking-wider bg-orange-500 hover:bg-orange-600 text-white active:scale-95 transition whitespace-nowrap">
                  🧪 PROBAR
                </button>
                <button onClick={guardar} disabled={guardando}
                  className={'flex-1 py-2.5 rounded-xl display-font text-sm font-bold tracking-wider transition ' +
                    (guardando ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700 text-white active:scale-95')}>
                  {guardando ? '⏳ GUARDANDO...' : '💾 GUARDAR'}
                </button>
              </div>
            </>
          )}
        </Modal>
      );
    }

    // ============ ENVIAR EMAIL (v260): admin central manda un correo puntual ============
    // A "Todos" los ciclistas con correo, o a los socios de una grupeta concreta.
    // Reusa el mismo mecanismo que el resto de la app: fdb.collection('mail').add(...)
    // (extensión Firestore Send Email de Firebase). `to` puede ser array de correos.
    function EnviarEmailModal({ onClose, allUsers, grupetas, currentUser }) {
      const [destino, setDestino] = useState('todos'); // 'todos' | grupetaId | 'personas'
      const [asunto, setAsunto] = useState('');
      const [mensaje, setMensaje] = useState('');
      const [enviando, setEnviando] = useState(false);
      const [enviado, setEnviado] = useState(false);
      const [err, setErr] = useState('');
      // v275: selección de personas concretas, independiente de "todos"/grupeta.
      const [personSearch, setPersonSearch] = useState('');
      const [selectedUids, setSelectedUids] = useState(function() { return new Set(); });
      const miCorreo = (currentUser && currentUser.correo) || ADMIN_NOTIFICATION_EMAIL;

      function toggleUid(uid) {
        setSelectedUids(function(prev) {
          const next = new Set(prev);
          if (next.has(uid)) { next.delete(uid); } else { next.add(uid); }
          return next;
        });
      }

      const grupetasList = Object.values(grupetas || {}).sort(function(a, b) {
        return (a.displayName || a.name || '').localeCompare(b.displayName || b.name || '');
      });

      // Lista de personas con correo, para el buscador de "Personas concretas".
      const personasList = Object.entries(allUsers || {})
        .filter(function(e) { return (e[1].correo || '').trim(); })
        .sort(function(a, b) {
          const na = ((a[1].nombre || '') + ' ' + (a[1].apellidos || '')).trim();
          const nb = ((b[1].nombre || '') + ' ' + (b[1].apellidos || '')).trim();
          return na.localeCompare(nb);
        });
      const personasFiltradas = personSearch.trim()
        ? personasList.filter(function(e) {
            const nombreCompleto = ((e[1].nombre || '') + ' ' + (e[1].apellidos || '')).toLowerCase();
            return nombreCompleto.indexOf(personSearch.trim().toLowerCase()) !== -1;
          })
        : personasList;

      // Lista de correos según el destino elegido. Para 'todos'/grupeta, filtra
      // a quien haya desactivado "recibir noticias" en su perfil (v262). Para
      // 'personas' NO se aplica ese filtro: es un envío dirigido a gente concreta
      // elegida a mano por el admin, no una newsletter masiva.
      function destinatarios() {
        if (destino === 'personas') {
          const filtered = Object.entries(allUsers || {})
            .filter(function(e) { return selectedUids.has(e[0]); })
            .map(function(e) { return e[1]; });
          const correosP = filtered.map(function(u) { return (u.correo || '').trim(); }).filter(Boolean);
          return Array.from(new Set(correosP));
        }
        const users = Object.values(allUsers || {}).filter(function(u) { return u.recibirNoticias !== false; });
        let filtered;
        if (destino === 'todos') {
          filtered = users;
        } else {
          const g = grupetas && grupetas[destino];
          const aliases = g ? [g.name, g.shortName].filter(Boolean) : [];
          filtered = users.filter(function(u) { return userHasAnyClub(u, aliases); });
        }
        const correos = filtered.map(function(u) { return (u.correo || '').trim(); }).filter(Boolean);
        return Array.from(new Set(correos));
      }

      const destCount = destinatarios().length;

      function enviar(prueba) {
        if (!asunto.trim()) { setErr('El asunto es obligatorio'); return; }
        if (!mensaje.trim()) { setErr('El mensaje es obligatorio'); return; }
        const lista = prueba ? [miCorreo] : destinatarios();
        if (!prueba && lista.length === 0) { setErr('No hay correos para ese destino'); return; }
        setEnviando(true); setErr('');
        const firma = '\n\nUn saludo desde Grupetas 🚴\nhttps://grupetas.com';
        // v260b: en envíos masivos usamos BCC, así nadie ve la lista de destinatarios.
        // En la prueba (un solo correo, a mí mismo) no hace falta ocultar nada → to normal.
        const doc = prueba
          ? { to: lista, message: { subject: '[PRUEBA] ' + asunto.trim(), text: mensaje.trim() + firma } }
          : { to: ADMIN_NOTIFICATION_EMAIL, bcc: lista, message: { subject: asunto.trim(), text: mensaje.trim() + firma } };
        fdb.collection('mail').add(doc).then(function() {
          setEnviado(true);
          setTimeout(function() { setEnviado(false); }, 3000);
        }).catch(function(e) { setErr('Error: ' + e.message); })
          .finally(function() { setEnviando(false); });
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-4">
            <div className="bg-indigo-100 text-indigo-700 w-10 h-10 rounded-full flex items-center justify-center text-lg">📨</div>
            <div>
              <h2 className="display-font font-bold text-2xl tracking-wide text-red-700">ENVIAR EMAIL</h2>
              <p className="body-font text-xs text-gray-500">Correo puntual a todos los ciclistas o a una grupeta</p>
            </div>
          </div>

          <div className="mb-3">
            <p className="body-font text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Destinatario</p>
            <select value={destino} onChange={function(e) { setDestino(e.target.value); }} className={inputCls}>
              <option value="todos">📢 Todos los ciclistas</option>
              <option value="personas">👤 Personas concretas</option>
              {grupetasList.map(function(g) {
                return <option key={g.id} value={g.id}>🚴 {g.displayName || g.name}</option>;
              })}
            </select>
            {destino === 'personas' && (
              <div className="mt-2 border border-gray-200 rounded-xl p-2">
                <input type="text" value={personSearch} onChange={function(e) { setPersonSearch(e.target.value); }}
                  placeholder="Buscar por nombre..." className={inputCls + ' mb-2'} />
                <div className="max-h-48 overflow-y-auto space-y-1">
                  {personasFiltradas.length === 0 && (
                    <p className="body-font text-xs text-gray-400 text-center py-2">Sin resultados</p>
                  )}
                  {personasFiltradas.map(function(e) {
                    const uid = e[0]; const u = e[1];
                    const nombreCompleto = ((u.nombre || '') + ' ' + (u.apellidos || '')).trim() || uid;
                    const checked = selectedUids.has(uid);
                    return (
                      <label key={uid} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-gray-50 cursor-pointer">
                        <input type="checkbox" checked={checked} onChange={function() { toggleUid(uid); }} />
                        <span className="body-font text-sm flex-1">{nombreCompleto}</span>
                        {u.recibirNoticias === false && (
                          <span className="body-font text-[10px] text-gray-400">sin noticias (se ignora aquí)</span>
                        )}
                      </label>
                    );
                  })}
                </div>
                <p className="body-font text-xs text-gray-400 mt-2">{selectedUids.size} seleccionado{selectedUids.size === 1 ? '' : 's'}</p>
              </div>
            )}
            <p className="body-font text-xs text-gray-400 mt-1">{destCount} correo{destCount === 1 ? '' : 's'} recibirá{destCount === 1 ? '' : 'n'} este email, en copia oculta (nadie ve a quién más se le ha enviado).</p>
          </div>

          <div className="mb-3">
            <p className="body-font text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Asunto</p>
            <input type="text" value={asunto} onChange={function(e) { setAsunto(e.target.value); }}
              placeholder="Ej: Cambios en la salida del domingo" className={inputCls} />
          </div>

          <div className="mb-3">
            <p className="body-font text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Mensaje</p>
            <textarea value={mensaje} onChange={function(e) { setMensaje(e.target.value); }}
              rows={8} placeholder="Escribe aquí el mensaje..."
              className="w-full border border-gray-300 rounded-xl p-3 text-sm body-font resize-none focus:outline-none focus:border-red-400" />
            <p className="body-font text-xs text-gray-400 mt-1">Se añade automáticamente la firma de Grupetas al final.</p>
          </div>

          {err && <p className="body-font text-xs text-red-600 mb-3">{err}</p>}
          {enviado && <p className="body-font text-xs text-green-600 mb-3">✅ Enviado correctamente</p>}

          <div className="flex gap-2 mt-2">
            <button onClick={onClose} className={cancelBtn}>CERRAR</button>
            <button onClick={function() { enviar(true); }} disabled={enviando}
              className="py-2.5 px-3 rounded-xl display-font text-xs font-bold tracking-wider bg-orange-500 hover:bg-orange-600 text-white active:scale-95 transition whitespace-nowrap disabled:opacity-60">
              🧪 PROBARME A MÍ
            </button>
            <button onClick={function() { enviar(false); }} disabled={enviando}
              className={'flex-1 py-2.5 rounded-xl display-font text-sm font-bold tracking-wider transition ' +
                (enviando ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700 text-white active:scale-95')}>
              {enviando ? '⏳ ENVIANDO...' : '📨 ENVIAR (' + destCount + ')'}
            </button>
          </div>
        </Modal>
      );
    }

    // ============ SIGNATURE PAD (v28-oct19: firma dibujada con el dedo) ============
    // Canvas simple. Captura trazos con ratón o dedo. Expone la firma como PNG base64
    // vía onChange(dataUrl|null). Botón "Borrar" para repetir.
    function SignaturePad({ onChange }) {
      const canvasRef = useRef(null);
      const drawing = useRef(false);
      const hasDrawn = useRef(false);
      const last = useRef({ x: 0, y: 0 });

      function getPos(e) {
        const canvas = canvasRef.current;
        const rect = canvas.getBoundingClientRect();
        const touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]);
        const clientX = touch ? touch.clientX : e.clientX;
        const clientY = touch ? touch.clientY : e.clientY;
        return {
          x: (clientX - rect.left) * (canvas.width / rect.width),
          y: (clientY - rect.top) * (canvas.height / rect.height)
        };
      }
      function start(e) {
        e.preventDefault();
        drawing.current = true;
        last.current = getPos(e);
      }
      function move(e) {
        if (!drawing.current) return;
        e.preventDefault();
        const canvas = canvasRef.current;
        const ctx = canvas.getContext('2d');
        const p = getPos(e);
        ctx.strokeStyle = '#1f2937';
        ctx.lineWidth = 2.5;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        ctx.beginPath();
        ctx.moveTo(last.current.x, last.current.y);
        ctx.lineTo(p.x, p.y);
        ctx.stroke();
        last.current = p;
        hasDrawn.current = true;
      }
      function end(e) {
        if (!drawing.current) return;
        e.preventDefault();
        drawing.current = false;
        if (hasDrawn.current && onChange) {
          try { onChange(canvasRef.current.toDataURL('image/png')); } catch (err) { onChange(null); }
        }
      }
      function clear() {
        const canvas = canvasRef.current;
        const ctx = canvas.getContext('2d');
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        hasDrawn.current = false;
        if (onChange) onChange(null);
      }

      return (
        <div>
          <div className="flex items-center justify-between mb-1">
            <p className="body-font text-[10px] uppercase tracking-wider text-gray-500 font-bold">Firma aquí con el dedo</p>
            <button type="button" onClick={clear}
              className="body-font text-[11px] font-bold text-red-700 hover:text-red-800 active:scale-95 transition">
              Borrar
            </button>
          </div>
          <canvas ref={canvasRef} width={600} height={180}
            onMouseDown={start} onMouseMove={move} onMouseUp={end} onMouseLeave={end}
            onTouchStart={start} onTouchMove={move} onTouchEnd={end}
            className="w-full h-[120px] bg-white border-2 border-dashed border-gray-300 rounded-lg touch-none"
            style={{ touchAction: 'none' }} />
        </div>
      );
    }

    // ============ RULES MODAL (acceptance before joining a ride) ============
    function RulesModal({ user, onClose, onAccept }) {
      const [c1, setC1] = useState(false);
      const [c2, setC2] = useState(false);
      const [c3, setC3] = useState(false);
      const [c4, setC4] = useState(false);
      const [signature, setSignature] = useState(null); // v28-oct19: PNG base64 de la firma
      const allChecked = c1 && c2 && c3 && c4 && !!signature;

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-2">
            <div className="bg-red-100 text-red-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">
              <Shield size={18} strokeWidth={2.5} />
            </div>
            <div className="min-w-0">
              <h2 className="display-font font-bold text-xl tracking-wide text-red-700 leading-tight">NORMAS DE LA GRUPETA</h2>
              <p className="body-font text-[11px] text-gray-500">Lee y acepta antes de apuntarte</p>
            </div>
          </div>

          <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2.5 mb-3">
            <p className="body-font text-[11px] text-yellow-800 leading-snug">
              Aceptas una vez y vale para todas las salidas. Tu aceptación queda registrada con tu nombre y fecha.
            </p>
          </div>

          <div className="border border-gray-200 rounded-lg p-3 max-h-[40vh] overflow-y-auto bg-gray-50 mb-3">
            <div className="body-font text-[11.5px] text-gray-800 space-y-3 leading-relaxed">

              <div>
                <h3 className="display-font font-bold text-sm text-red-700 mb-1.5 tracking-wide">1. EXENCIÓN DE RESPONSABILIDAD</h3>
                <p className="mb-2">Mediante mi participación en las salidas de la grupeta, declaro y acepto expresamente lo siguiente:</p>
                <ol className="list-decimal pl-5 space-y-1.5">
                  <li><strong>Participación voluntaria.</strong> Tomo parte de forma libre, voluntaria y consciente, asumiendo personalmente todos los riesgos del ciclismo en carretera (caídas, accidentes, atropellos, lesiones, daños materiales, robos, etc.).</li>
                  <li><strong>Carácter no competitivo.</strong> La grupeta es una reunión recreativa entre aficionados, no una competición ni evento federado.</li>
                  <li><strong>Sin responsabilidad de organizadores.</strong> Quienes promuevan, convoquen o coordinen las salidas no asumen responsabilidad civil, penal ni de ningún tipo por accidentes, lesiones, daños a terceros, robos, fallos mecánicos, problemas de salud o meteorología adversa.</li>
                  <li><strong>Sin responsabilidad de patrocinadores.</strong> Las marcas, tiendas o empresas que apoyen a la grupeta no son responsables de incidencias durante las salidas.</li>
                  <li><strong>Estado de salud.</strong> Declaro estar en condiciones físicas adecuadas. Es mi responsabilidad someterme a revisiones médicas si lo considero necesario.</li>
                  <li><strong>Seguro y licencia.</strong> Es responsabilidad exclusiva de cada participante disponer de licencia federativa, seguro de RC y accidentes deportivos, y documentación personal.</li>
                  <li><strong>Material y bicicleta.</strong> Cada ciclista es responsable del estado y mantenimiento de su bicicleta y equipamiento.</li>
                  <li><strong>Cumplimiento normativo.</strong> Asumo respetar las normas de tráfico. Las sanciones administrativas son del infractor.</li>
                  <li><strong>Menores de edad.</strong> Su participación requiere autorización de padres o tutores legales, quienes asumen toda responsabilidad.</li>
                  <li><strong>Imagen.</strong> Acepto que se tomen fotos/vídeos con fines no comerciales (RRSS del grupo). Si no estoy de acuerdo, lo comunicaré expresamente.</li>
                </ol>
              </div>

              <div>
                <h3 className="display-font font-bold text-sm text-red-700 mb-1.5 tracking-wide">2. NORMAS DE CIRCULACIÓN</h3>
                <p className="mb-2">Cumplir el Reglamento General de Circulación y, además:</p>

                <p className="font-bold mt-2">Equipamiento obligatorio</p>
                <ul className="list-disc pl-5 space-y-1">
                  <li>Casco homologado correctamente abrochado durante toda la salida.</li>
                  <li>Luces delantera (blanca) y trasera (roja) con poca visibilidad, túneles, niebla o salidas matinales/nocturnas.</li>
                  <li>Chaleco o prenda reflectante en baja visibilidad.</li>
                  <li>Bicicleta en perfecto estado (frenos, transmisión, neumáticos, dirección).</li>
                </ul>

                <p className="font-bold mt-2">Formación y posición en la calzada</p>
                <ul className="list-disc pl-5 space-y-1">
                  <li>Grupo compacto y ordenado, columna de a dos máximo, arrimados al borde derecho.</li>
                  <li>En tramos peligrosos, curvas, vías estrechas, túneles o tráfico denso: fila india.</li>
                  <li>Distancia de seguridad. Nunca por la izquierda del compañero ni invadiendo el carril contrario.</li>
                  <li>En rotondas y cruces: cruzar compacto sin partir el grupo cuando sea seguro.</li>
                </ul>

                <p className="font-bold mt-2">Señales y comunicación</p>
                <ul className="list-disc pl-5 space-y-1">
                  <li>Señales con la mano para giros, frenadas, obstáculos, coches o cualquier incidencia.</li>
                  <li>Voz alta: "¡Coche!", "¡Hoyo!", "¡Cristal!", "¡Arena!", "¡Stop!", "¡Frena!", "¡Gente!".</li>
                  <li>Las señales se transmiten de delante hacia atrás, repitiéndose por toda la fila.</li>
                </ul>

                <p className="font-bold mt-2">Prohibido durante la marcha</p>
                <ul className="list-disc pl-5 space-y-1">
                  <li>Auriculares, móvil o cualquier dispositivo que distraiga.</li>
                  <li>Giros, frenadas o cambios de trayectoria bruscos sin avisar.</li>
                  <li>Adelantar al ciclista de cabeza sin acuerdo previo.</li>
                  <li>Circular sin sujetar el manillar al menos con una mano.</li>
                </ul>

                <p className="font-bold mt-2">Semáforos y señales</p>
                <ul className="list-disc pl-5 space-y-1">
                  <li>Respetar siempre semáforos, stops, cedas y demás señalización.</li>
                  <li>Si el grupo queda partido, los de delante esperarán en lugar seguro.</li>
                </ul>
              </div>

              <div>
                <h3 className="display-font font-bold text-sm text-red-700 mb-1.5 tracking-wide">3. CONDUCTAS DEL CICLISTA</h3>

                <p className="font-bold mt-2">Antes de la salida</p>
                <ul className="list-disc pl-5 space-y-1">
                  <li>Puntualidad. Quien llegue tarde, alcanza al grupo por su cuenta.</li>
                  <li>Bicicleta revisada, ruedas hinchadas, frenos en buen estado.</li>
                  <li>Material básico: cámara, bomba/bombín, desmontables, multiherramienta, eslabón rápido.</li>
                  <li>Avituallamiento: agua suficiente, barritas/geles según duración.</li>
                  <li>Móvil con batería, dinero, DNI y tarjeta sanitaria.</li>
                  <li>Conocer la ruta, distancia, desnivel y ritmo aproximado.</li>
                </ul>

                <p className="font-bold mt-2">Durante la marcha</p>
                <ul className="list-disc pl-5 space-y-1">
                  <li>Respetar el ritmo acordado. La grupeta no es competición ni intervalos.</li>
                  <li>No dejar a nadie atrás: reagrupación en cima de puertos, cruces y finales rápidos.</li>
                  <li>Ayudar al compañero con problema mecánico, pájara o caída. Nadie vuelve solo a casa salvo acuerdo.</li>
                  <li>Comunicar abandono o desvío de la ruta.</li>
                  <li>Relevos suaves y ordenados, sin tirones ni acelerones.</li>
                  <li>No protagonismos. Apretar solo en tramos pactados (puerto, repecho, sprint) y reagrupar.</li>
                </ul>

                <p className="font-bold mt-2">Convivencia y respeto</p>
                <ul className="list-disc pl-5 space-y-1">
                  <li>Trato cordial con todos. Cero insultos ni actitudes agresivas.</li>
                  <li>Respeto a peatones, otros ciclistas, conductores, animales y entorno natural.</li>
                  <li>No tirar basura. Lo que entra al bolsillo, sale en el bolsillo.</li>
                  <li>No realizar necesidades en lugares visibles ni propiedades privadas.</li>
                  <li>Aceptar al ciclista más lento y al más fuerte por igual.</li>
                </ul>

                <p className="font-bold mt-2">Nuevos miembros</p>
                <ul className="list-disc pl-5 space-y-1">
                  <li>Informados de las normas antes de su primera salida.</li>
                  <li>En posiciones seguras del grupo durante las primeras salidas.</li>
                  <li>Paciencia mutua: veteranos con nuevos y nuevos con la dinámica.</li>
                </ul>

                <p className="font-bold mt-2">Causas de exclusión</p>
                <ul className="list-disc pl-5 space-y-1">
                  <li>Conductas peligrosas que pongan en riesgo al resto.</li>
                  <li>Falta de respeto reiterada a compañeros o terceros.</li>
                  <li>Incumplimiento grave o reiterado de las normas de circulación.</li>
                  <li>Actitudes que dañen la convivencia o el espíritu del grupo.</li>
                </ul>
              </div>

            </div>
          </div>

          <div className="space-y-2 mb-3">
            <label className="flex items-start gap-2 cursor-pointer">
              <input type="checkbox" checked={c1} onChange={function(e) { setC1(e.target.checked); }}
                className="mt-0.5 w-4 h-4 accent-red-700 cursor-pointer flex-shrink-0" />
              <span className="body-font text-[12px] text-gray-700 leading-snug">
                He <strong>leído y comprendido</strong> en su totalidad las normas de la grupeta.
              </span>
            </label>
            <label className="flex items-start gap-2 cursor-pointer">
              <input type="checkbox" checked={c2} onChange={function(e) { setC2(e.target.checked); }}
                className="mt-0.5 w-4 h-4 accent-red-700 cursor-pointer flex-shrink-0" />
              <span className="body-font text-[12px] text-gray-700 leading-snug">
                <strong>Acepto y asumo</strong> las cláusulas de exención de responsabilidad, eximiendo a organizadores y patrocinadores de cualquier responsabilidad por accidentes, daños, lesiones o incidencias.
              </span>
            </label>
            <label className="flex items-start gap-2 cursor-pointer">
              <input type="checkbox" checked={c3} onChange={function(e) { setC3(e.target.checked); }}
                className="mt-0.5 w-4 h-4 accent-red-700 cursor-pointer flex-shrink-0" />
              <span className="body-font text-[12px] text-gray-700 leading-snug">
                Me <strong>comprometo a cumplir</strong> las normas de circulación y las conductas exigidas durante todas las salidas.
              </span>
            </label>
            <label className="flex items-start gap-2 cursor-pointer">
              <input type="checkbox" checked={c4} onChange={function(e) { setC4(e.target.checked); }}
                className="mt-0.5 w-4 h-4 accent-red-700 cursor-pointer flex-shrink-0" />
              <span className="body-font text-[12px] text-gray-700 leading-snug">
                Declaro encontrarme en <strong>condiciones físicas adecuadas</strong> y participar de forma libre y voluntaria.
              </span>
            </label>
          </div>

          {user && (
            <div className="bg-red-50 border border-red-200 rounded-lg p-2.5 mb-3">
              <p className="body-font text-[10px] uppercase tracking-wider text-gray-500 font-bold">Aceptando como</p>
              <p className="body-font text-sm font-bold text-red-700 truncate">{getUserFullName(user)}</p>
            </div>
          )}

          {/* v28-oct19: firma obligatoria con el dedo */}
          <div className="border border-gray-200 rounded-lg p-2.5 mb-3 bg-gray-50">
            <SignaturePad onChange={setSignature} />
            {!signature && (
              <p className="body-font text-[10.5px] text-gray-500 mt-1.5 leading-snug">
                Tu firma es obligatoria para aceptar las normas. Quedará registrada con tu nombre y fecha.
              </p>
            )}
          </div>

          <div className="flex gap-2">
            <button onClick={onClose} className={cancelBtn}>CANCELAR</button>
            <button onClick={function() { onAccept(signature); }} disabled={!allChecked}
              className={primaryBtn + ' disabled:opacity-50 disabled:cursor-not-allowed'}>
              ACEPTO
            </button>
          </div>
        </Modal>
      );
    }

    // ============ WHATSAPP HELPER (v24) ============
    // En móvil, wa.me/?text= corrompe emojis suplementarios (📍🚴⏰📅 etc).
    // Solución: abrir directamente whatsapp:// que pasa el texto a la app nativa
    // sin pasar por la página intermedia que destroza la codificación UTF-8.
    // En escritorio, mantener wa.me (WhatsApp Web sí los renderiza bien).
    function isMobileDevice() {
      if (typeof navigator === 'undefined') return false;
      const ua = navigator.userAgent || '';
      return /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
    }
    function openWhatsAppShare(msg) {
      const encoded = encodeURIComponent(msg);
      if (isMobileDevice()) {
        // whatsapp:// preserva los emojis al pasarlos a la app nativa
        window.location.href = 'whatsapp://send?text=' + encoded;
      } else {
        // Escritorio: WhatsApp Web vía wa.me funciona bien
        window.open('https://wa.me/?text=' + encoded, '_blank');
      }
    }

    // ============ SHARE RIDE MODAL (after proposing) ============
    function buildShareText(ride, dateKey, grupeta, shareUrl) {
      const parts = dateKey.split('-');
      const day = parseInt(parts[2]);
      const monthName = MONTHS[parseInt(parts[1]) - 1].toLowerCase();
      const dateStr = day + ' de ' + monthName;
      // v28-octo: shareUrl es la URL profunda a la salida. Si no se pasa, usamos APP_URL.
      const url = shareUrl || APP_URL;

      // Etiquetas textuales delante de cada dato para que el mensaje siga siendo
      // legible si el cliente del receptor no renderiza algún emoji.
      let m = '🚴 *¡NUEVA SALIDA PROPUESTA!*\n';
      if (url) m += '🔗 ' + url + '\n';
      m += '\n';
      m += '📍 Grupeta: ' + (grupeta.shortName || grupeta.name) + '\n';
      m += '🛣 Ruta: ' + ride.route + '\n';
      if (ride.bikeType && BIKE_TYPES[ride.bikeType]) {
        const bt = BIKE_TYPES[ride.bikeType];
        m += bt.emoji + ' Tipo: *' + bt.label + '*\n';
      }
      m += '📅 Fecha: ' + dateStr + '\n';
      m += '⏰ Hora: ' + ride.time + '\n';
      if (ride.meetingPoint) m += '🚩 Salida: ' + ride.meetingPoint + '\n';
      if (ride.distance) m += '📏 Distancia: ' + ride.distance + '\n';
      if (ride.difficulty && DIFFICULTIES[ride.difficulty]) {
        m += '⚡ Dificultad: ' + ride.difficulty + ' pts (' + DIFFICULTIES[ride.difficulty].label + ')\n';
      }
      if (ride.notes) m += '\n📝 Notas: ' + ride.notes + '\n';
      if (ride.supportDriver || ride.supportDriverPhone) {
        const sd = (ride.supportDriver || '').trim();
        const sp = (ride.supportDriverPhone || '').trim();
        m += '🚗 Coche escoba: ' + (sd || 'sí') + (sp ? ' · ' + sp : '') + '\n';
      }

      m += '\n━━━ RECORDATORIO ━━━\n';
      m += '• 🪖 Casco siempre. Luces con poca visibilidad.\n';
      m += '• 👥 Grupo compacto, columna de 2 máx, fila india en zonas peligrosas.\n';
      m += '• 📣 Avisos a viva voz: ¡coche!, ¡hoyo!, ¡frena!\n';
      m += '• 🚦 Respetar semáforos, stops y señales.\n';
      m += '• 🚫 Sin móvil ni auriculares en marcha.\n';
      m += '• 🤝 Ayudar al compañero. Nadie vuelve solo a casa.\n';
      m += '• 🎯 Ritmo acordado, sin protagonismos.\n';
      m += '• 🔧 Bici revisada, agua y avituallamiento.\n';

      m += '\n💪 ESPÍRITU GRUPETA\n';
      m += 'Esto no es competición. Pedaleamos juntos para disfrutar, ayudarnos y volver todos a casa. Aceptamos al más lento y al más fuerte por igual.\n\n';
      m += '¡Apúntate y nos vemos en la salida!';
      return m;
    }

    // Mensaje de RECORDATORIO unos días antes de la salida. Más conciso que buildShareText
    // (no incluye lista de normas) y añade los apuntados actuales (máx 10).
    function buildReminderText(ride, dateKey, grupeta, allUsers) {
      const parts = dateKey.split('-');
      const day = parseInt(parts[2], 10);
      const dObj = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, day);
      // getDay() devuelve 0=Domingo, 1=Lunes, ..., 6=Sábado (NO usar el array DAYS aquí,
      // que está en orden europeo lunes-primero para las cabeceras del calendario).
      const DAYS_FULL = ['Domingo','Lunes','Martes','Miércoles','Jueves','Viernes','Sábado'];
      const dayName = DAYS_FULL[dObj.getDay()] || '';
      const monthName = MONTHS[parseInt(parts[1], 10) - 1].toLowerCase();
      const dateStr = (dayName ? dayName + ' ' : '') + day + ' de ' + monthName;

      let m = '🚴 *RECORDATORIO DE SALIDA*\n';
      m += '*' + (grupeta.shortName || grupeta.name) + '*\n\n';
      // Etiquetas textuales delante de cada dato para que el mensaje siga siendo
      // legible si el cliente del receptor no renderiza algún emoji.
      m += '📅 Fecha: ' + dateStr + '\n';
      m += '⏰ Hora: ' + ride.time + '\n';
      m += '🛣 Ruta: ' + ride.route + '\n';
      if (ride.meetingPoint) m += '🚩 Salida: ' + ride.meetingPoint + '\n';
      if (ride.distance) m += '📏 Distancia: ' + ride.distance + '\n';
      if (ride.difficulty && DIFFICULTIES[ride.difficulty]) {
        m += '⚡ Dificultad: ' + ride.difficulty + ' pts (' + DIFFICULTIES[ride.difficulty].label + ')\n';
      }
      if (ride.bikeType && BIKE_TYPES[ride.bikeType]) {
        const bt = BIKE_TYPES[ride.bikeType];
        m += bt.emoji + ' Tipo: ' + bt.label + '\n';
      }
      if (ride.notes) m += '\n📝 Notas: ' + ride.notes + '\n';
      if (ride.supportDriver || ride.supportDriverPhone) {
        const sd = (ride.supportDriver || '').trim();
        const sp = (ride.supportDriverPhone || '').trim();
        m += '🚗 Coche escoba: ' + (sd || 'sí') + (sp ? ' · ' + sp : '') + '\n';
      }

      // Apuntados (solo usuarios con perfil cargado; nombres público nombre+inicial)
      // v28-quatre: ya no se filtra por 'u_' porque los UIDs de Firebase Auth no siguen ese patrón.
      // Se incluye cualquier participante que tenga perfil en allUsers (excluyendo marcador legacy 'Tú').
      const realParts = (ride.participants || []).filter(function(pid) {
        return pid && typeof pid === 'string' && pid !== 'Tú' && allUsers && allUsers[pid];
      });
      if (realParts.length > 0) {
        m += '\n*Apuntados (' + realParts.length + '):*\n';
        const toShow = realParts.slice(0, 10);
        toShow.forEach(function(pid) { m += '• ' + getUserDisplay(allUsers[pid]) + '\n'; });
        if (realParts.length > 10) m += '...y ' + (realParts.length - 10) + ' más\n';
      } else {
        m += '\n_Aún no hay apuntados._\n';
      }

      m += '\n¿No te has apuntado? Entra en la app:\n';
      if (APP_URL) m += APP_URL;
      return m;
    }


    // ShareRideModal → js/modals-a.js
    function RideTrafficLightBadge({ grupeta, dateKey, timeStr }) {
      const { loading, data } = useRideWeather(grupeta, dateKey, timeStr);
      if (loading || !data) return null;
      const light = weatherTrafficLight(data);
      if (!light) return null;
      const cfg = {
        green:  { dot: '🟢', cls: 'bg-green-50 text-green-800 border-green-200',  title: 'Buena previsión' },
        yellow: { dot: '🟡', cls: 'bg-amber-50 text-amber-800 border-amber-200',  title: 'Precaución con la meteo' },
        red:    { dot: '🔴', cls: 'bg-red-50 text-red-800 border-red-200',        title: 'Mala previsión' },
      }[light];
      return (
        <span title={cfg.title}
          className={'inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md border text-[10px] font-semibold align-middle ' + cfg.cls}>
          <span className="leading-none text-xs">{cfg.dot}</span>
        </span>
      );
    }

    function RideWeatherLine({ grupeta, dateKey, timeStr, onSuggestSuspend }) {
      const { loading, data } = useRideWeather(grupeta, dateKey, timeStr);
      if (loading) {
        return (
          <div className="body-font text-[11px] text-gray-400 italic">
            Consultando meteo…
          </div>
        );
      }
      if (!data) return null;
      const info = wmoToInfo(data.code);
      const t = data.temp != null ? Math.round(data.temp) : null;
      const feels = data.feels != null ? Math.round(data.feels) : null;
      const w = data.wind != null ? Math.round(data.wind) : null;
      const gust = data.gusts != null ? Math.round(data.gusts) : null;
      const rp = data.rainProb;
      // v28-oct9: AEMET ya devuelve windDir como texto ("NE", "S"...). Open-Meteo devuelve grados.
      const dir = data.windDir || windDirShort(data.windDeg);
      const light = weatherTrafficLight(data);
      const sourceLabel = data.source === 'aemet' ? 'AEMET' : (data.source === 'openmeteo' ? 'Open-Meteo' : '');
      // Color del viento: verde <20, ámbar 20-35, rojo >35 km/h
      let windCls = 'text-gray-700';
      if (w != null) {
        if (w > 35) windCls = 'text-red-600 font-semibold';
        else if (w >= 20) windCls = 'text-amber-600 font-semibold';
      }
      // Aviso si lluvia probable o viento muy fuerte
      const heavyRain = (rp != null && rp >= 60);
      const heavyWind = (w != null && w > 35) || (gust != null && gust > 50);
      const warn = heavyRain || heavyWind;
      return (
        <div className="mb-2 px-2 py-1.5 bg-sky-50 border border-sky-100 rounded-lg">
          <div className="flex items-center gap-3 text-xs body-font flex-wrap">
            <span className="flex items-center gap-1" title={info.desc}>
              <span className="text-base leading-none">{info.icon}</span>
              {t != null && <span className="font-semibold text-gray-900">{t}°</span>}
              {feels != null && feels !== t && (
                <span className="text-gray-500 text-[10px]">(sens. {feels}°)</span>
              )}
            </span>
            {w != null && (
              <span className={'flex items-center gap-1 ' + windCls}>
                <span>💨</span>
                <span>{w} km/h{dir ? ' ' + dir : ''}</span>
                {gust != null && gust >= w + 10 && (
                  <span className="text-[10px] opacity-80">(ráf. {gust})</span>
                )}
              </span>
            )}
            {rp != null && (
              <span className={'flex items-center gap-1 ' + (rp >= 60 ? 'text-blue-700 font-semibold' : 'text-gray-700')}>
                <span>☔</span>
                <span>{rp}%</span>
              </span>
            )}
          </div>
          {warn && (
            <div className="body-font text-[11px] text-red-700 font-semibold mt-1">
              {heavyRain && heavyWind && '⚠️ Lluvia probable y viento fuerte'}
              {heavyRain && !heavyWind && '⚠️ Alta probabilidad de lluvia'}
              {!heavyRain && heavyWind && '⚠️ Viento fuerte previsto'}
            </div>
          )}
          {/* v28-oct7: sugerencia de suspender al admin si el semáforo está rojo */}
          {light === 'red' && typeof onSuggestSuspend === 'function' && (
            <button onClick={onSuggestSuspend}
              className="mt-2 w-full bg-red-600 hover:bg-red-700 text-white display-font font-bold text-[11px] tracking-wider py-1.5 rounded-md flex items-center justify-center gap-1.5 active:scale-95 transition">
              🚫 SUGERENCIA: SUSPENDER ESTA SALIDA
            </button>
          )}
          {/* v28-oct9: indicador discreto de la fuente (AEMET/Open-Meteo) */}
          {sourceLabel && (
            <div className="text-[9px] text-gray-400 body-font mt-1 text-right">
              Fuente: {sourceLabel}
            </div>
          )}
        </div>
      );
    }

    // ======================================================================
    // v28-oct11: 💬 COMENTARIOS por salida (hilo corto tipo WhatsApp)
    // ----------------------------------------------------------------------
    // - Backend: Firebase RTDB en /rideComments/{grupetaId}/{rideId}/{cid}.
    //   Estructura paralela a /grupetas para no engordar el árbol de salidas.
    // - Lectura: hook con .on('value') solo cuando el modal está abierto.
    // - Badge "no leídos": comparamos createdAt de cada comentario con un
    //   timestamp guardado en localStorage por (grupeta, ride). 0 coste backend.
    // - Permisos cliente: solo socios escriben; borra autor + admin grupeta +
    //   admin central. Las reglas Firestore reales son la barrera definitiva
    //   (pendiente #1 del handoff).
    // - Ventana temporal: visible desde 7 días antes hasta 3 después de la
    //   salida (anteriores quedan en histórico de la salida, sin caja escribir).
    // ======================================================================

    // Anti-spam: cooldown de 5s entre comentarios por usuario.
    if (typeof window._lastCommentSentAt === 'undefined') window._lastCommentSentAt = 0;

    // Helpers localStorage "última lectura" por (grupeta, ride).
    function commentsReadStorageKey() { return 'grupetas_lastReadComments'; }

    function getLastReadComment(grupetaId, rideId) {
      try {
        const raw = localStorage.getItem(commentsReadStorageKey());
        if (!raw) return 0;
        const obj = JSON.parse(raw) || {};
        const k = grupetaId + ':' + rideId;
        return obj[k] || 0;
      } catch (e) { return 0; }
    }

    function setLastReadComment(grupetaId, rideId, timestamp) {
      try {
        const raw = localStorage.getItem(commentsReadStorageKey());
        const obj = (raw ? JSON.parse(raw) : null) || {};
        const k = grupetaId + ':' + rideId;
        obj[k] = timestamp || Date.now();
        // Limpieza simple: si pasamos de 500 entradas, conservar las 250 más recientes.
        const entries = Object.entries(obj);
        if (entries.length > 500) {
          entries.sort(function(a, b) { return b[1] - a[1]; });
          const trimmed = {};
          entries.slice(0, 250).forEach(function(e) { trimmed[e[0]] = e[1]; });
          localStorage.setItem(commentsReadStorageKey(), JSON.stringify(trimmed));
        } else {
          localStorage.setItem(commentsReadStorageKey(), JSON.stringify(obj));
        }
      } catch (e) { /* localStorage lleno o deshabilitado: ignorar */ }
    }

    // ======================================================================
    // v28-oct18: AVISO DE SALIDAS NUEVAS (badge rojo en la tarjeta de grupeta).
    // Mismo enfoque que el badge de comentarios: guardamos en localStorage el
    // timestamp de la última vez que el usuario ENTRÓ en cada grupeta, y contamos
    // las salidas con createdAt posterior. 0 coste de backend, sin permisos, sin push.
    // Solo se muestra en grupetas donde el usuario es socio.
    // ======================================================================
    // ── MERCADILLO lastSeen ───────────────────────────────────────────────
    function getMercadilloLastSeen(uid) {
      try { return parseInt(localStorage.getItem('merc_lastseen_' + uid) || '0', 10); } catch(e) { return 0; }
    }
    function setMercadilloLastSeen(uid) {
      try { localStorage.setItem('merc_lastseen_' + uid, String(Date.now())); } catch(e) {}
    }
    function getMercadilloChatLastSeen(uid) {
      try { return parseInt(localStorage.getItem('merc_chatlastseen_' + uid) || '0', 10); } catch(e) { return 0; }
    }
    function setMercadilloChatLastSeen(uid) {
      try { localStorage.setItem('merc_chatlastseen_' + uid, String(Date.now())); } catch(e) {}
    }
    // ─────────────────────────────────────────────────────────────────────

    function ridesSeenStorageKey() { return 'grupetas_lastSeenRides'; }

    function getLastSeenRides(grupetaId) {
      try {
        const raw = localStorage.getItem(ridesSeenStorageKey());
        if (!raw) return 0;
        const obj = JSON.parse(raw) || {};
        return obj[grupetaId] || 0;
      } catch (e) { return 0; }
    }

    function setLastSeenRides(grupetaId, timestamp) {
      try {
        const raw = localStorage.getItem(ridesSeenStorageKey());
        const obj = (raw ? JSON.parse(raw) : null) || {};
        obj[grupetaId] = timestamp || Date.now();
        const entries = Object.entries(obj);
        if (entries.length > 500) {
          entries.sort(function(a, b) { return b[1] - a[1]; });
          const trimmed = {};
          entries.slice(0, 250).forEach(function(e) { trimmed[e[0]] = e[1]; });
          localStorage.setItem(ridesSeenStorageKey(), JSON.stringify(trimmed));
        } else {
          localStorage.setItem(ridesSeenStorageKey(), JSON.stringify(obj));
        }
      } catch (e) { /* localStorage lleno o deshabilitado: ignorar */ }
    }

    // Cuenta cuántas salidas de una grupeta son "nuevas" (createdAt posterior a la
    // última visita del usuario). Excluye las propuestas por el propio usuario.
    // `rides` es el objeto grupeta.rides = { 'YYYY-MM-DD': [ride, ride...] }.
    function countNewRides(grupetaId, rides, currentUserId) {
      if (!grupetaId || !rides) return 0;
      const lastSeen = getLastSeenRides(grupetaId);
      // Primera vez (nunca ha entrado): no marcamos todo como nuevo para no abrumar.
      if (!lastSeen) return 0;
      let n = 0;
      Object.keys(rides).forEach(function(dk) {
        const dayRides = rides[dk];
        if (!Array.isArray(dayRides)) return;
        dayRides.forEach(function(r) {
          if (!r || !r.createdAt) return;
          // Mías no cuentan (ya sé que las propuse yo).
          if (r.proposedBy && r.proposedBy === currentUserId) return;
          const ts = Date.parse(r.createdAt);
          if (!isNaN(ts) && ts > lastSeen) n++;
        });
      });
      return n;
    }

    // Ventana en la que se permite ver y escribir comentarios:
    //   desde 7 días ANTES de la salida hasta 3 días DESPUÉS.
    // Comentarios viejos siguen visibles en Firebase pero no se accede a ellos
    // desde la UI (la salida pasa al HISTORIAL y allí no abrimos comentarios).
    function isCommentWindowOpen(dateKey) {
      if (!dateKey) return false;
      try {
        const p = dateKey.split('-');
        const rideDate = new Date(parseInt(p[0]), parseInt(p[1]) - 1, parseInt(p[2]));
        const today = new Date();
        today.setHours(0, 0, 0, 0);
        const diffDays = Math.floor((rideDate.getTime() - today.getTime()) / 86400000);
        return diffDays >= -3 && diffDays <= 7;
      } catch (e) { return false; }
    }

    // Hook: escucha comentarios de una salida en tiempo real.
    // enabled=false para no abrir listener si el modal no está abierto y solo
    // queremos el contador. Para el contador usamos otro hook ligero más abajo.
    function useRideComments(grupetaId, rideId, enabled) {
      const [comments, setComments] = useState([]);
      const [loading, setLoading] = useState(!!enabled);
      useEffect(function() {
        if (!enabled || !grupetaId || !rideId) {
          setComments([]); setLoading(false);
          return undefined;
        }
        setLoading(true);
        const ref = db.ref('rideComments/' + grupetaId + '/' + rideId);
        const handler = ref.on('value', function(snap) {
          const val = snap.val() || {};
          const arr = Object.keys(val).map(function(k) {
            return Object.assign({ id: k }, val[k]);
          });
          arr.sort(function(a, b) { return (a.createdAt || 0) - (b.createdAt || 0); });
          setComments(arr);
          setLoading(false);
        }, function(err) {
          console.warn('[comments] read error', err);
          setLoading(false);
        });
        return function() { ref.off('value', handler); };
      }, [grupetaId, rideId, enabled]);
      return { comments: comments, loading: loading };
    }

    // Hook ligero: solo cuenta total y no-leídos. Reutiliza el mismo listener
    // RTDB pero con un valor mínimo en memoria. Lo abrimos solo si la salida
    // está en ventana (próximas/recientes) — así no escuchamos comentarios
    // de salidas viejas innecesariamente.
    // v28-oct11b: forceEnabled=true ignora la ventana temporal. Útil en HISTORIAL.
    function useRideCommentsCount(grupetaId, rideId, dateKey, forceEnabled) {
      const enabled = (forceEnabled || isCommentWindowOpen(dateKey)) && !!grupetaId && !!rideId;
      const [total, setTotal] = useState(0);
      const [unread, setUnread] = useState(0);
      useEffect(function() {
        if (!enabled) { setTotal(0); setUnread(0); return undefined; }
        const ref = db.ref('rideComments/' + grupetaId + '/' + rideId);
        const lastRead = getLastReadComment(grupetaId, rideId);
        const handler = ref.on('value', function(snap) {
          const val = snap.val() || {};
          let t = 0, u = 0;
          Object.keys(val).forEach(function(k) {
            t++;
            if ((val[k].createdAt || 0) > lastRead) u++;
          });
          setTotal(t);
          setUnread(u);
        }, function() { /* silencio */ });
        return function() { ref.off('value', handler); };
      // lastRead no va en deps a propósito: solo se "refresca" al volver a
      // montar el componente (p.ej. tras cerrar el modal, el botón se rerenderiza
      // y vuelve a leer localStorage).
      // eslint-disable-next-line
      }, [grupetaId, rideId, enabled]);
      return { total: total, unread: unread };
    }

    // Escribe un comentario. Devuelve una promesa.
    function addRideComment(grupetaId, rideId, text, authorId, authorName) {
      const now = Date.now();
      if (now - (window._lastCommentSentAt || 0) < 5000) {
        return Promise.reject(new Error('cooldown'));
      }
      const cleanText = String(text || '').trim();
      if (!cleanText) return Promise.reject(new Error('empty'));
      if (cleanText.length > 280) return Promise.reject(new Error('too_long'));
      const ref = db.ref('rideComments/' + grupetaId + '/' + rideId).push();
      return ref.set({
        text: cleanText,
        authorId: authorId,
        authorName: authorName || '',
        createdAt: firebase.database.ServerValue.TIMESTAMP
      }).then(function() {
        window._lastCommentSentAt = now;
        return ref.key;
      });
    }

    function deleteRideComment(grupetaId, rideId, commentId) {
      return db.ref('rideComments/' + grupetaId + '/' + rideId + '/' + commentId).remove();
    }

    // v77: 💬→🔔 aviso push best-effort cuando se escribe un comentario en una salida.
    // Reglas (decididas con el usuario):
    // - SOLO dispara si el autor es un APUNTADO o el ORGANIZADOR (proposedBy).
    // - Reciben: apuntados + organizador (aunque no esté apuntado), SIN el autor, sin duplicados.
    // - Espejo del patrón v70: nunca rompemos el comentario por un fallo del aviso.
    function notifyRideComment(ride, commentText, authorId, authorName) {
      try {
        if (!ride || !authorId) return;
        var participants = (ride.participants || []).filter(function(p) { return !!p; });
        var organizadorUid = ride.proposedBy || null;
        var authorIsIn = participants.indexOf(authorId) !== -1 || authorId === organizadorUid;
        if (!authorIsIn) return;
        var uids = [];
        participants.forEach(function(u) {
          if (u !== authorId && uids.indexOf(u) === -1) uids.push(u);
        });
        if (organizadorUid && organizadorUid !== authorId && uids.indexOf(organizadorUid) === -1) {
          uids.push(organizadorUid);
        }
        if (uids.length === 0) return;
        var rutaLbl = (ride.route || ride.name) || 'la salida';
        var who = authorName || 'Alguien';
        var txt = String(commentText || '').trim();
        if (txt.length > 120) txt = txt.slice(0, 117) + '…';
        fetch('/.netlify/functions/send-push', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            uids: uids,
            excludeUid: authorId,
            title: '💬 Comentario en ' + rutaLbl,
            body: who + ': "' + txt + '"',
            url: '/'
          })
        }).catch(function() { /* best-effort */ });
      } catch (e) { /* nunca rompemos el comentario por un aviso */ }
    }

    // Tiempo relativo "hace 5 min" / "hace 2 h" / "ayer" / fecha corta.
    function commentTimeAgo(ts) {
      if (!ts) return '';
      const diff = Math.max(0, Date.now() - ts);
      const sec = Math.floor(diff / 1000);
      if (sec < 60) return 'ahora';
      const min = Math.floor(sec / 60);
      if (min < 60) return 'hace ' + min + ' min';
      const hr = Math.floor(min / 60);
      if (hr < 24) return 'hace ' + hr + ' h';
      const d = Math.floor(hr / 24);
      if (d === 1) return 'ayer';
      if (d < 7) return 'hace ' + d + ' días';
      try {
        const dt = new Date(ts);
        return dt.getDate() + '/' + (dt.getMonth() + 1) + '/' + String(dt.getFullYear()).slice(-2);
      } catch (e) { return ''; }
    }

    // Botón compacto "💬 COMENTAR" con badge rojo de no leídos.
    function RideCommentsButton({ grupetaId, rideId, dateKey, onClick }) {
      const { total, unread } = useRideCommentsCount(grupetaId, rideId, dateKey);
      if (!isCommentWindowOpen(dateKey)) return null;
      const hasAny = total > 0;
      return (
        <button onClick={onClick}
          className="relative display-font text-[11px] font-bold tracking-wider px-3 py-1.5 rounded-lg bg-purple-600 hover:bg-purple-700 text-white flex items-center gap-1.5 active:scale-95 transition"
          title="Comentarios de la salida">
          <span>💬</span>
          <span>{hasAny ? 'COMENTAR (' + total + ')' : 'COMENTAR'}</span>
          {unread > 0 && (
            <span className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-600 text-white text-[10px] font-bold flex items-center justify-center border-2 border-white shadow">
              {unread > 9 ? '9+' : unread}
            </span>
          )}
        </button>
      );
    }

    // v28-oct11b: badge compacto para HISTORIAL.
    // - Solo aparece si la salida tiene ≥1 comentario (no contamina si no hay nada).
    // - Sin filtro de ventana temporal (forceEnabled=true).
    // - Estilo más discreto (outline morado) para no competir con REPETIR.
    // - Solo lectura: al pulsar abre el modal pero CommentsModal recibe canWrite=false.
    function RideCommentsHistoryBadge({ grupetaId, rideId, dateKey, onClick }) {
      const { total } = useRideCommentsCount(grupetaId, rideId, dateKey, true);
      if (total <= 0) return null;
      return (
        <button onClick={onClick}
          className="display-font text-[11px] font-bold tracking-wider px-3 py-1.5 rounded-lg bg-white text-purple-700 border border-purple-300 hover:bg-purple-50 flex items-center gap-1.5 active:scale-95 transition"
          title="Ver comentarios de esta salida">
          <span>💬</span>
          <span>{total}</span>
        </button>
      );
    }

    // v28-oct11e: vista INLINE de comentarios DENTRO de la tarjeta de la salida.
    // - Misma información que el modal pero embebida en la tarjeta del día.
    // - Solo se muestra si la salida está en ventana temporal (mismo criterio que el botón COMENTAR).
    // - Si no es socio, solo se muestra si hay comentarios (lectura).
    // - Si es socio, se muestra siempre (incluida la caja de escribir si vacío).
    // - Marca como leídos al renderizar (un único timestamp por usuario al abrir la salida del día).
    function RideCommentsInline({ grupeta, ride, dateKey, currentUserId, currentUser, allUsers, canWrite, effectiveAdmin, showToast }) {
      const enabled = isCommentWindowOpen(dateKey);
      const { comments, loading } = useRideComments(grupeta.id, ride.id, enabled);
      const [text, setText] = useState('');
      const [sending, setSending] = useState(false);

      // Al renderizar (montar) marcamos como leídos, así el badge rojo del botón
      // COMENTAR desaparece al abrir el día. Ya no se vuelve a marcar — los comentarios
      // que lleguen mientras el modal del día está abierto NO se marcan automáticamente
      // como leídos (queremos que el badge se actualice si entra uno nuevo en vivo).
      useEffect(function() {
        if (enabled) setLastReadComment(grupeta.id, ride.id, Date.now());
      // eslint-disable-next-line
      }, []);

      if (!enabled) return null;
      // Si no es socio Y no hay comentarios, no mostramos nada (evitamos contaminar).
      if (!canWrite && comments.length === 0) return null;

      function handleSend() {
        const clean = text.trim();
        if (!clean) return;
        if (clean.length > 280) { showToast && showToast('Máximo 280 caracteres'); return; }
        setSending(true);
        const authorName = currentUser ? getUserDisplay(currentUser) : '';
        addRideComment(grupeta.id, ride.id, clean, currentUserId, authorName)
          .then(function() {
            setText(''); setSending(false);
            // v77: aviso push a apuntados + organizador (solo si el autor está apuntado o es organizador)
            notifyRideComment(ride, clean, currentUserId, authorName);
          })
          .catch(function(err) {
            setSending(false);
            if (err && err.message === 'cooldown') {
              showToast && showToast('Espera unos segundos antes de enviar otro');
            } else if (err && err.message === 'too_long') {
              showToast && showToast('Máximo 280 caracteres');
            } else {
              showToast && showToast('No se pudo enviar. Reintenta.');
            }
          });
      }

      function handleDelete(c) {
        if (!window.__confirmPending_comment) { window.__confirmPending_comment = true; setTimeout(function(){ window.__confirmPending_comment=false; }, 3500); showToast('Pulsa de nuevo para borrar'); return; } window.__confirmPending_comment = false;
        deleteRideComment(grupeta.id, ride.id, c.id)
          .catch(function() { showToast && showToast('No se pudo borrar'); });
      }

      const charsLeft = 280 - text.length;
      const charsCls = charsLeft < 20 ? (charsLeft < 0 ? 'text-red-600' : 'text-amber-600') : 'text-gray-400';

      return (
        <div className="mb-2 mt-1 px-2 py-2 bg-purple-50/60 border border-purple-100 rounded-lg">
          <div className="flex items-center justify-between mb-1.5 px-0.5">
            <span className="display-font text-[10px] font-bold tracking-wider text-purple-700">
              💬 COMENTARIOS{comments.length > 0 ? ' (' + comments.length + ')' : ''}
            </span>
          </div>
          {loading && comments.length === 0 && (
            <p className="body-font text-[11px] text-gray-400 italic px-1 py-1">Cargando…</p>
          )}
          {!loading && comments.length === 0 && canWrite && (
            <p className="body-font text-[11px] text-gray-500 italic px-1 py-1">¡Sé el primero en comentar! 👇</p>
          )}
          {comments.length > 0 && (
            <div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
              {comments.map(function(c) {
                const isMine = c.authorId === currentUserId;
                const canDelete = isMine || effectiveAdmin;
                const authorUser = (allUsers && c.authorId) ? allUsers[c.authorId] : null;
                const displayName = authorUser ? getUserDisplay(authorUser) : (c.authorName || 'Anónimo');
                return (
                  <div key={c.id} className={'rounded-lg px-2 py-1.5 ' + (isMine ? 'bg-red-50 border border-red-100' : 'bg-white border border-gray-100')}>
                    <div className="flex items-start gap-2">
                      <div className="flex-shrink-0 mt-0.5">
                        <UserAvatar user={authorUser} userId={c.authorId} size={22} />
                      </div>
                      <div className="flex-1 min-w-0">
                        <div className="flex items-baseline justify-between gap-2">
                          <span className="body-font font-bold text-[11px] text-gray-900 truncate">{displayName}</span>
                          <span className="body-font text-[9px] text-gray-400 flex-shrink-0">{commentTimeAgo(c.createdAt)}</span>
                        </div>
                        <p className="body-font text-[12px] text-gray-800 mt-0.5 whitespace-pre-wrap break-words leading-snug">{c.text}</p>
                      </div>
                      {canDelete && (
                        <button onClick={function() { handleDelete(c); }}
                          className="text-gray-300 hover:text-red-600 active:scale-90 transition flex-shrink-0"
                          aria-label="Borrar comentario" title="Borrar comentario">
                          <Trash2 size={12} />
                        </button>
                      )}
                    </div>
                  </div>
                );
              })}
            </div>
          )}
          {canWrite && (
            <div className="mt-2">
              <div className="flex items-end gap-1.5">
                <textarea
                  value={text}
                  onChange={function(e) { setText(e.target.value); }}
                  onKeyDown={function(e) {
                    if (e.key === 'Enter' && !e.shiftKey) {
                      e.preventDefault();
                      if (!sending && text.trim()) handleSend();
                    }
                  }}
                  placeholder="Escribe un comentario..."
                  rows={1}
                  maxLength={320}
                  disabled={sending}
                  className="flex-1 resize-none rounded-lg border border-gray-300 px-2 py-1.5 text-[12px] body-font focus:outline-none focus:border-purple-400 focus:ring-1 focus:ring-purple-200" />
                <button onClick={handleSend} disabled={sending || !text.trim() || charsLeft < 0}
                  className="display-font font-bold text-[10px] tracking-wider px-3 py-1.5 rounded-lg bg-purple-600 hover:bg-purple-700 text-white active:scale-95 transition disabled:opacity-40 disabled:cursor-not-allowed">
                  {sending ? '...' : 'ENVIAR'}
                </button>
              </div>
              {text.length > 0 && (
                <div className={'text-right body-font text-[9px] mt-0.5 ' + charsCls}>
                  {text.length}/280
                </div>
              )}
            </div>
          )}
        </div>
      );
    }

    // Modal con el hilo completo + caja de escribir.

    // CommentsModal → js/modals-a.js
    function RoutePreviewMap({ preview, bounds, height }) {
      const h = height || 110;
      if (!preview || preview.length < 2 || !bounds) {
        return (
          <div className="w-full bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 body-font text-[11px]"
               style={{ height: h }}>sin mapa</div>
        );
      }
      // Proyección equirectangular simple (válida para tramos pequeños).
      // Calculamos viewBox para que el track entre con padding del 5%.
      const pad = 4;
      const W = 300, H = h;
      const latRange = (bounds.north - bounds.south) || 0.0001;
      const lonRange = (bounds.east - bounds.west) || 0.0001;
      // Corrección por latitud para no estirar horizontalmente.
      const meanLat = (bounds.north + bounds.south) / 2;
      const xScale = Math.cos(meanLat * Math.PI / 180);
      const dataAR = (lonRange * xScale) / latRange; // ancho/alto en grados corregidos
      const boxAR = (W - 2*pad) / (H - 2*pad);
      let drawW, drawH;
      if (dataAR > boxAR) { drawW = W - 2*pad; drawH = drawW / dataAR; }
      else { drawH = H - 2*pad; drawW = drawH * dataAR; }
      const offX = (W - drawW) / 2;
      const offY = (H - drawH) / 2;
      const toXY = function(p) {
        const lat = p[0], lon = p[1];
        const x = offX + ((lon - bounds.west) / lonRange) * drawW;
        const y = offY + ((bounds.north - lat) / latRange) * drawH;
        return x.toFixed(1) + ',' + y.toFixed(1);
      };
      const d = preview.map(function(p, i) {
        return (i === 0 ? 'M' : 'L') + toXY(p);
      }).join(' ');
      const first = preview[0];
      const last = preview[preview.length - 1];
      const [fx, fy] = toXY(first).split(',');
      const [lx, ly] = toXY(last).split(',');
      return (
        <svg viewBox={'0 0 ' + W + ' ' + H} width="100%" height={h} preserveAspectRatio="none"
             className="bg-gradient-to-br from-blue-50 to-gray-100 rounded-lg">
          <path d={d} stroke="#7c3aed" strokeWidth="2.5" fill="none" strokeLinejoin="round" strokeLinecap="round" />
          <circle cx={fx} cy={fy} r="4" fill="#16a34a" stroke="white" strokeWidth="1.5" />
          <circle cx={lx} cy={ly} r="4" fill="#dc2626" stroke="white" strokeWidth="1.5" />
        </svg>
      );
    }

    // ======================================================================
    // v28-oct13: UI BIBLIOTECA PÚBLICA POR PROVINCIA
    // ----------------------------------------------------------------------
    // PublicRouteCard       — tarjeta de una ruta del catálogo provincial.
    // ProvincialRoutesModal — modal con listado, búsqueda y orden.
    // ProvincesHomeButtons  — botones por provincia para el home (NUEVO).
    // ======================================================================

    // v28-oct14: contenedor con lazy-mount via IntersectionObserver.
    // Renderiza un placeholder gris hasta que la tarjeta entra en viewport;
    // entonces monta el mapa Leaflet real. Evita cargar 10+ Leaflets a la vez
    // cuando el catálogo crece (las tarjetas que nunca se ven nunca instancian Leaflet).
    function LazyPreviewGpxMap({ preview, bounds, height }) {
      const containerRef = useRef(null);
      const [visible, setVisible] = useState(false);

      useEffect(function() {
        if (visible) return; // ya montado
        const el = containerRef.current;
        if (!el) return;
        // Fallback: navegadores muy viejos sin IntersectionObserver → carga directa.
        if (typeof IntersectionObserver === 'undefined') { setVisible(true); return; }
        const obs = new IntersectionObserver(function(entries) {
          for (let i = 0; i < entries.length; i++) {
            if (entries[i].isIntersecting) { setVisible(true); obs.disconnect(); return; }
          }
        }, { rootMargin: '200px 0px' }); // pre-monta 200px antes de entrar en viewport
        obs.observe(el);
        return function() { obs.disconnect(); };
      }, [visible]);

      const h = height || 180;
      if (visible) {
        return <PreviewGpxMap preview={preview} bounds={bounds} height={h} />;
      }
      return (
        <div ref={containerRef}
          style={{ height: h + 'px', borderRadius: '8px', overflow: 'hidden', border: '1px solid #e5e7eb', background: '#f3f4f6' }}
          className="flex items-center justify-center text-xs text-gray-400 body-font italic">
          Cargando mapa…
        </div>
      );
    }

    // v2-jun: MINI-PERFIL de altimetría (silueta de subidas/bajadas) en SVG.
    // Recibe `profile` = objeto de buildElevationProfile (o null). Componente "tonto":
    // solo dibuja. Quien lo usa decide de dónde sacan los datos. Altura por defecto 32px.
    function ElevationProfile({ profile, height }) {
      const H = height || 32;
      if (!profile || !profile.linePath) return null;
      const W = profile.W || 100;
      const vbH = profile.H || H;
      return (
        <div className="flex items-center gap-1.5 mt-1.5">
          <span className="text-[9px]" title="Perfil de altimetría">⛰️</span>
          <svg
            viewBox={'0 0 ' + W + ' ' + vbH}
            preserveAspectRatio="none"
            style={{ height: H + 'px', flex: 1, display: 'block', overflow: 'visible' }}
            aria-label={'Perfil de altimetría: de ' + profile.minEle + ' a ' + profile.maxEle + ' metros'}>
            <path d={profile.areaPath} fill="rgba(124,58,237,0.12)" stroke="none" />
            <path d={profile.linePath} fill="none" stroke="#7c3aed" strokeWidth="1.5"
              strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
          </svg>
          <span className="text-[9px] text-gray-400 body-font whitespace-nowrap tabular-nums">
            {profile.minEle}–{profile.maxEle} m
          </span>
        </div>
      );
    }

    // v2-jun: perfil de altimetría para una RUTA DEL CATÁLOGO (rutas de la región).
    // El GPX no está en el catálogo ligero, así que se descarga BAJO DEMANDA y solo
    // cuando la tarjeta entra en viewport (lazy, como LazyPreviewGpxMap). Caché por id de ruta
    // en window._gpxProfileCache para no volver a descargar/recalcular al reaparecer.
    function LazyRouteElevationProfile({ route, height }) {
      const containerRef = useRef(null);
      const [visible, setVisible] = useState(false);
      const [profile, setProfile] = useState(undefined); // undefined=sin pedir, null=sin datos
      const H = height || 32;

      // Lazy-mount: no pedimos nada hasta que la tarjeta se acerca al viewport.
      useEffect(function() {
        if (visible) return;
        const el = containerRef.current;
        if (!el) return;
        if (typeof IntersectionObserver === 'undefined') { setVisible(true); return; }
        const obs = new IntersectionObserver(function(entries) {
          for (let i = 0; i < entries.length; i++) {
            if (entries[i].isIntersecting) { setVisible(true); obs.disconnect(); return; }
          }
        }, { rootMargin: '200px 0px' });
        obs.observe(el);
        return function() { obs.disconnect(); };
      }, [visible]);

      // Cuando es visible: descarga el GPX (con caché) y construye el perfil.
      useEffect(function() {
        if (!visible || profile !== undefined) return;
        let cancelled = false;
        window._gpxProfileCache = window._gpxProfileCache || {};
        const cacheKey = 'route:' + (route.id || route.storagePath || route.name) + '|' + H;
        if (window._gpxProfileCache[cacheKey] !== undefined) {
          setProfile(window._gpxProfileCache[cacheKey]);
          return;
        }
        // Solo tiene sentido si la ruta declara desnivel (si es plana/no hay ele, no perfil).
        if (!route.elevationM) {
          window._gpxProfileCache[cacheKey] = null;
          setProfile(null);
          return;
        }
        downloadPublicRouteAsTrackFile(route)
          .then(function(tf) {
            if (cancelled) return;
            let prof = null;
            try {
              const txt = dataURLToText(tf.data);
              const pts = parseGpxPointsWithEle(txt);
              prof = buildElevationProfile(pts, 100, H);
            } catch (e) { prof = null; }
            window._gpxProfileCache[cacheKey] = prof;
            setProfile(prof);
          })
          .catch(function() {
            if (cancelled) return;
            window._gpxProfileCache[cacheKey] = null;
            setProfile(null);
          });
        return function() { cancelled = true; };
      }, [visible, profile]);

      // Marcador para el observer; reserva un poco de alto mientras carga.
      if (profile === undefined) {
        return <div ref={containerRef} style={{ height: (H + 6) + 'px' }} />;
      }
      if (profile === null) return null; // sin datos de altitud → no mostramos nada
      return <ElevationProfile profile={profile} height={H} />;
    }

    function PublicRouteCard({ route, isCentral, onUse, onDelete, onUseLoading, onOpenDetail, onUseInGrupeta, onUseInGrupetaLoading, currentUser, currentUserId, adminUnlocked, showToast }) {
      const grupetaLabel = route.authorGrupetaName || '';
      const bikeType = route.bikeType;
      const bikeInfo = bikeType && BIKE_TYPES[bikeType] ? BIKE_TYPES[bikeType] : null;
      // Admin central puede asignar/cambiar bikeType desde la propia tarjeta
      const [editingBikeType, setEditingBikeType] = useState(false);
      const [savingBikeType, setSavingBikeType] = useState(false);

      // v28-oct16: edición de metadatos (nombre, modalidad, descripción)
      const canEdit = canEditPublicRoute(route, currentUserId, isCentral, adminUnlocked, currentUser);
      const [editing, setEditing] = useState(false);
      const [eName, setEName] = useState(route.name || '');
      const [eBike, setEBike] = useState(route.bikeType || '');
      const [eDesc, setEDesc] = useState(route.description || '');
      const [eMunicipio, setEMunicipio] = useState(route.municipio || '');
      const [savingEdit, setSavingEdit] = useState(false);

      function openEdit() {
        setEName(route.name || '');
        setEBike(route.bikeType || '');
        setEDesc(route.description || '');
        setEMunicipio(route.municipio || '');
        setEditing(true);
      }

      function saveEdit() {
        if (!eName.trim()) { if (showToast) showToast('El nombre no puede estar vacío'); return; }
        const currentCode = route._provinciaCode || route.provinciaCode || (route.storagePath || '').split('/')[1];
        const detectedCode = eMunicipio.trim() ? provinceCodeFromCity(eMunicipio.trim()) : null;
        const needsMove = detectedCode && detectedCode !== currentCode;

        // Si la provincia cambia, pedir confirmación doble
        if (needsMove) {
          if (!window.__confirmMoveRoute) {
            window.__confirmMoveRoute = true;
            setTimeout(function() { window.__confirmMoveRoute = false; }, 4000);
            if (showToast) showToast('La ruta se moverá de ' + provinceName(currentCode) + ' → ' + provinceName(detectedCode) + '. Pulsa GUARDAR de nuevo para confirmar.');
            return;
          }
          window.__confirmMoveRoute = false;
        }

        setSavingEdit(true);
        const fields = {
          name: eName,
          bikeType: eBike || null,
          description: eDesc,
          municipio: eMunicipio.trim()
        };

        const doSave = needsMove
          ? movePublicRoute(currentCode, detectedCode, route.id, fields)
          : updatePublicRouteMeta(currentCode, route.id, fields);

        doSave
          .then(function() {
            setSavingEdit(false);
            setEditing(false);
            if (showToast) showToast(needsMove
              ? '✅ Ruta movida a ' + provinceName(detectedCode)
              : 'Ruta actualizada');
          })
          .catch(function(err) {
            setSavingEdit(false);
            if (showToast) showToast('No se pudo actualizar: ' + (err && err.message ? err.message : 'error'));
          });
      }

      function handleSetBikeType(newType) {
        // newType puede ser null para "Sin clasificar"
        setSavingBikeType(true);
        const provinciaCode = route._provinciaCode || (route.storagePath || '').split('/')[1];
        setPublicRouteBikeType(provinciaCode, route.id, newType)
          .then(function() { setSavingBikeType(false); setEditingBikeType(false); })
          .catch(function() { setSavingBikeType(false); });
      }

      return (
        <div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
          <div className="relative">
            <button
              type="button"
              onClick={onOpenDetail}
              className="block w-full text-left active:opacity-80 transition"
              aria-label={'Ver detalle de ' + route.name}>
              <LazyPreviewGpxMap preview={route.preview} bounds={route.bounds} height={180} />
            </button>
            {/* Badge bikeType arriba a la izquierda del mapa */}
            {bikeInfo && (
              <div className="absolute top-2 left-2 pointer-events-none">
                <span className="inline-flex items-center gap-1 px-2 py-1 rounded-md font-bold text-[10px] tracking-wide whitespace-nowrap shadow-md"
                  style={{ background: bikeInfo.color, color: 'white' }}>
                  <span style={{ fontSize: '12px' }}>{bikeInfo.emoji}</span>{bikeInfo.label}
                </span>
              </div>
            )}
          </div>
          <div className="p-3">
            <button
              type="button"
              onClick={onOpenDetail}
              className="display-font font-bold text-sm text-gray-900 leading-tight break-words text-left hover:text-purple-700 transition">
              {route.name}
            </button>
            <div className="body-font text-[11px] text-gray-500 mt-1">
              {(route.distanceKm || 0).toFixed(1)} km · {route.elevationM || 0} m desnivel
              {(function() {
                const d = routeDifficulty(route.distanceKm, route.elevationM);
                return d ? <span className="ml-1 font-bold" style={{ color: d.color }}>· {d.emoji} {d.label}</span> : null;
              })()}
              {route.usageCount > 0 && (
                <span className="ml-1 text-purple-600">· usada {route.usageCount}×</span>
              )}
            </div>
            {/* v2-jun: mini-perfil de altimetría (lazy: descarga el GPX al entrar en viewport) */}
            <LazyRouteElevationProfile route={route} height={32} />
            <div className="body-font text-[10px] text-gray-400 mt-0.5">
              por {route.authorName || 'Anónimo'}{grupetaLabel ? ' · ' + grupetaLabel : ''}
            </div>
            {route.description && (
              <div className="body-font text-[11px] text-gray-600 mt-1.5 leading-snug line-clamp-2">
                {route.description}
              </div>
            )}

            {/* v28-oct16: panel de edición de metadatos (nombre, modalidad, descripción) */}
            {editing && (
              <div className="mt-2 bg-blue-50 border border-blue-200 rounded-lg p-2.5 space-y-2">
                <div className="body-font text-[10px] text-blue-700 font-bold uppercase tracking-wide">Editar ruta</div>
                <div>
                  <label className="body-font text-[10px] text-gray-500 font-medium">Nombre</label>
                  <input
                    type="text"
                    value={eName}
                    maxLength={80}
                    onChange={function(e) { setEName(e.target.value); }}
                    className="w-full mt-0.5 border border-gray-300 rounded-md px-2 py-1.5 text-xs body-font focus:outline-none focus:border-blue-400"
                    placeholder="Nombre de la ruta" />
                </div>
                <div>
                  <label className="body-font text-[10px] text-gray-500 font-medium">Municipio <span className="text-gray-400">(opcional)</span></label>
                  <input
                    type="text"
                    value={eMunicipio}
                    maxLength={60}
                    list="muni-suggestions-edit"
                    onChange={function(e) { setEMunicipio(e.target.value); }}
                    className="w-full mt-0.5 border border-gray-300 rounded-md px-2 py-1.5 text-xs body-font focus:outline-none focus:border-blue-400"
                    placeholder="Ej: Cartagena, Lorca, Mazarrón…" />
                  <datalist id="muni-suggestions-edit">
                    {Object.keys(AEMET_MUNICIPIOS).map(function(k) {
                      return <option key={k} value={k.split(' ').map(function(w){ return w.charAt(0).toUpperCase()+w.slice(1); }).join(' ')} />;
                    })}
                  </datalist>
                  {eMunicipio.trim() && (function(){
                    const det = provinceCodeFromCity(eMunicipio.trim());
                    return det
                      ? <div className="body-font text-[10px] text-green-600 mt-0.5">✅ Provincia: {provinceName(det)}</div>
                      : <div className="body-font text-[10px] text-orange-500 mt-0.5">⚠️ Municipio no reconocido</div>;
                  })()}
                </div>
                <div>
                  <label className="body-font text-[10px] text-gray-500 font-medium">Modalidad</label>
                  <div className="flex flex-wrap gap-1.5 mt-0.5">
                    {Object.keys(BIKE_TYPES).map(function(t) {
                      const b = BIKE_TYPES[t];
                      const sel = eBike === t;
                      return (
                        <button key={t} type="button"
                          onClick={function() { setEBike(t); }}
                          className={'rounded px-2 py-1 text-[10px] font-bold tracking-wide ' + (sel ? 'text-white' : 'bg-white border border-gray-300 text-gray-700 hover:border-blue-400')}
                          style={sel ? { background: b.color } : {}}>
                          {b.emoji} {b.label}
                        </button>
                      );
                    })}
                    <button type="button"
                      onClick={function() { setEBike(''); }}
                      className={'rounded px-2 py-1 text-[10px] font-bold tracking-wide ' + (!eBike ? 'bg-gray-600 text-white' : 'bg-white border border-gray-300 text-gray-600 hover:border-blue-400')}>
                      ❓ SIN CLASIF.
                    </button>
                  </div>
                </div>
                <div>
                  <label className="body-font text-[10px] text-gray-500 font-medium">Descripción <span className="text-gray-400">({eDesc.length}/500)</span></label>
                  <textarea
                    value={eDesc}
                    maxLength={500}
                    rows={2}
                    onChange={function(e) { setEDesc(e.target.value); }}
                    className="w-full mt-0.5 border border-gray-300 rounded-md px-2 py-1.5 text-xs body-font resize-none focus:outline-none focus:border-blue-400"
                    placeholder="Terreno, dificultad, avituallamiento…" />
                </div>
                <div className="flex gap-2 pt-0.5">
                  <button type="button" disabled={savingEdit}
                    onClick={saveEdit}
                    className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white display-font font-bold text-[11px] tracking-wide rounded-md px-3 py-1.5 active:scale-[0.98] transition">
                    {savingEdit ? 'GUARDANDO…' : '💾 GUARDAR'}
                  </button>
                  <button type="button" disabled={savingEdit}
                    onClick={function() { setEditing(false); }}
                    className="bg-white border border-gray-300 text-gray-600 hover:bg-gray-50 display-font font-bold text-[11px] tracking-wide rounded-md px-3 py-1.5 active:scale-95 transition">
                    CANCELAR
                  </button>
                </div>
              </div>
            )}

            {/* Editor inline de bikeType para admin central */}
            {isCentral && !editing && (
              <div className="mt-2">
                {!editingBikeType ? (
                  <button
                    type="button"
                    onClick={function() { setEditingBikeType(true); }}
                    className="body-font text-[10px] text-purple-600 hover:text-purple-800 underline">
                    {bikeInfo ? 'Cambiar modalidad' : '⚙️ Asignar modalidad'}
                  </button>
                ) : (
                  <div className="bg-purple-50 border border-purple-200 rounded-lg p-2">
                    <div className="body-font text-[10px] text-purple-700 mb-1.5 font-bold">Modalidad de la ruta:</div>
                    <div className="flex flex-wrap gap-1.5">
                      {['carretera', 'mtb', 'cabra'].map(function(t) {
                        const b = BIKE_TYPES[t];
                        const sel = bikeType === t;
                        return (
                          <button key={t} type="button" disabled={savingBikeType}
                            onClick={function() { handleSetBikeType(t); }}
                            className={'rounded px-2 py-1 text-[10px] font-bold tracking-wide disabled:opacity-50 ' + (sel ? 'text-white' : 'bg-white border border-gray-300 text-gray-700 hover:border-purple-400')}
                            style={sel ? { background: b.color } : {}}>
                            {b.emoji} {b.label}
                          </button>
                        );
                      })}
                      <button type="button" disabled={savingBikeType}
                        onClick={function() { handleSetBikeType(null); }}
                        className={'rounded px-2 py-1 text-[10px] font-bold tracking-wide disabled:opacity-50 ' + (!bikeType ? 'bg-gray-600 text-white' : 'bg-white border border-gray-300 text-gray-600 hover:border-purple-400')}>
                        ❓ SIN CLASIF.
                      </button>
                      <button type="button" disabled={savingBikeType}
                        onClick={function() { setEditingBikeType(false); }}
                        className="rounded px-2 py-1 text-[10px] font-bold tracking-wide text-gray-500 hover:text-gray-700">
                        ✕
                      </button>
                    </div>
                  </div>
                )}
              </div>
            )}

            <div className="flex gap-2 mt-2.5">
              {/* Botón principal: USAR EN MI GRUPETA (solo logueados no anónimos) */}
              {currentUser && onUseInGrupeta ? (
                <div className="flex-1 flex flex-col gap-0.5">
                  <button
                    onClick={onUseInGrupeta}
                    disabled={onUseInGrupetaLoading}
                    className="w-full bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white display-font font-bold text-xs tracking-wide rounded-lg px-3 py-2 active:scale-[0.98] transition">
                    {onUseInGrupetaLoading ? 'PREPARANDO…' : '📋 USAR EN MI GRUPETA'}
                  </button>
                  <button onClick={function(e) { e.stopPropagation(); window._gnav && window._gnav.openHelp && window._gnav.openHelp('help-step-17'); }}
                    className="w-full bg-transparent border-none text-purple-400 text-[9px] font-bold display-font tracking-wider cursor-pointer p-0 active:opacity-70 text-center">
                    ❓ ¿CÓMO SE HACE?
                  </button>
                </div>
              ) : (
                <button
                  onClick={onOpenDetail}
                  className="flex-1 bg-purple-600 hover:bg-purple-700 text-white display-font font-bold text-xs tracking-wide rounded-lg px-3 py-2 active:scale-[0.98] transition">
                  🗺️ VER DETALLE
                </button>
              )}
              <button
                onClick={onUse}
                disabled={onUseLoading}
                className="bg-gray-100 hover:bg-gray-200 disabled:opacity-50 text-gray-700 display-font font-bold text-xs tracking-wide rounded-lg px-3 py-2 active:scale-[0.98] transition"
                aria-label="Descargar GPX">
                {onUseLoading ? '…' : '📥'}
              </button>
              {canEdit && !editing && (
                <button
                  onClick={openEdit}
                  className="bg-gray-100 hover:bg-blue-50 hover:text-blue-700 text-gray-500 rounded-lg w-9 h-9 flex items-center justify-center active:scale-90 transition"
                  aria-label="Editar ruta">
                  <Edit2 size={16} strokeWidth={2.2} />
                </button>
              )}
              {isCentral && (
                <button
                  onClick={onDelete}
                  className="bg-gray-100 hover:bg-red-50 hover:text-red-700 text-gray-500 rounded-lg w-9 h-9 flex items-center justify-center active:scale-90 transition"
                  aria-label="Borrar ruta del catálogo">
                  <Trash2 size={16} strokeWidth={2.2} />
                </button>
              )}
            </div>
          </div>
        </div>
      );
    }

    // Modal con el catálogo de rutas de una provincia.
    // Accesible desde: botón en home (por provincia) o desde una grupeta (su propia provincia).
    // Cualquier visitante puede ver y descargar; solo admin central puede borrar.
    function ProvincialRoutesModal({ provinciaCode, isCentral, currentUser, currentUserId, grupetas, onUseRouteInGrupeta, onClose, showToast, adminUnlocked }) {
      const [search, setSearch] = useState('');
      const [sortBy, setSortBy] = useState('recent'); // recent | used | name
      const [bikeFilter, setBikeFilter] = useState('all'); // all | carretera | mtb | cabra | ... | unknown
      const [muniFilter, setMuniFilter] = useState('all'); // all | nombre-municipio
      const [usingRouteId, setUsingRouteId] = useState(null);
      const [useInGrupetaLoadingId, setUseInGrupetaLoadingId] = useState(null);
      // v28-oct13d: detalle de ruta con mapa Leaflet
      const [detailRoute, setDetailRoute] = useState(null);
      const { routes, loading } = usePublicRoutes(provinciaCode, true);
      const provLabel = provinceName(provinciaCode);

      // Cuenta cuántas rutas hay de cada bikeType (para mostrar solo chips relevantes)
      const counts = (function() {
        const c = { all: routes.length, unknown: 0 };
        routes.forEach(function(r) {
          const bt = r.bikeType;
          if (bt && BIKE_TYPES[bt]) c[bt] = (c[bt] || 0) + 1;
          else c.unknown += 1;
        });
        return c;
      })();

      // Lista de bikeTypes que aparecen en el catálogo
      const presentBikeTypes = Object.keys(BIKE_TYPES).filter(function(t) { return counts[t] > 0; });

      // Municipios presentes en el catálogo (con count), ordenados alfabéticamente
      const presentMunicipios = (function() {
        const m = {};
        routes.forEach(function(r) {
          const muni = (r.municipio && r.municipio.trim()) || '';
          if (muni) m[muni] = (m[muni] || 0) + 1;
        });
        return Object.keys(m).sort().map(function(name) { return { name: name, count: m[name] }; });
      })();

      const filtered = (routes || []).filter(function(r) {
        // Filtro bikeType
        if (bikeFilter !== 'all') {
          if (bikeFilter === 'unknown') {
            if (r.bikeType && BIKE_TYPES[r.bikeType]) return false;
          } else if (r.bikeType !== bikeFilter) return false;
        }
        // Filtro municipio
        if (muniFilter !== 'all') {
          if ((r.municipio && r.municipio.trim()) !== muniFilter) return false;
        }
        // Filtro texto
        if (!search.trim()) return true;
        const q = search.trim().toLowerCase();
        const name = (r.name || '').toLowerCase();
        const grup = (r.authorGrupetaName || '').toLowerCase();
        return name.indexOf(q) !== -1 || grup.indexOf(q) !== -1;
      });
      const sorted = filtered.slice().sort(function(a, b) {
        if (sortBy === 'used') return (b.usageCount || 0) - (a.usageCount || 0);
        if (sortBy === 'name') return (a.name || '').localeCompare(b.name || '');
        return (b.createdAt || 0) - (a.createdAt || 0);
      });

      function handleDelete(route) {
        if (!window.__confirmPending_route1) { window.__confirmPending_route1 = true; setTimeout(function(){ window.__confirmPending_route1=false; }, 3500); showToast('Pulsa de nuevo para borrar'); return; } window.__confirmPending_route1 = false;
        deletePublicRoute(provinciaCode, route.id, route.storagePath)
          .then(function() { showToast('Ruta borrada del catálogo'); })
          .catch(function(e) { showToast('No se pudo borrar: ' + (e.message || 'error')); });
      }

      function handleUse(route) {
        setUsingRouteId(route.id);
        downloadPublicRouteAsTrackFile(route, route.name)
          .then(function(trackFile) {
            incrementPublicRouteUsage(provinciaCode, route.id).catch(function(){});
            setUsingRouteId(null);
            // Descarga local. La integración "usar al proponer salida" es futura.
            try {
              const a = document.createElement('a');
              a.href = trackFile.data;
              a.download = trackFile.name;
              document.body.appendChild(a); a.click(); document.body.removeChild(a);
              showToast('📥 GPX descargado');
            } catch(e) { showToast('GPX preparado'); }
          })
          .catch(function(e) {
            setUsingRouteId(null);
            showToast('No se pudo descargar: ' + (e.message || 'error'));
          });
      }

      // v28-oct14: usar ruta en una de mis grupetas (precarga form de Proponer)
      function handleUseInGrupeta(route) {
        if (!onUseRouteInGrupeta) return;
        setUseInGrupetaLoadingId(route.id);
        onUseRouteInGrupeta(route, provinciaCode)
          .then(function() { setUseInGrupetaLoadingId(null); })
          .catch(function(err) {
            setUseInGrupetaLoadingId(null);
            if (err && err.message) showToast(err.message);
          });
      }

      return (
        <React.Fragment>
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-2xl tracking-wide text-purple-700 leading-tight">🗺️ RUTAS</h2>
          <p className="body-font text-[13px] text-gray-700 -mt-0.5">{provLabel}</p>
          <p className="body-font text-[11px] text-gray-500 mb-3">
            {routes.length} {routes.length === 1 ? 'ruta en el catálogo' : 'rutas en el catálogo'}
          </p>

          <div className="bg-purple-50 border border-purple-200 rounded-lg p-2.5 mb-3 body-font text-[11px] text-purple-800 leading-snug">
            💡 Las rutas se añaden solas cuando los socios proponen salidas con un GPX adjunto. Aquí puedes descargarlas para tu navegador.
          </div>

          {routes.length > 0 && (
            <div className="flex gap-2 mb-3">
              <input
                type="text"
                value={search}
                onChange={function(e) { setSearch(e.target.value); }}
                placeholder="🔍 Buscar..."
                className="flex-1 border border-gray-300 rounded-lg px-3 py-2 body-font text-sm focus:outline-none focus:border-purple-500"
              />
              <select
                value={sortBy}
                onChange={function(e) { setSortBy(e.target.value); }}
                className="border border-gray-300 rounded-lg px-2 py-2 body-font text-xs bg-white">
                <option value="recent">Recientes</option>
                <option value="used">Más usadas</option>
                <option value="name">A–Z</option>
              </select>
            </div>
          )}

          {/* v28-oct14: chips para filtrar por modalidad. Solo aparecen modalidades presentes en el catálogo. */}
          {routes.length > 0 && (presentBikeTypes.length > 0 || counts.unknown > 0) && (
            <div className="flex flex-wrap gap-1.5 mb-3">
              <button type="button" onClick={function() { setBikeFilter('all'); }}
                className={'rounded-full px-3 py-1 text-[11px] font-bold tracking-wide transition ' + (bikeFilter === 'all' ? 'bg-purple-600 text-white shadow' : 'bg-white border border-gray-300 text-gray-700 hover:border-purple-400')}>
                Todas ({counts.all})
              </button>
              {presentBikeTypes.map(function(t) {
                const b = BIKE_TYPES[t];
                const sel = bikeFilter === t;
                return (
                  <button key={t} type="button" onClick={function() { setBikeFilter(t); }}
                    style={sel ? { background: b.color, color: 'white' } : {}}
                    className={'rounded-full px-3 py-1 text-[11px] font-bold tracking-wide transition flex items-center gap-1 ' + (sel ? 'shadow' : 'bg-white border border-gray-300 text-gray-700 hover:border-purple-400')}>
                    <span>{b.emoji}</span>{b.label} ({counts[t]})
                  </button>
                );
              })}
              {counts.unknown > 0 && (
                <button type="button" onClick={function() { setBikeFilter('unknown'); }}
                  className={'rounded-full px-3 py-1 text-[11px] font-bold tracking-wide transition ' + (bikeFilter === 'unknown' ? 'bg-gray-600 text-white shadow' : 'bg-white border border-gray-300 text-gray-700 hover:border-purple-400')}>
                  ❓ Sin clasif. ({counts.unknown})
                </button>
              )}
            </div>
          )}

          {/* Chips de municipio — solo si hay más de uno */}
          {!loading && presentMunicipios.length > 1 && (
            <div className="flex flex-wrap gap-1.5 mb-3">
              <button type="button" onClick={function() { setMuniFilter('all'); }}
                className={'rounded-full px-3 py-1 text-[11px] font-bold tracking-wide transition ' + (muniFilter === 'all' ? 'bg-fuchsia-600 text-white shadow' : 'bg-white border border-gray-300 text-gray-700 hover:border-fuchsia-400')}>
                📍 Todos
              </button>
              {presentMunicipios.map(function(m) {
                const sel = muniFilter === m.name;
                return (
                  <button key={m.name} type="button" onClick={function() { setMuniFilter(sel ? 'all' : m.name); }}
                    className={'rounded-full px-3 py-1 text-[11px] font-bold tracking-wide transition ' + (sel ? 'bg-fuchsia-600 text-white shadow' : 'bg-fuchsia-50 border border-fuchsia-200 text-fuchsia-800 hover:border-fuchsia-400')}>
                    {m.name} ({m.count})
                  </button>
                );
              })}
            </div>
          )}

          {loading && (
            <div className="text-center py-8 body-font text-sm text-gray-400">Cargando rutas…</div>
          )}

          {!loading && routes.length === 0 && (
            <div className="text-center py-8 px-4">
              <div className="text-4xl mb-2">🗺️</div>
              <div className="display-font font-bold text-sm text-gray-700">Catálogo vacío</div>
              <p className="body-font text-[12px] text-gray-500 mt-1">
                Cuando alguien proponga una salida con GPX en una grupeta de {provLabel}, la ruta aparecerá aquí automáticamente.
              </p>
            </div>
          )}

          {!loading && routes.length > 0 && sorted.length === 0 && (
            <div className="text-center py-6 body-font text-sm text-gray-400">
              {search.trim()
                ? 'Ninguna ruta coincide con "' + search + '"' + (bikeFilter !== 'all' ? ' en esta modalidad.' : '.')
                : 'Ninguna ruta en esta modalidad.'}
            </div>
          )}

          <div className="space-y-3">
            {sorted.map(function(r) {
              return (
                <PublicRouteCard
                  key={r.id}
                  route={Object.assign({}, r, { _provinciaCode: provinciaCode })}
                  isCentral={isCentral}
                  currentUser={currentUser}
                  currentUserId={currentUserId}
                  adminUnlocked={adminUnlocked}
                  showToast={showToast}
                  onUse={function() { handleUse(r); }}
                  onDelete={function() { handleDelete(r); }}
                  onUseLoading={usingRouteId === r.id}
                  onUseInGrupeta={onUseRouteInGrupeta ? function() { handleUseInGrupeta(r); } : null}
                  onUseInGrupetaLoading={useInGrupetaLoadingId === r.id}
                  onOpenDetail={function() { setDetailRoute(r); }}
                />
              );
            })}
          </div>
        </Modal>

        {/* v28-oct13d: modal de detalle con mapa Leaflet — HERMANO, no anidado */}
        {detailRoute && (
          <RouteDetailModal
            route={detailRoute}
            provinciaCode={provinciaCode}
            isCentral={isCentral}
            onClose={function() { setDetailRoute(null); }}
            showToast={showToast} />
        )}
        </React.Fragment>
      );
    }

    // v28-oct15: Modal para SUBIR UNA RUTA al catálogo directamente (sin crear salida).
    // Disponible para cualquier usuario logueado desde el home.
    // Pide: GPX + nombre + modalidad + descripción (+ provincia si no se puede derivar).
    function UploadRouteModal({ currentUser, currentUserId, grupetas, onClose, showToast }) {
      const [gpxFile, setGpxFile] = useState(null);   // { name, gpxText }
      const [reading, setReading] = useState(false);
      const [rName, setRName] = useState('');
      const [rBikeType, setRBikeType] = useState('carretera');
      const [rDesc, setRDesc] = useState('');
      const [rMunicipio, setRMunicipio] = useState('');
      const [saving, setSaving] = useState(false);

      // Lista de municipios de la tabla AEMET para el datalist (nombres normalizados → bonitos)
      // Usamos las claves de AEMET_MUNICIPIOS capitalizando cada palabra
      const municipioOptions = (function() {
        const seen = {};
        Object.keys(AEMET_MUNICIPIOS).forEach(function(k) {
          const pretty = k.split(' ').map(function(w) {
            return w.charAt(0).toUpperCase() + w.slice(1);
          }).join(' ');
          const code = AEMET_MUNICIPIOS[k];
          if (!seen[code]) seen[code] = pretty; // deduplicar por código INE
        });
        return Object.values(seen).sort();
      })();

      // Provincia autodetectada desde municipio
      const detectedProvince = (function() {
        if (!rMunicipio.trim()) return null;
        const code = provinceCodeFromCity(rMunicipio.trim());
        return code ? { code: code, name: provinceName(code) } : null;
      })();

      // Provincias candidatas del usuario (para fallback manual)
      const userProvinces = (function() {
        const seen = {};
        Object.values(grupetas || {}).forEach(function(g) {
          const aliases = [g.name, g.shortName].filter(Boolean);
          if (typeof userHasAnyClub === 'function' && userHasAnyClub(currentUser, aliases)) {
            const code = grupetaProvinceCode(g);
            if (code && !seen[code]) seen[code] = { code: code, name: provinceName(code) };
          }
        });
        return Object.values(seen);
      })();

      // rProvince: si el municipio detecta provincia, esa gana; si no, selector manual
      const [rProvinceManual, setRProvinceManual] = useState(
        userProvinces.length === 1 ? userProvinces[0].code : ''
      );
      const rProvince = detectedProvince ? detectedProvince.code : rProvinceManual;

      const allProvinces = Object.keys(PROVINCE_NAMES).map(function(code) {
        return { code: code, name: PROVINCE_NAMES[code] };
      }).sort(function(a, b) { return a.name.localeCompare(b.name); });

      function handleFile(e) {
        const f = e.target.files[0];
        if (!f) return;
        if (!/\.gpx$/i.test(f.name)) { showToast('Solo archivos .gpx'); return; }
        if (f.size > 8 * 1024 * 1024) { showToast("Archivo demasiado grande (máx 8 MB)"); return; }
        setReading(true);
        const r = new FileReader();
        r.onload = function(ev) {
          const txt = ev.target.result;
          const pts = parseGpxPoints(txt);
          if (!pts || pts.length < 2) {
            showToast('El GPX no tiene recorrido válido');
            setReading(false); return;
          }
          setGpxFile({ name: f.name, gpxText: txt });
          if (!rName.trim()) {
            const base = f.name.replace(/\.gpx$/i, '').replace(/[_-]+/g, ' ').trim();
            setRName(base.slice(0, 80));
          }
          setReading(false);
        };
        r.onerror = function() { showToast('Error al leer el archivo'); setReading(false); };
        r.readAsText(f);
      }

      function submit() {
        if (!gpxFile) { showToast('Adjunta un archivo .gpx'); return; }
        if (!rName.trim()) { showToast('Ponle un nombre a la ruta'); return; }
        if (!rProvince) { showToast('Indica el municipio o elige la provincia'); return; }
        setSaving(true);
        uploadStandaloneRoute({
          provinciaCode: rProvince,
          gpxText: gpxFile.gpxText,
          name: rName,
          bikeType: rBikeType,
          description: rDesc,
          municipio: rMunicipio.trim(),
          currentUserId: currentUserId,
          currentUser: currentUser
        }).then(function() {
          setSaving(false);
          showToast('✅ Ruta subida al catálogo');
          onClose();
        }).catch(function(err) {
          setSaving(false);
          showToast('No se pudo subir: ' + (err.message || 'error'));
        });
      }

      const fieldLabel = "display-font font-bold text-[11px] tracking-wide text-gray-500 uppercase mb-1 block";
      const inp = "w-full bg-gray-50 border border-gray-200 rounded-lg px-3 py-2.5 body-font text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent";

      return (
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-xl tracking-wide text-purple-700">📤 SUBIR RUTA</h2>
          <p className="body-font text-[12px] text-gray-500 mt-1 leading-snug">
            Comparte un recorrido con el catálogo de tu provincia. Cualquiera de tu zona podrá usarlo en sus salidas.
          </p>

          <div className="mt-4 space-y-3">
            {/* Archivo GPX */}
            <div>
              <label className={fieldLabel}>Archivo GPX</label>
              <label className={"flex items-center justify-center gap-2 w-full border-2 border-dashed rounded-lg px-3 py-4 cursor-pointer transition " + (gpxFile ? "border-green-300 bg-green-50 text-green-700" : "border-purple-200 bg-purple-50 hover:bg-purple-100 text-purple-700")}>
                <span className="display-font font-bold text-xs tracking-wide">
                  {reading ? 'LEYENDO…' : gpxFile ? ('✅ ' + gpxFile.name) : '📎 ELEGIR ARCHIVO .GPX'}
                </span>
                <input type="file" accept=".gpx" onChange={handleFile} className="hidden" disabled={reading || saving} />
              </label>
              <div className="body-font text-[10px] text-gray-400 mt-0.5">Solo .gpx · máx 8 MB</div>
            </div>

            {/* Nombre */}
            <div>
              <label className={fieldLabel}>Nombre de la ruta</label>
              <input type="text" value={rName} maxLength={80}
                onChange={function(e) { setRName(e.target.value); }}
                placeholder="Ej: Subida al Faro de Cabo de Palos" className={inp} />
            </div>

            {/* MUNICIPIO — primero, con datalist de sugerencias AEMET */}
            <div>
              <label className={fieldLabel}>Municipio <span className="text-gray-400 normal-case font-normal">(opcional)</span></label>
              <input type="text" value={rMunicipio} maxLength={60} list="muni-suggestions"
                onChange={function(e) { setRMunicipio(e.target.value); }}
                placeholder="Ej: Cartagena, Lorca, Mazarrón…" className={inp} />
              <datalist id="muni-suggestions">
                {municipioOptions.map(function(m) { return <option key={m} value={m} />; })}
              </datalist>
            </div>

            {/* PROVINCIA — siempre visible, se autocompleta si el municipio la detecta */}
            <div>
              <label className={fieldLabel}>Provincia</label>
              <select value={rProvince} onChange={function(e) { setRProvinceManual(e.target.value); }} className={inp}>
                <option value="">— Elige provincia —</option>
                {userProvinces.length > 0 && (
                  <optgroup label="Tus provincias">
                    {userProvinces.map(function(p) {
                      return <option key={'u' + p.code} value={p.code}>{p.name}</option>;
                    })}
                  </optgroup>
                )}
                <optgroup label="Todas">
                  {allProvinces.map(function(p) {
                    return <option key={p.code} value={p.code}>{p.name}</option>;
                  })}
                </optgroup>
              </select>
              {detectedProvince && (
                <div className="body-font text-[11px] text-green-600 mt-0.5">✅ Detectada automáticamente</div>
              )}
            </div>

            {/* Modalidad */}
            <div>
              <label className={fieldLabel}>Modalidad</label>
              <div className="flex flex-wrap gap-2">
                {Object.keys(BIKE_TYPES).map(function(key) {
                  const bt = BIKE_TYPES[key];
                  const active = rBikeType === key;
                  return (
                    <button key={key} type="button"
                      onClick={function() { setRBikeType(key); }}
                      className={"display-font font-bold text-[11px] tracking-wide rounded-full px-3 py-1.5 border transition active:scale-95 " + (active ? "bg-purple-600 text-white border-purple-600" : "bg-white text-gray-600 border-gray-200 hover:border-purple-300")}>
                      {bt.emoji} {bt.label}
                    </button>
                  );
                })}
              </div>
            </div>

            {/* Descripción */}
            <div>
              <label className={fieldLabel}>Descripción <span className="text-gray-400 normal-case font-normal">(opcional)</span></label>
              <textarea value={rDesc} maxLength={500} rows={3}
                onChange={function(e) { setRDesc(e.target.value); }}
                placeholder="Cuenta cómo es: terreno, dificultad, puntos de avituallamiento…"
                className={inp + " resize-none"} />
              <div className="body-font text-[10px] text-gray-400 mt-0.5 text-right">{rDesc.length}/500</div>
            </div>
          </div>

          <div className="flex gap-2 mt-5">
            <button onClick={onClose} disabled={saving}
              className="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-600 display-font font-bold text-xs tracking-wide rounded-lg px-3 py-2.5 active:scale-[0.98] transition">
              CANCELAR
            </button>
            <button onClick={submit} disabled={saving || reading}
              className="flex-1 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-300 text-white display-font font-bold text-xs tracking-wide rounded-lg px-3 py-2.5 active:scale-[0.98] transition">
              {saving ? 'SUBIENDO…' : '📤 SUBIR AL CATÁLOGO'}
            </button>
          </div>
        </Modal>
      );
    }

    // Botones por provincia para mostrar en el home / portada.
    // Cada uno abre ProvincialRoutesModal con su código de provincia.
    // v28-oct13b: rediseño solicitado por Juan — fucsia + letras negras + texto
    // explicativo + visible SIEMPRE (también cuando aún no hay rutas en ninguna).
    function ProvincesHomeButtons({ onOpenProvince, onUploadRoute, grupetas }) {
      const { provinces, loading } = useProvincesWithRoutes(true);
      // Provincias derivadas de las grupetas existentes (para mostrar algo
      // aunque aún no haya rutas autogeneradas).
      const provincesFromGrupetas = (function() {
        const seen = {};
        Object.values(grupetas || {}).forEach(function(g) {
          const code = grupetaProvinceCode(g);
          if (!code) return;
          if (!seen[code]) seen[code] = { code: code, name: provinceName(code), count: 0 };
        });
        return Object.values(seen);
      })();
      // Fusionamos: las que tienen rutas (con count) y las que no (count = 0).
      const byCode = {};
      provincesFromGrupetas.forEach(function(p) { byCode[p.code] = { code: p.code, name: p.name, count: 0, municipios: [] }; });
      (provinces || []).forEach(function(p) { byCode[p.code] = { code: p.code, name: p.name, count: p.count, municipios: p.municipios || [] }; });
      const merged = Object.values(byCode).sort(function(a, b) {
        if (b.count !== a.count) return b.count - a.count;
        return a.name.localeCompare(b.name);
      });
      if (loading && merged.length === 0) return null;
      return (
        <div className="mt-8 bg-gradient-to-br from-fuchsia-500 to-pink-500 rounded-2xl p-4 shadow-lg">
          <h3 className="display-font font-bold text-base tracking-wide text-black flex items-center gap-2">
            🗺️ RUTAS POR PROVINCIAS
          </h3>
          <p className="body-font text-[12px] text-black/80 mt-1 leading-snug">
            Catálogo de GPX compartido entre todas las grupetas de tu provincia. Las rutas se añaden solas cuando alguien propone una salida con un archivo .gpx adjunto. <strong>Pulsa tu provincia para ver las rutas disponibles.</strong>
          </p>
          {onUploadRoute && (
            <div className="mt-3">
              <button
                onClick={onUploadRoute}
                className="w-full bg-black/85 hover:bg-black text-white display-font font-bold text-xs tracking-wide rounded-t-xl px-3 py-2.5 flex items-center justify-center gap-2 active:scale-[0.98] transition shadow">
                📤 SUBIR UNA RUTA AL CATÁLOGO
              </button>
              <button onClick={function(e) { e.stopPropagation(); window._gnav && window._gnav.openHelp && window._gnav.openHelp('help-step-18'); }}
                className="w-full bg-fuchsia-900/40 rounded-b-xl py-1 text-[10px] font-bold text-fuchsia-200 display-font tracking-wider active:opacity-70 transition border-none">
                ❓ ¿CÓMO SE HACE?
              </button>
            </div>
          )}
          {merged.length === 0 ? (
            <div className="mt-3 bg-white/30 rounded-lg p-3 body-font text-[12px] text-black/80">
              Aún no hay provincias con grupetas activas. Cuando se cree la primera grupeta con ciudad/provincia, aparecerá aquí su catálogo de rutas.
            </div>
          ) : (
            <div className="grid grid-cols-2 gap-2 mt-3">
              {merged.map(function(p) {
                const munis = p.municipios || [];
                return (
                  <button
                    key={p.code}
                    onClick={function() { onOpenProvince(p.code); }}
                    className="bg-white hover:bg-yellow-50 rounded-xl p-3 text-left active:scale-[0.98] transition shadow-sm">
                    <div className="display-font font-bold text-sm tracking-wide text-black">{p.name}</div>
                    <div className="body-font text-[11px] text-black/60 mt-0.5">
                      {p.count === 0 ? 'Sin rutas todavía' : (p.count + ' ' + (p.count === 1 ? 'ruta' : 'rutas'))}
                    </div>
                    {munis.length > 0 && (
                      <div className="mt-1.5 flex flex-wrap gap-1">
                        {munis.map(function(m) {
                          return (
                            <span key={m.name} className="bg-fuchsia-100 text-fuchsia-800 body-font text-[9px] font-bold px-1.5 py-0.5 rounded-full">
                              {m.name} ({m.count})
                            </span>
                          );
                        })}
                      </div>
                    )}
                  </button>
                );
              })}
            </div>
          )}
        </div>
      );
    }

    // ======================================================================
    // v28-oct13c: MIGRAR RUTAS HISTÓRICAS AL CATÁLOGO PROVINCIAL
    // ----------------------------------------------------------------------
    // Solo admin central. Recorre todas las grupetas y todas sus salidas con
    // GPX adjunto, y las sube al catálogo de su provincia.
    // - Idempotente: marca cada salida con routeMigrated=true para no repetir
    // - Detección de duplicados: si en la provincia ya hay una ruta con el
    //   mismo nombre normalizado y distancia ±0.5 km, se omite (cuenta como
    //   duplicada) y se incrementa el usageCount de la existente.
    // - Sin filtros de nombre: todas las salidas se migran con su nombre tal cual.
    // ======================================================================

    // Carga el snapshot completo de publicRoutes/{provincia} a un mapa
    // {nombreNormalizado__distanciaRedondeada: routeId} para detectar duplicados.
    function _loadExistingPublicRoutesIndex(provinciaCode) {
      return db.ref('publicRoutes/' + provinciaCode).once('value').then(function(snap) {
        const val = snap.val() || {};
        const idx = {};
        Object.keys(val).forEach(function(rid) {
          const r = val[rid];
          if (!r || !r.name) return;
          const k = normalizeName(r.name) + '__' + Math.round((r.distanceKm || 0));
          if (!idx[k]) idx[k] = rid;
        });
        return idx;
      });
    }

    function MigrateRoutesModal({ grupetas, currentUserId, currentUser, allUsers, onClose, showToast }) {
      const [phase, setPhase] = useState('idle'); // idle | scanning | running | done
      const [scanResult, setScanResult] = useState(null); // {total, migrable, skipped: [{reason, count}]}
      const [progress, setProgress] = useState({ done: 0, total: 0, uploaded: 0, dupes: 0, errors: 0, current: '' });
      const [log, setLog] = useState([]);
      const cancelRef = React.useRef(false);

      // Lee todas las salidas de RTDB y prepara la lista de tareas.
      function scan() {
        setPhase('scanning');
        setLog([]);
        db.ref('grupetas').once('value').then(function(snap) {
          const allGrupetas = snap.val() || {};
          const tasks = []; // {gid, gname, prov, dateKey, ride}
          const skipReasons = { noProvince: 0, noGpx: 0, alreadyMigrated: 0, emptyName: 0, alreadyHasRouteId: 0 };
          Object.keys(allGrupetas).forEach(function(gid) {
            const g = Object.assign({ id: gid }, allGrupetas[gid] || {});
            const prov = grupetaProvinceCode(g);
            const rides = (g && g.rides) || {};
            Object.keys(rides).forEach(function(dk) {
              const arr = rides[dk] || [];
              arr.forEach(function(r) {
                if (!r || !r.trackFile || !r.trackFile.data) { skipReasons.noGpx++; return; }
                const fname = (r.trackFile.name || '').toLowerCase();
                if (!/\.gpx$/i.test(fname)) { skipReasons.noGpx++; return; }
                if (r.routeMigrated) { skipReasons.alreadyMigrated++; return; }
                if (!(r.route || '').trim()) { skipReasons.emptyName++; return; }
                if (!prov) { skipReasons.noProvince++; return; }
                tasks.push({
                  gid: gid, gname: g.name || '', prov: prov,
                  dateKey: dk, ride: r
                });
              });
            });
          });
          setScanResult({
            total: tasks.length,
            skips: skipReasons,
            tasks: tasks
          });
          setPhase('idle');
        }).catch(function(err) {
          showToast('Error escaneando: ' + err.message);
          setPhase('idle');
        });
      }

      // Ejecuta la migración secuencialmente (1 a la vez, así no pisamos Storage
      // ni la RTDB con burstss y podemos parar si hace falta).
      function run() {
        if (!scanResult || !scanResult.tasks.length) return;
        cancelRef.current = false;
        setPhase('running');
        const tasks = scanResult.tasks;
        setProgress({ done: 0, total: tasks.length, uploaded: 0, dupes: 0, errors: 0, current: '' });
        const counters = { done: 0, uploaded: 0, dupes: 0, errors: 0 };
        // Indexes por provincia para detección de duplicados.
        const indexesByProvince = {};

        function step(i) {
          if (cancelRef.current) {
            setPhase('done');
            return;
          }
          if (i >= tasks.length) {
            setPhase('done');
            return;
          }
          const t = tasks[i];
          const ride = t.ride;
          setProgress({
            done: counters.done, total: tasks.length,
            uploaded: counters.uploaded, dupes: counters.dupes, errors: counters.errors,
            current: (ride.route || '?') + ' [' + t.gname + ']'
          });

          // 1) Asegurar índice de duplicados de esta provincia
          const ensureIndex = indexesByProvince[t.prov]
            ? Promise.resolve(indexesByProvince[t.prov])
            : _loadExistingPublicRoutesIndex(t.prov).then(function(idx) {
                indexesByProvince[t.prov] = idx;
                return idx;
              });

          ensureIndex.then(function(idx) {
            // 2) Decodificar GPX y calcular distancia para detectar duplicado
            const gpxText = dataURLToText(ride.trackFile.data);
            if (!gpxText) throw new Error('GPX vacío');
            const pts = parseGpxPointsWithEle(gpxText);
            if (pts.length < 2) throw new Error('GPX sin trayecto');
            const stats = computeRouteStats(pts);
            const dupKey = normalizeName((ride.route || '').trim()) + '__' + Math.round(stats.distanceKm);
            const dupKeyAdj1 = normalizeName((ride.route || '').trim()) + '__' + (Math.round(stats.distanceKm) - 1);
            const dupKeyAdj2 = normalizeName((ride.route || '').trim()) + '__' + (Math.round(stats.distanceKm) + 1);
            const existingId = idx[dupKey] || idx[dupKeyAdj1] || idx[dupKeyAdj2];
            if (existingId) {
              // Duplicado: incrementar uso de la existente y marcar la salida como migrada
              counters.dupes++; counters.done++;
              setLog(function(l) { return l.concat(['↪ Duplicada: ' + (ride.route || '?') + ' → +1 uso']); });
              return Promise.all([
                incrementPublicRouteUsage(t.prov, existingId).catch(function(){}),
                db.ref('grupetas/' + t.gid + '/rides/' + t.dateKey).once('value').then(function(s) {
                  const list = s.val() || [];
                  const newList = list.map(function(r) {
                    return (r && r.id === ride.id) ? Object.assign({}, r, { routeMigrated: true }) : r;
                  });
                  return db.ref('grupetas/' + t.gid + '/rides/' + t.dateKey).set(newList);
                })
              ]).then(function() {
                setTimeout(function() { step(i + 1); }, 50);
              });
            }

            // 3) Subir nueva ruta al catálogo
            const authorName = (function() {
              const u = allUsers && allUsers[ride.proposedBy];
              if (u && typeof getUserDisplay === 'function') return getUserDisplay(u);
              return (u && u.nombre) || 'Autor';
            })();

            return uploadPublicRouteFromRide({
              provinciaCode: t.prov,
              gpxText: gpxText,
              name: (ride.route || '').trim(),
              authorId: ride.proposedBy || currentUserId,
              authorName: authorName,
              authorGrupetaId: t.gid,
              authorGrupetaName: t.gname,
              sourceRideId: ride.id,
              sourceRideDate: t.dateKey,
              bikeType: ride.bikeType || null
            }).then(function(routeId) {
              if (routeId) {
                counters.uploaded++; counters.done++;
                // Añadir al índice para que duplicados posteriores en esta misma migración no se cuelen
                idx[dupKey] = routeId;
                setLog(function(l) { return l.concat(['✓ ' + (ride.route || '?') + ' → ' + provinceName(t.prov)]); });
                // Marcar salida como migrada
                return db.ref('grupetas/' + t.gid + '/rides/' + t.dateKey).once('value').then(function(s) {
                  const list = s.val() || [];
                  const newList = list.map(function(r) {
                    return (r && r.id === ride.id) ? Object.assign({}, r, { routeMigrated: true }) : r;
                  });
                  return db.ref('grupetas/' + t.gid + '/rides/' + t.dateKey).set(newList);
                });
              } else {
                counters.errors++; counters.done++;
                setLog(function(l) { return l.concat(['✗ ' + (ride.route || '?') + ' (no se pudo subir)']); });
              }
            }).then(function() {
              setTimeout(function() { step(i + 1); }, 50);
            });
          }).catch(function(err) {
            counters.errors++; counters.done++;
            setLog(function(l) { return l.concat(['✗ ' + (ride.route || '?') + ': ' + (err.message || 'error')]); });
            setTimeout(function() { step(i + 1); }, 50);
          });
        }
        step(0);
      }

      return (
        <Modal onClose={phase === 'running' ? function(){} : onClose}>
          <h2 className="display-font font-bold text-xl tracking-wide text-purple-700 mb-1">🔄 MIGRAR RUTAS GPX</h2>
          <p className="body-font text-[12px] text-gray-500 mb-3">
            Sube al catálogo provincial todas las rutas GPX de las salidas existentes (futuras + históricas) en todas las grupetas.
            Detecta duplicados por nombre + distancia. Marca cada salida con <code>routeMigrated</code> para no repetir.
          </p>

          {phase === 'idle' && !scanResult && (
            <button onClick={scan}
              className="w-full bg-purple-600 hover:bg-purple-700 text-white display-font font-bold text-sm tracking-wide rounded-xl px-4 py-3 active:scale-[0.98] transition">
              1️⃣ ESCANEAR SALIDAS
            </button>
          )}

          {phase === 'scanning' && (
            <div className="text-center py-6 body-font text-sm text-gray-500">Escaneando todas las grupetas…</div>
          )}

          {phase === 'idle' && scanResult && (
            <React.Fragment>
              <div className="bg-purple-50 border border-purple-200 rounded-lg p-3 mb-3">
                <div className="display-font font-bold text-base text-purple-800">
                  {scanResult.total} {scanResult.total === 1 ? 'ruta migrable' : 'rutas migrables'}
                </div>
                <div className="body-font text-[11px] text-purple-600 mt-1.5 leading-relaxed">
                  Omitidas:<br/>
                  · {scanResult.skips.noGpx} sin GPX adjunto<br/>
                  · {scanResult.skips.emptyName} con nombre de ruta vacío<br/>
                  · {scanResult.skips.noProvince} en grupetas sin provincia<br/>
                  · {scanResult.skips.alreadyMigrated} ya migradas antes
                </div>
              </div>
              {scanResult.total === 0 ? (
                <div className="text-center py-2 body-font text-sm text-gray-500">No hay nada que migrar 🤷</div>
              ) : (
                <button onClick={run}
                  className="w-full bg-purple-600 hover:bg-purple-700 text-white display-font font-bold text-sm tracking-wide rounded-xl px-4 py-3 active:scale-[0.98] transition">
                  2️⃣ EMPEZAR MIGRACIÓN
                </button>
              )}
              <button onClick={onClose}
                className="w-full mt-2 bg-gray-100 hover:bg-gray-200 text-gray-700 display-font font-bold text-xs tracking-wide rounded-xl px-4 py-2.5 active:scale-[0.98] transition">
                CANCELAR
              </button>
            </React.Fragment>
          )}

          {phase === 'running' && (
            <React.Fragment>
              <div className="bg-purple-50 border border-purple-200 rounded-lg p-3 mb-3">
                <div className="display-font font-bold text-base text-purple-800">
                  {progress.done} / {progress.total}
                </div>
                <div className="w-full bg-white rounded-full h-2 mt-2 overflow-hidden">
                  <div className="bg-purple-600 h-2 transition-all" style={{ width: (progress.total ? (progress.done / progress.total * 100) : 0) + '%' }} />
                </div>
                <div className="body-font text-[11px] text-purple-600 mt-2">
                  ✓ {progress.uploaded} subidas · ↪ {progress.dupes} duplicadas · ✗ {progress.errors} errores
                </div>
                {progress.current && (
                  <div className="body-font text-[10px] text-gray-500 mt-1 truncate">→ {progress.current}</div>
                )}
              </div>
              <button onClick={function() { cancelRef.current = true; }}
                className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 display-font font-bold text-xs tracking-wide rounded-xl px-4 py-2.5 active:scale-[0.98] transition">
                PARAR
              </button>
            </React.Fragment>
          )}

          {phase === 'done' && (
            <React.Fragment>
              <div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-3">
                <div className="display-font font-bold text-base text-green-800">✅ Migración completada</div>
                <div className="body-font text-[12px] text-green-700 mt-1.5">
                  ✓ {progress.uploaded} rutas subidas · ↪ {progress.dupes} duplicadas · ✗ {progress.errors} errores
                </div>
              </div>
              <button onClick={onClose}
                className="w-full bg-purple-600 hover:bg-purple-700 text-white display-font font-bold text-sm tracking-wide rounded-xl px-4 py-3 active:scale-[0.98] transition">
                CERRAR
              </button>
            </React.Fragment>
          )}

          {log.length > 0 && (phase === 'running' || phase === 'done') && (
            <div className="mt-3 bg-gray-50 border border-gray-200 rounded-lg p-2 max-h-40 overflow-y-auto">
              {log.slice(-50).map(function(line, idx) {
                return <div key={line + '-' + idx} className="body-font text-[10px] text-gray-600 leading-tight font-mono">{line}</div>;
              })}
            </div>
          )}
        </Modal>
      );
    }

    // ======================================================================
    // v260-fix-bandwidth: MIGRACIÓN — sacar el GPX/FIT/TCX embebido de /grupetas
    // ----------------------------------------------------------------------
    // Herramienta de un solo uso (admin central) para los datos YA EXISTENTES
    // en producción. El código nuevo ya no guarda trackFile.data inline, pero
    // eso solo afecta a salidas/plantillas creadas o editadas DE AHORA EN
    // ADELANTE — lo que ya está en /grupetas sigue pesando lo mismo hasta que
    // se migra explícitamente. Por eso esta herramienta es necesaria.
    //
    // Para cada salida/plantilla con trackFile.data todavía inline:
    //   1) Sube el archivo a /rideTracks/{grupetaId}/{key} (escritura simple).
    //   2) Solo si (1) tuvo éxito, relee el nodo en /grupetas (fresco, por si
    //      cambió mientras tanto) y sustituye trackFile por la versión ligera
    //      {name,size,type,hasData,trackKey} — aprovechando también para
    //      calcular distanceKm/elevationM si faltan (así la dificultad sigue
    //      funcionando sin necesidad de volver a leer el GPX).
    // Si el paso (1) falla, no se toca nada de esa salida — seguro de repetir
    // tantas veces como haga falta (los ya migrados se saltan automáticamente).
    // ======================================================================
    function MigrateRideTracksModal({ onClose, showToast }) {
      const [phase, setPhase] = useState('idle'); // idle | scanning | running | done
      const [scanResult, setScanResult] = useState(null); // {total, estMB, tasks}
      const [progress, setProgress] = useState({ done: 0, total: 0, migrated: 0, skipped: 0, errors: 0, current: '' });
      const [log, setLog] = useState([]);
      const cancelRef = React.useRef(false);

      function scan() {
        setPhase('scanning');
        setLog([]);
        db.ref('grupetas').once('value').then(function(snap) {
          const allGrupetas = snap.val() || {};
          const tasks = []; // {gid, gname, kind:'ride'|'template', dateKey, id, label}
          let estBytes = 0;
          Object.keys(allGrupetas).forEach(function(gid) {
            const g = allGrupetas[gid] || {};
            const rides = g.rides || {};
            Object.keys(rides).forEach(function(dk) {
              const arr = rides[dk];
              const list = Array.isArray(arr) ? arr : Object.values(arr || {});
              list.forEach(function(r) {
                if (r && r.trackFile && r.trackFile.data) {
                  tasks.push({ gid: gid, gname: g.name || gid, kind: 'ride', dateKey: dk, id: r.id, label: (r.route || 'Sin ruta') + ' · ' + dk + ' [' + (g.name || gid) + ']' });
                  estBytes += (r.trackFile.data.length || 0);
                }
              });
            });
            const templates = g.templates || {};
            Object.keys(templates).forEach(function(tid) {
              const t = templates[tid];
              if (t && t.trackFile && t.trackFile.data) {
                tasks.push({ gid: gid, gname: g.name || gid, kind: 'template', id: tid, label: '🗂️ ' + (t.name || 'Plantilla') + ' [' + (g.name || gid) + ']' });
                estBytes += (t.trackFile.data.length || 0);
              }
            });
          });
          setScanResult({ total: tasks.length, estMB: (estBytes / 1024 / 1024).toFixed(1), tasks: tasks });
          setPhase('idle');
        }).catch(function(err) {
          showToast('Error escaneando: ' + err.message);
          setPhase('idle');
        });
      }

      // Calcula distanceKm/elevationM desde el GPX si faltan (best-effort, no bloqueante).
      function computeStatsIfMissing(trackFile, existing) {
        if (existing && existing.distanceKm && existing.elevationM != null) return existing;
        try {
          const isGpx = /gpx/i.test((trackFile.type || '') + ' ' + (trackFile.name || ''));
          if (!isGpx) return existing || {};
          const pts = parseGpxPointsWithEle(dataURLToText(trackFile.data));
          if (pts.length < 2) return existing || {};
          const st = computeRouteStats(pts);
          return { distanceKm: st.distanceKm, elevationM: st.elevationM };
        } catch (e) { return existing || {}; }
      }

      function run() {
        if (!scanResult || !scanResult.tasks.length) return;
        cancelRef.current = false;
        setPhase('running');
        const tasks = scanResult.tasks;
        setProgress({ done: 0, total: tasks.length, migrated: 0, skipped: 0, errors: 0, current: '' });
        const counters = { done: 0, migrated: 0, skipped: 0, errors: 0 };

        function step(i) {
          if (cancelRef.current || i >= tasks.length) { setPhase('done'); return; }
          const t = tasks[i];
          setProgress({ done: counters.done, total: tasks.length, migrated: counters.migrated, skipped: counters.skipped, errors: counters.errors, current: t.label });

          const nodePath = t.kind === 'ride'
            ? ('grupetas/' + t.gid + '/rides/' + t.dateKey)
            : ('grupetas/' + t.gid + '/templates/' + t.id);

          // 1) Releer fresco por si cambió desde el escaneo.
          db.ref(nodePath).once('value').then(function(s) {
            const val = s.val();
            let targetObj = null;
            if (t.kind === 'ride') {
              const list = Array.isArray(val) ? val : Object.values(val || {});
              targetObj = list.find(function(r) { return r && r.id === t.id; });
            } else {
              targetObj = val;
            }
            if (!targetObj || !targetObj.trackFile || !targetObj.trackFile.data) {
              // Ya migrado (por otra ejecución) o ya no existe: nada que hacer.
              counters.skipped++; counters.done++;
              setLog(function(l) { return l.concat(['↪ Ya migrado / no encontrado: ' + t.label]); });
              setTimeout(function() { step(i + 1); }, 30);
              return;
            }
            const stats = computeStatsIfMissing(targetObj.trackFile, { distanceKm: targetObj.distanceKm, elevationM: targetObj.elevationM });
            const explicitKey = (t.kind === 'ride' ? ('ride_' + t.id + '_migr') : (t.id + '_track_migr'));
            // 2) Subir el archivo pesado a /rideTracks PRIMERO.
            prepareTrackFileForSave(t.gid, targetObj.trackFile, explicitKey).then(function(slimTrack) {
              // 3) Solo si (2) fue bien, parchear /grupetas con la versión ligera.
              if (t.kind === 'ride') {
                return db.ref(nodePath).once('value').then(function(s2) {
                  const list2 = Array.isArray(s2.val()) ? s2.val() : Object.values(s2.val() || {});
                  const newList = list2.map(function(r) {
                    if (!r || r.id !== t.id) return r;
                    const patched = Object.assign({}, r, { trackFile: slimTrack });
                    if (stats.distanceKm) patched.distanceKm = stats.distanceKm;
                    if (stats.elevationM != null) patched.elevationM = stats.elevationM;
                    return patched;
                  });
                  return db.ref(nodePath).set(newList);
                });
              } else {
                const patch = { trackFile: slimTrack };
                if (stats.distanceKm) patch.distanceKm = stats.distanceKm;
                if (stats.elevationM != null) patch.elevationM = stats.elevationM;
                return db.ref(nodePath).update(patch);
              }
            }).then(function() {
              counters.migrated++; counters.done++;
              setLog(function(l) { return l.concat(['✓ ' + t.label]); });
              setTimeout(function() { step(i + 1); }, 40);
            }).catch(function(err) {
              counters.errors++; counters.done++;
              setLog(function(l) { return l.concat(['✗ ' + t.label + ': ' + (err.message || 'error')]); });
              setTimeout(function() { step(i + 1); }, 40);
            });
          }).catch(function(err) {
            counters.errors++; counters.done++;
            setLog(function(l) { return l.concat(['✗ ' + t.label + ' (leyendo): ' + (err.message || 'error')]); });
            setTimeout(function() { step(i + 1); }, 40);
          });
        }
        step(0);
      }

      return (
        <Modal onClose={phase === 'running' ? function(){} : onClose}>
          <h2 className="display-font font-bold text-xl tracking-wide text-rose-700 mb-1">🪶 ALIGERAR GRUPETAS (GPX)</h2>
          <p className="body-font text-[12px] text-gray-500 mb-3">
            Saca el archivo GPX/FIT/TCX embebido de las salidas y plantillas ya existentes
            (hasta 8 MB cada uno) fuera de <code>/grupetas</code>, a un nodo aparte que solo
            se lee cuando hace falta. Esto es lo que reduce el consumo de descargas de Firebase.
            Seguro de repetir: lo ya migrado se salta solo.
          </p>

          {phase === 'idle' && !scanResult && (
            <button onClick={scan}
              className="w-full bg-rose-700 hover:bg-rose-800 text-white display-font font-bold text-sm tracking-wide rounded-xl px-4 py-3 active:scale-[0.98] transition">
              1️⃣ ESCANEAR
            </button>
          )}

          {phase === 'scanning' && (
            <div className="text-center py-6 body-font text-sm text-gray-500">Escaneando todas las grupetas…</div>
          )}

          {phase === 'idle' && scanResult && (
            <React.Fragment>
              <div className="bg-rose-50 border border-rose-200 rounded-lg p-3 mb-3">
                <div className="display-font font-bold text-base text-rose-800">
                  {scanResult.total} {scanResult.total === 1 ? 'archivo por migrar' : 'archivos por migrar'}
                </div>
                <div className="body-font text-[11px] text-rose-600 mt-1.5">
                  ≈ {scanResult.estMB} MB que dejarán de descargarse en cada sincronización inicial.
                </div>
              </div>
              {scanResult.total === 0 ? (
                <div className="text-center py-2 body-font text-sm text-gray-500">No hay nada que migrar 🎉</div>
              ) : (
                <button onClick={run}
                  className="w-full bg-rose-700 hover:bg-rose-800 text-white display-font font-bold text-sm tracking-wide rounded-xl px-4 py-3 active:scale-[0.98] transition">
                  2️⃣ EMPEZAR MIGRACIÓN
                </button>
              )}
              <button onClick={onClose}
                className="w-full mt-2 bg-gray-100 hover:bg-gray-200 text-gray-700 display-font font-bold text-xs tracking-wide rounded-xl px-4 py-2.5 active:scale-[0.98] transition">
                CANCELAR
              </button>
            </React.Fragment>
          )}

          {phase === 'running' && (
            <React.Fragment>
              <div className="bg-rose-50 border border-rose-200 rounded-lg p-3 mb-3">
                <div className="display-font font-bold text-base text-rose-800">
                  {progress.done} / {progress.total}
                </div>
                <div className="w-full bg-white rounded-full h-2 mt-2 overflow-hidden">
                  <div className="bg-rose-700 h-2 transition-all" style={{ width: (progress.total ? (progress.done / progress.total * 100) : 0) + '%' }} />
                </div>
                <div className="body-font text-[11px] text-rose-600 mt-2">
                  ✓ {progress.migrated} migrados · ↪ {progress.skipped} ya migrados · ✗ {progress.errors} errores
                </div>
                {progress.current && (
                  <div className="body-font text-[10px] text-gray-500 mt-1 truncate">→ {progress.current}</div>
                )}
              </div>
              <button onClick={function() { cancelRef.current = true; }}
                className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 display-font font-bold text-xs tracking-wide rounded-xl px-4 py-2.5 active:scale-[0.98] transition">
                PARAR
              </button>
            </React.Fragment>
          )}

          {phase === 'done' && (
            <React.Fragment>
              <div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-3">
                <div className="display-font font-bold text-base text-green-800">✅ Migración completada</div>
                <div className="body-font text-[12px] text-green-700 mt-1.5">
                  ✓ {progress.migrated} migrados · ↪ {progress.skipped} ya migrados · ✗ {progress.errors} errores
                </div>
              </div>
              <button onClick={onClose}
                className="w-full bg-rose-700 hover:bg-rose-800 text-white display-font font-bold text-sm tracking-wide rounded-xl px-4 py-3 active:scale-[0.98] transition">
                CERRAR
              </button>
            </React.Fragment>
          )}

          {log.length > 0 && (phase === 'running' || phase === 'done') && (
            <div className="mt-3 bg-gray-50 border border-gray-200 rounded-lg p-2 max-h-40 overflow-y-auto">
              {log.slice(-50).map(function(line, idx) {
                return <div key={line + '-' + idx} className="body-font text-[10px] text-gray-600 leading-tight font-mono">{line}</div>;
              })}
            </div>
          )}
        </Modal>
      );
    }

    // ======================================================================
    // v28-oct7: MAPA GPX INTERACTIVO (Leaflet bajo demanda)
    // ----------------------------------------------------------------------
    // - Cargamos Leaflet (JS + CSS) la PRIMERA vez que se renderiza un mapa.
    //   Promesa cacheada en window._leafletReady para no recargar.
    // - Parseamos el GPX (XML) en cliente con DOMParser → array de [lat, lon].
    //   Cacheamos por dataURL para no re-parsear los mismos puntos.
    // - Dibujamos polilínea + marcadores de inicio/fin + auto-zoom (fitBounds).
    // - Si el archivo no es .gpx o falla el parseo, renderiza null silenciosamente
    //   (la imagen estática mapImage seguirá siendo el fallback en la UI).
    // ======================================================================
    if (typeof window._leafletReady === 'undefined') window._leafletReady = null;
    if (typeof window._gpxPointsCache === 'undefined') window._gpxPointsCache = {};

    function loadLeaflet() {
      if (window.L) return Promise.resolve(window.L);
      if (window._leafletReady) return window._leafletReady;
      window._leafletReady = new Promise(function(resolve, reject) {
        // CSS
        if (!document.querySelector('link[data-leaflet]')) {
          const link = document.createElement('link');
          link.rel = 'stylesheet';
          link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
          link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
          link.crossOrigin = '';
          link.setAttribute('data-leaflet', '1');
          document.head.appendChild(link);
        }
        // JS
        const sc = document.createElement('script');
        sc.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
        sc.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
        sc.crossOrigin = '';
        sc.onload = function() { resolve(window.L); };
        sc.onerror = function() { reject(new Error('Leaflet load failed')); };
        document.head.appendChild(sc);
      });
      return window._leafletReady;
    }

    // v2-jun: carga SheetJS (XLSX) bajo demanda (solo al exportar el Excel de equipación).
    function ensureXLSX() {
      if (window.XLSX) return Promise.resolve(window.XLSX);
      if (window._xlsxReady) return window._xlsxReady;
      window._xlsxReady = new Promise(function(resolve, reject) {
        const sc = document.createElement('script');
        sc.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
        sc.onload = function() { resolve(window.XLSX); };
        sc.onerror = function() { reject(new Error('XLSX load failed')); };
        document.head.appendChild(sc);
      });
      return window._xlsxReady;
    }

    // Decodifica una dataURL base64 a string (UTF-8 si procede).
    function dataURLToText(dataURL) {
      if (!dataURL) return null;
      const idx = dataURL.indexOf(',');
      if (idx === -1) return null;
      const isBase64 = dataURL.substring(0, idx).indexOf('base64') >= 0;
      const payload = dataURL.substring(idx + 1);
      try {
        if (isBase64) {
          // atob → binary string → decodeURIComponent para UTF-8
          const bin = atob(payload);
          try {
            return decodeURIComponent(bin.split('').map(function(c) {
              return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
            }).join(''));
          } catch (e) { return bin; }
        }
        return decodeURIComponent(payload);
      } catch (e) { return null; }
    }

    // Parsea XML GPX → array de [lat, lon]. Soporta <trkpt>, <rtept> y <wpt>.
    function parseGpxPoints(gpxText) {
      if (!gpxText) return [];
      try {
        const parser = new DOMParser();
        const doc = parser.parseFromString(gpxText, 'application/xml');
        if (doc.querySelector('parsererror')) return [];
        // Tags posibles, en orden de preferencia (track > route > waypoint)
        const nodes = doc.getElementsByTagName('trkpt').length > 0 ? doc.getElementsByTagName('trkpt')
                    : doc.getElementsByTagName('rtept').length > 0 ? doc.getElementsByTagName('rtept')
                    : doc.getElementsByTagName('wpt');
        const points = [];
        for (let i = 0; i < nodes.length; i++) {
          const lat = parseFloat(nodes[i].getAttribute('lat'));
          const lon = parseFloat(nodes[i].getAttribute('lon'));
          if (!isNaN(lat) && !isNaN(lon)) points.push([lat, lon]);
        }
        return points;
      } catch (e) { return []; }
    }

    // v28-oct15: selector de punto de salida sobre mapa. El usuario pincha y marca
    // las coordenadas exactas. Centro inicial: coords previas > inicio del GPX > [37.6,-0.98] (Cartagena).
    // Botón "mi ubicación" usa geolocalización del navegador.
    function MeetingPointPicker({ initialCoords, gpxStartPoint, centerHint, onPick, onClose, showToast }) {
      const ref = useRef(null);
      const mapRef = useRef(null);
      const markerRef = useRef(null);
      const [coords, setCoords] = useState(initialCoords || null);
      const [locating, setLocating] = useState(false);

      useEffect(function() {
        let alive = true;
        loadLeaflet().then(function(L) {
          if (!alive || !ref.current) return;
          if (mapRef.current) { try { mapRef.current.remove(); } catch (e) {} mapRef.current = null; }
          const startCenter = (initialCoords && [initialCoords.lat, initialCoords.lng])
            || gpxStartPoint
            || centerHint
            || [37.6057, -0.9863]; // Cartagena por defecto
          const startZoom = (initialCoords || gpxStartPoint) ? 14 : 11;
          const map = L.map(ref.current, { zoomControl: true, attributionControl: true, scrollWheelZoom: true });
          mapRef.current = map;
          map.setView(startCenter, startZoom);
          L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '© OpenStreetMap', maxZoom: 18,
          }).addTo(map);

          const pin = function(latlng) {
            if (markerRef.current) { markerRef.current.setLatLng(latlng); }
            else {
              markerRef.current = L.marker(latlng, {
                icon: L.divIcon({
                  className: 'meet-marker',
                  html: '<div style="font-size:28px;line-height:1;transform:translate(-2px,-22px)">🚩</div>',
                  iconSize: [28, 28], iconAnchor: [4, 26],
                }),
              }).addTo(map);
            }
          };

          if (initialCoords) pin([initialCoords.lat, initialCoords.lng]);

          map.on('click', function(e) {
            const c = { lat: +e.latlng.lat.toFixed(6), lng: +e.latlng.lng.toFixed(6) };
            setCoords(c);
            pin([c.lat, c.lng]);
          });

          // Exponer la función pin para el botón de ubicación
          map._pinHelper = pin;
          // El mapa va dentro de un Modal: forzar recálculo de tamaño tras montar.
          setTimeout(function() { try { map.invalidateSize(); } catch (e) {} }, 150);
        }).catch(function() { showToast && showToast('No se pudo cargar el mapa'); });

        return function() {
          alive = false;
          if (mapRef.current) { try { mapRef.current.remove(); } catch (e) {} mapRef.current = null; }
        };
      }, []);

      function useMyLocation() {
        if (!navigator.geolocation) { showToast && showToast('Tu dispositivo no permite geolocalización'); return; }
        setLocating(true);
        navigator.geolocation.getCurrentPosition(function(pos) {
          const c = { lat: +pos.coords.latitude.toFixed(6), lng: +pos.coords.longitude.toFixed(6) };
          setCoords(c);
          if (mapRef.current) {
            mapRef.current.setView([c.lat, c.lng], 15);
            if (mapRef.current._pinHelper) mapRef.current._pinHelper([c.lat, c.lng]);
          }
          setLocating(false);
        }, function() {
          setLocating(false);
          showToast && showToast('No se pudo obtener tu ubicación');
        }, { enableHighAccuracy: true, timeout: 8000 });
      }

      return (
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-xl tracking-wide text-purple-700">🚩 PUNTO DE SALIDA</h2>
          <p className="body-font text-[12px] text-gray-500 mt-1 leading-snug">
            Pincha en el mapa para marcar el punto exacto donde quedáis. Puedes arrastrar y hacer zoom.
          </p>

          <div className="mt-3 rounded-lg overflow-hidden border border-gray-200">
            <div ref={ref} style={{ height: 300, width: '100%' }} />
          </div>

          <button onClick={useMyLocation} disabled={locating}
            className="mt-2 w-full bg-gray-100 hover:bg-gray-200 text-gray-700 display-font font-bold text-[11px] tracking-wide rounded-lg px-3 py-2 active:scale-[0.98] transition">
            {locating ? 'BUSCANDO…' : '📍 USAR MI UBICACIÓN ACTUAL'}
          </button>

          <div className="mt-2 body-font text-[12px] text-gray-600 text-center">
            {coords
              ? ('Marcado: ' + coords.lat.toFixed(5) + ', ' + coords.lng.toFixed(5))
              : 'Aún no has marcado ningún punto'}
          </div>

          <div className="flex gap-2 mt-4">
            <button onClick={onClose}
              className="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-600 display-font font-bold text-xs tracking-wide rounded-lg px-3 py-2.5 active:scale-[0.98] transition">
              CANCELAR
            </button>
            {coords && (
              <button onClick={function() { onPick(null); }}
                className="bg-red-50 hover:bg-red-100 text-red-600 display-font font-bold text-xs tracking-wide rounded-lg px-3 py-2.5 active:scale-[0.98] transition">
                QUITAR
              </button>
            )}
            <button onClick={function() { if (!coords) { showToast && showToast('Pincha en el mapa primero'); return; } onPick(coords); }}
              disabled={!coords}
              className="flex-1 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-300 text-white display-font font-bold text-xs tracking-wide rounded-lg px-3 py-2.5 active:scale-[0.98] transition">
              ✅ USAR ESTE PUNTO
            </button>
          </div>
        </Modal>
      );
    }

    // v28-oct15: vista del punto de salida en el detalle de una salida.
    // Mini-mapa Leaflet centrado en el punto + botón para abrir Google Maps (navegar).
    function MeetingPointView({ lat, lng, label }) {
      const ref = useRef(null);
      const mapRef = useRef(null);
      useEffect(function() {
        let alive = true;
        if (lat == null || lng == null) return;
        loadLeaflet().then(function(L) {
          if (!alive || !ref.current) return;
          if (mapRef.current) { try { mapRef.current.remove(); } catch (e) {} mapRef.current = null; }
          const map = L.map(ref.current, {
            zoomControl: false, attributionControl: true, scrollWheelZoom: false, dragging: false, doubleClickZoom: false,
          });
          mapRef.current = map;
          map.setView([lat, lng], 15);
          L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '© OpenStreetMap', maxZoom: 18,
          }).addTo(map);
          L.marker([lat, lng], {
            icon: L.divIcon({
              className: 'meet-marker',
              html: '<div style="font-size:26px;line-height:1;transform:translate(-2px,-20px)">🚩</div>',
              iconSize: [26, 26], iconAnchor: [4, 24],
            }),
          }).addTo(map);
          setTimeout(function() { try { map.invalidateSize(); } catch (e) {} }, 150);
        }).catch(function(){});
        return function() {
          alive = false;
          if (mapRef.current) { try { mapRef.current.remove(); } catch (e) {} mapRef.current = null; }
        };
      }, [lat, lng]);

      if (lat == null || lng == null) return null;
      const gmaps = 'https://www.google.com/maps/dir/?api=1&destination=' + lat + ',' + lng;
      return (
        <div className="mb-2">
          <div className="flex items-center gap-1.5 mb-1 body-font text-[12px] font-bold text-gray-700">
            <span>🚩</span> Punto de salida{label ? (': ' + label) : ''}
          </div>
          <div className="rounded-lg overflow-hidden border border-gray-200">
            <div ref={ref} style={{ height: 160, width: '100%' }} />
          </div>
          <a href={gmaps} target="_blank" rel="noopener noreferrer"
            className="mt-1.5 inline-flex w-full items-center justify-center gap-1.5 display-font font-bold text-[11px] tracking-wide bg-blue-600 hover:bg-blue-700 text-white rounded-lg px-3 py-2 active:scale-[0.98] transition">
            🧭 CÓMO LLEGAR (Google Maps)
          </a>
        </div>
      );
    }

    function GpxMap({ trackFile, height }) {
      const ref = useRef(null);
      const mapRef = useRef(null);
      const [status, setStatus] = useState('loading'); // 'loading' | 'ready' | 'empty' | 'error'

      useEffect(function() {
        let alive = true;
        // Solo intentamos con .gpx (FIT/TCX son binarios)
        if (!trackFile || !trackFile.data) { setStatus('empty'); return; }
        const ext = (trackFile.type || trackFile.name || '').toString().toLowerCase();
        if (ext.indexOf('gpx') === -1 && !/\.gpx/i.test(trackFile.name || '')) {
          setStatus('empty'); return;
        }
        // Puntos cacheados por dataURL
        let points = window._gpxPointsCache[trackFile.data];
        if (!points) {
          const txt = dataURLToText(trackFile.data);
          points = parseGpxPoints(txt);
          window._gpxPointsCache[trackFile.data] = points;
        }
        if (!points.length) { setStatus('empty'); return; }

        loadLeaflet().then(function(L) {
          if (!alive || !ref.current) return;
          // Si ya había un mapa previo (por hot-update), lo destruimos
          if (mapRef.current) { try { mapRef.current.remove(); } catch (e) {} mapRef.current = null; }
          const map = L.map(ref.current, {
            zoomControl: true, attributionControl: true, scrollWheelZoom: false,
          });
          mapRef.current = map;
          L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '© OpenStreetMap',
            maxZoom: 18,
          }).addTo(map);
          const line = L.polyline(points, { color: '#dc2626', weight: 4, opacity: 0.85 });
          line.addTo(map);
          // Marcadores inicio (verde) y fin (rojo)
          const start = points[0], end = points[points.length - 1];
          const markerIcon = function(color, label) {
            return L.divIcon({
              className: 'gpx-marker',
              html: '<div style="background:' + color + ';width:14px;height:14px;border-radius:50%;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.4);"></div>',
              iconSize: [14, 14], iconAnchor: [7, 7],
            });
          };
          L.marker(start, { icon: markerIcon('#16a34a'), title: 'Inicio' }).addTo(map);
          if (points.length > 1) L.marker(end, { icon: markerIcon('#dc2626'), title: 'Fin' }).addTo(map);
          map.fitBounds(line.getBounds(), { padding: [20, 20] });
          setStatus('ready');
        }).catch(function() { if (alive) setStatus('error'); });

        return function() {
          alive = false;
          if (mapRef.current) { try { mapRef.current.remove(); } catch (e) {} mapRef.current = null; }
        };
      }, [trackFile && trackFile.data]);

      if (status === 'empty') return null;
      if (status === 'error') {
        // Fallback discreto si Leaflet no carga. La imagen mapImage seguirá apareciendo
        // gracias a que el padre la condiciona con la misma regla solo cuando GpxMap
        // se monta — pero si Leaflet falla totalmente, al menos avisamos.
        return (
          <div className="mb-2 px-2 py-2 bg-gray-50 border border-gray-200 rounded-lg text-center">
            <p className="body-font text-[11px] text-gray-500">Mapa interactivo no disponible. Descarga el GPX para verlo en tu app de bici.</p>
          </div>
        );
      }
      return (
        <div className="mb-2 -mx-1 relative">
          <div ref={ref} style={{ height: (height || 200) + 'px', borderRadius: '8px', overflow: 'hidden', border: '1px solid #e5e7eb', background: '#f3f4f6' }} />
          {status === 'loading' && (
            <div className="absolute inset-0 flex items-center justify-center text-xs text-gray-500 body-font italic pointer-events-none">
              Cargando mapa…
            </div>
          )}
        </div>
      );
    }

    // v28-oct13d: variante de GpxMap que dibuja desde el `preview` (array de 200 [lat,lon])
    // almacenado en RTDB junto a cada ruta del catálogo. Así no hay que descargar
    // el GPX completo de Storage solo para enseñar el mapa interactivo.
    function PreviewGpxMap({ preview, bounds, height }) {
      const ref = useRef(null);
      const mapRef = useRef(null);
      const [status, setStatus] = useState('loading');

      useEffect(function() {
        let alive = true;
        if (!preview || preview.length < 2) { setStatus('empty'); return; }

        loadLeaflet().then(function(L) {
          if (!alive || !ref.current) return;
          if (mapRef.current) { try { mapRef.current.remove(); } catch (e) {} mapRef.current = null; }
          const map = L.map(ref.current, {
            zoomControl: true, attributionControl: true, scrollWheelZoom: false,
          });
          mapRef.current = map;
          L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '© OpenStreetMap',
            maxZoom: 18,
          }).addTo(map);
          const line = L.polyline(preview, { color: '#dc2626', weight: 4, opacity: 0.85 });
          line.addTo(map);
          const start = preview[0], end = preview[preview.length - 1];
          const markerIcon = function(color) {
            return L.divIcon({
              className: 'gpx-marker',
              html: '<div style="background:' + color + ';width:14px;height:14px;border-radius:50%;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.4);"></div>',
              iconSize: [14, 14], iconAnchor: [7, 7],
            });
          };
          L.marker(start, { icon: markerIcon('#16a34a'), title: 'Inicio' }).addTo(map);
          if (preview.length > 1) L.marker(end, { icon: markerIcon('#dc2626'), title: 'Fin' }).addTo(map);
          if (bounds) {
            map.fitBounds([[bounds.south, bounds.west], [bounds.north, bounds.east]], { padding: [20, 20] });
          } else {
            map.fitBounds(line.getBounds(), { padding: [20, 20] });
          }
          setStatus('ready');
        }).catch(function() { if (alive) setStatus('error'); });

        return function() {
          alive = false;
          if (mapRef.current) { try { mapRef.current.remove(); } catch (e) {} mapRef.current = null; }
        };
      }, [preview]);

      if (status === 'empty') return null;
      if (status === 'error') {
        return (
          <div className="px-2 py-2 bg-gray-50 border border-gray-200 rounded-lg text-center">
            <p className="body-font text-[11px] text-gray-500">Mapa interactivo no disponible.</p>
          </div>
        );
      }
      return (
        <div className="relative">
          <div ref={ref} style={{ height: (height || 280) + 'px', borderRadius: '8px', overflow: 'hidden', border: '1px solid #e5e7eb', background: '#f3f4f6' }} />
          {status === 'loading' && (
            <div className="absolute inset-0 flex items-center justify-center text-xs text-gray-500 body-font italic pointer-events-none">
              Cargando mapa…
            </div>
          )}
        </div>
      );
    }

    // v28-oct13d: modal de detalle de una ruta del catálogo provincial,
    // con mapa interactivo Leaflet a partir del `preview` (sin descargar Storage).
    function RouteDetailModal({ route, provinciaCode, isCentral, onClose, showToast }) {
      const [downloading, setDownloading] = useState(false);

      function handleDownload() {
        setDownloading(true);
        downloadPublicRouteAsTrackFile(route, route.name)
          .then(function(trackFile) {
            incrementPublicRouteUsage(provinciaCode, route.id).catch(function(){});
            setDownloading(false);
            try {
              const a = document.createElement('a');
              a.href = trackFile.data;
              a.download = trackFile.name;
              document.body.appendChild(a); a.click(); document.body.removeChild(a);
              showToast('📥 GPX descargado');
            } catch(e) { showToast('GPX preparado'); }
          })
          .catch(function(e) {
            setDownloading(false);
            showToast('No se pudo descargar: ' + (e.message || 'error'));
          });
      }

      function handleDelete() {
        if (!window.__confirmPending_route2) { window.__confirmPending_route2 = true; setTimeout(function(){ window.__confirmPending_route2=false; }, 3500); showToast('Pulsa de nuevo para borrar'); return; } window.__confirmPending_route2 = false;
        deletePublicRoute(provinciaCode, route.id, route.storagePath)
          .then(function() { showToast('Ruta borrada del catálogo'); onClose(); })
          .catch(function(e) { showToast('No se pudo borrar: ' + (e.message || 'error')); });
      }

      return (
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-xl tracking-wide text-purple-700 leading-tight break-words">{route.name}</h2>
          <div className="body-font text-[12px] text-gray-600 mt-1">
            {(route.distanceKm || 0).toFixed(1)} km · {route.elevationM || 0} m desnivel
            {(function() {
              const d = routeDifficulty(route.distanceKm, route.elevationM);
              return d ? <span className="ml-1 font-bold" style={{ color: d.color }}>· {d.emoji} {d.label}</span> : null;
            })()}
            {route.usageCount > 0 && (<span className="ml-1 text-purple-600">· usada {route.usageCount}×</span>)}
          </div>
          {/* v2-jun: mini-perfil de altimetría en el detalle */}
          <LazyRouteElevationProfile route={route} height={40} />
          {(route.authorGrupetaName || route.authorName) && (
            <div className="body-font text-[11px] text-gray-500 mt-0.5">
              Subida por <strong>{route.authorName || 'Anónimo'}</strong>
              {route.authorGrupetaName && (<span> · grupeta <strong>{route.authorGrupetaName}</strong></span>)}
            </div>
          )}

          {route.description && (
            <div className="mt-2 bg-purple-50 border border-purple-100 rounded-lg px-3 py-2 body-font text-[12px] text-gray-700 leading-snug whitespace-pre-line">
              {route.description}
            </div>
          )}

          <div className="mt-3">
            <PreviewGpxMap preview={route.preview} bounds={route.bounds} height={300} />
          </div>

          <div className="flex gap-2 mt-3">
            <button
              onClick={handleDownload}
              disabled={downloading}
              className="flex-1 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-300 text-white display-font font-bold text-xs tracking-wide rounded-lg px-3 py-2.5 active:scale-[0.98] transition">
              {downloading ? 'DESCARGANDO…' : '📥 DESCARGAR GPX'}
            </button>
            {isCentral && (
              <button
                onClick={handleDelete}
                className="bg-gray-100 hover:bg-red-50 hover:text-red-700 text-gray-500 rounded-lg w-10 h-10 flex items-center justify-center active:scale-90 transition flex-shrink-0"
                aria-label="Borrar ruta del catálogo">
                <Trash2 size={16} strokeWidth={2.2} />
              </button>
            )}
          </div>
        </Modal>
      );
    }

    // v28-nonoY: modal para confirmar SUSPENDER salida con motivo opcional y envío por WhatsApp
    function SuspendRideModal({ ride, dateKey, grupeta, onClose, onConfirm, showToast }) {
      const [reason, setReason] = useState('');
      const parts = dateKey.split('-');
      const day = parseInt(parts[2]);
      const monthName = MONTHS[parseInt(parts[1]) - 1].toLowerCase();
      const dateStr = day + ' de ' + monthName;
      const routeLabel = (ride.route && ride.route.trim()) ? ride.route : 'la salida';

      function buildSuspendMsg() {
        let m = '🚫 *SALIDA SUSPENDIDA*\n\n';
        m += '📅 *' + dateStr + '*';
        if (ride.time) m += ' · ' + ride.time + 'h';
        m += '\n';
        if (ride.route && ride.route.trim() && !/^(FALTA PROPUESTA RUTA|PENDIENTE DE RUTA|SIN RUTA|RUTA PENDIENTE)$/i.test(ride.route.trim())) {
          m += '🚴‍♂️ ' + ride.route + '\n';
        }
        if (ride.meetingPoint) m += '📍 ' + ride.meetingPoint + '\n';
        if (reason.trim()) m += '\n_Motivo: ' + reason.trim() + '_\n';
        m += '\nMás info en grupetas.com';
        return m;
      }

      function handleConfirm() {
        onConfirm(reason);
      }

      function handleConfirmAndShare() {
        const msg = buildSuspendMsg();
        onConfirm(reason);
        // Pequeño retardo para que el cambio se guarde antes de abrir WhatsApp
        setTimeout(function() { openWhatsAppShare(msg); }, 150);
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-4">
            <div className="bg-red-100 text-red-700 w-10 h-10 rounded-full flex items-center justify-center">
              <span className="text-xl">🚫</span>
            </div>
            <h2 className="display-font font-bold text-2xl tracking-wide text-red-700">SUSPENDER SALIDA</h2>
          </div>
          <p className="body-font text-sm text-gray-600 mb-4">
            La salida del <strong>{dateStr}</strong> ({routeLabel}) quedará marcada como <strong>SUSPENDIDA</strong>. Los apuntados seguirán viéndola, pero no contará en el ranking. Podrás reactivarla en cualquier momento.
          </p>
          <FormField label="Motivo (opcional)">
            <input type="text" value={reason}
              onChange={function(e) { setReason(e.target.value); }}
              placeholder="Ej: lluvia, pocos apuntados, fiesta local..."
              maxLength={80}
              className={inputCls} />
          </FormField>
          <div className="flex flex-col gap-2 mt-5">
            <button onClick={handleConfirmAndShare}
              className="display-font font-bold text-sm tracking-wider bg-green-600 hover:bg-green-700 text-white py-3 rounded-xl active:scale-95 transition flex items-center justify-center gap-2">
              <span>📲</span> SUSPENDER Y AVISAR POR WHATSAPP
            </button>
            <button onClick={handleConfirm}
              className="display-font font-bold text-sm tracking-wider bg-red-700 hover:bg-red-800 text-white py-3 rounded-xl active:scale-95 transition">
              🚫 SOLO SUSPENDER
            </button>
            <button onClick={onClose} className={cancelBtn}>CANCELAR</button>
          </div>
        </Modal>
      );
    }

    // v28-oct2: HelpModal — manual de uso ("¿Cómo se hace?"). Reutilizable: portada principal + cada grupeta
    // v28-oct14b: cada sección es un acordeón independiente — cabecera siempre visible,
    // contenido colapsado por defecto. Varios pueden estar abiertos a la vez.
    function HelpSection({ title, children, id }) {
      const [open, setOpen] = useState(false);
      return (
        <section id={id} className="border border-gray-200 rounded-lg overflow-hidden bg-white">
          <button type="button" onClick={function() { setOpen(!open); }}
            className="w-full flex items-center justify-between gap-2 px-3 py-2.5 text-left hover:bg-gray-50 active:bg-gray-100 transition">
            <span className="display-font font-bold text-sm text-red-700 tracking-wide flex-1">{title}</span>
            <span className={'text-gray-400 text-lg leading-none transition-transform ' + (open ? 'rotate-180' : '')}>▾</span>
          </button>
          {open && (
            <div className="px-3 pb-3 pt-1 border-t border-gray-100">
              {children}
            </div>
          )}
        </section>
      );
    }


    // HelpModal → js/modals-a.js
    function RemindShareModal({ ride, dateKey, grupeta, allUsers, onClose, showToast }) {
      const msg = buildReminderText(ride, dateKey, grupeta, allUsers);
      const title = 'Recordatorio salida ' + (grupeta.shortName || grupeta.name) + ': ' + ride.route;
      const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;

      function handleNativeShare() {
        if (canNativeShare) {
          navigator.share({ title: title, text: msg }).catch(function(e) {
            if (e && e.name !== 'AbortError') {
              console.error('share error', e);
              showToast('No se pudo compartir');
            }
          });
        } else {
          openWhatsAppShare(msg);
        }
      }
      function handleWhatsApp() {
        openWhatsAppShare(msg);
      }
      function handleTelegram() {
        const url = APP_URL;
        window.open('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(msg), '_blank');
      }
      function handleCopy() {
        if (navigator.clipboard && navigator.clipboard.writeText) {
          navigator.clipboard.writeText(msg).then(function() {
            showToast('Copiado al portapapeles');
          }).catch(function() { fallbackCopy(); });
        } else {
          fallbackCopy();
        }
      }
      function fallbackCopy() {
        try {
          const ta = document.createElement('textarea');
          ta.value = msg;
          ta.style.position = 'fixed';
          ta.style.opacity = '0';
          document.body.appendChild(ta);
          ta.select();
          document.execCommand('copy');
          document.body.removeChild(ta);
          showToast('Copiado al portapapeles');
        } catch (e) {
          showToast('No se pudo copiar');
        }
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-2">
            <div className="bg-green-100 text-green-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">
              <span style={{ fontSize: '20px' }}>📲</span>
            </div>
            <div className="min-w-0">
              <h2 className="display-font font-bold text-xl tracking-wide text-red-700 leading-tight">RECORDAR A LA GRUPETA</h2>
              <p className="body-font text-[11px] text-gray-500">Lanza el aviso de la salida que viene</p>
            </div>
          </div>

          <div className="bg-green-50 border border-green-200 rounded-lg p-2.5 mb-3">
            <p className="body-font text-[11px] text-green-800 leading-snug">
              Si compartes desde el móvil, elige tu chat de WhatsApp en la bandeja. Desde el ordenador, usa <strong>Copiar</strong> y pega el mensaje en WhatsApp Web (así los emojis se preservan).
            </p>
          </div>

          <div className="bg-gray-50 border border-gray-200 rounded-lg p-3 max-h-[35vh] overflow-y-auto mb-3">
            <pre className="body-font text-[11px] text-gray-700 whitespace-pre-wrap leading-relaxed font-sans">{msg}</pre>
          </div>

          <div className="space-y-2 mb-3">
            <button onClick={handleNativeShare}
              className="w-full bg-red-700 hover:bg-red-800 text-white rounded-xl py-3 font-bold display-font tracking-wider text-sm active:scale-[0.98] transition flex items-center justify-center gap-2">
              <Upload size={16} strokeWidth={3} className="rotate-180" /> COMPARTIR
            </button>
            <div className="grid grid-cols-3 gap-2">
              <button onClick={handleWhatsApp}
                className="bg-green-600 hover:bg-green-700 text-white rounded-lg py-2 font-bold body-font text-[11px] tracking-wider active:scale-95 transition flex items-center justify-center">
                WhatsApp
              </button>
              <button onClick={handleTelegram}
                className="bg-sky-500 hover:bg-sky-600 text-white rounded-lg py-2 font-bold body-font text-[11px] tracking-wider active:scale-95 transition flex items-center justify-center">
                Telegram
              </button>
              <button onClick={handleCopy}
                className="bg-gray-100 hover:bg-gray-200 text-gray-700 border border-gray-300 rounded-lg py-2 font-bold body-font text-[11px] tracking-wider active:scale-95 transition flex items-center justify-center">
                Copiar
              </button>
            </div>
          </div>

          <button onClick={onClose} className={cancelBtn + ' w-full'}>CERRAR</button>
        </Modal>
      );
    }

    // ============ BAIL MODAL (cuando alguien no puede ir / llega tarde) ============
    // v71: texto en movimiento del botón ¿CÓMO SE HACE? (mismo sistema marquee de noticias)
    const HELP_MARQUEE_TXT = '¿Sabes TODO lo que puedes hacer aquí? Salidas, rutas, avisos al móvil, fotos y mucho más. Pulsa el botón amarillo ¿CÓMO SE HACE? y descúbrelo paso a paso 🚴';
    // v71: configuración editable por el admin central en /config/helpMarquee.
    // Si no existe (o faltan campos) se usan estos valores por defecto, idénticos al diseño clásico.
    const HELP_MARQUEE_DEFAULTS = { texto: HELP_MARQUEE_TXT, bg: '#facc15', fg: '#7f1d1d', velocidad: 18, activo: true };

    // Lee /config/helpMarquee en tiempo real (lectura pública; escritura solo admin central por reglas).
    function useHelpMarquee() {
      const [cfg, setCfg] = useState(null);
      useEffect(function() {
        const ref = db.ref('config/helpMarquee');
        const cb = function(s) { setCfg(s.val()); };
        ref.on('value', cb, function() { /* sin permiso o sin red: se quedan los defaults */ });
        return function() { ref.off('value', cb); };
      }, []);
      return Object.assign({}, HELP_MARQUEE_DEFAULTS, cfg || {});
    }

    // ============ v92: CONSTRUCTOR DE PORTADA ============
    // Config en /config/portada/<id> = { orden:int, ancho:'full'|'half', color:hex|'' }.
    // Lectura pública (mismo patrón que helpMarquee); escritura solo admin central por reglas.
    // 'noticias' y 'cajas' solo admiten orden (su aspecto no se toca aquí).
    const PORTADA_DEFAULTS = [
      { id: 'marchas',  ancho: 'full', color: '' },
      { id: 'publicar', ancho: 'full', color: '' },
      { id: 'noticias', ancho: 'full', color: '' },
      { id: 'ranking',  ancho: 'full', color: '' },
      { id: 'proponer', ancho: 'half', color: '' },
      { id: 'salgosolo', ancho: 'half', color: '' },
      { id: 'cajas',    ancho: 'full', color: '' }
    ];
    function usePortadaConfig() {
      const [cfg, setCfg] = useState(null);
      useEffect(function() {
        const ref = db.ref('config/portada');
        const cb = function(s) { setCfg(s.val()); };
        ref.on('value', cb, function() { /* sin permiso o sin red: se quedan los defaults */ });
        return function() { ref.off('value', cb); };
      }, []);
      return React.useMemo(function() {
        if (!cfg || typeof cfg !== 'object') return PORTADA_DEFAULTS;
        const out = PORTADA_DEFAULTS.map(function(def, idx) {
          const c = (cfg[def.id] && typeof cfg[def.id] === 'object') ? cfg[def.id] : {};
          return {
            id: def.id,
            orden: (typeof c.orden === 'number') ? c.orden : idx * 10,
            ancho: (c.ancho === 'half') ? 'half' : 'full',
            color: (typeof c.color === 'string') ? c.color : ''
          };
        });
        out.sort(function(a, b) { return a.orden - b.orden; });
        return out;
      }, [cfg]);
    }

    // Botón amarillo ¿CÓMO SE HACE? con rótulo en movimiento, configurable.
    // Si activo=false vuelve al clásico texto fijo. extraClass: clases extra (p.ej. mb-3).
    function HelpMarqueeButton({ onClick, extraClass }) {
      const c = useHelpMarquee();
      const activo = c.activo !== false;
      const vel = Math.min(60, Math.max(5, parseInt(c.velocidad, 10) || 18));
      return (
        <button onClick={onClick}
          style={{ backgroundColor: c.bg || '#facc15', color: c.fg || '#7f1d1d' }}
          className={'w-full display-font font-bold text-sm tracking-widest py-3 px-4 rounded-2xl flex items-center gap-2 active:scale-[0.98] transition shadow-lg ' + (extraClass || '')}>
          <span className="text-xl flex-shrink-0">❓</span>
          {activo ? (
            <span className="marquee-wrap flex-1 min-w-0">
              <span className="marquee-track" style={{ animationDuration: vel + 's' }}>{(c.texto || HELP_MARQUEE_TXT)}</span>
            </span>
          ) : (
            <span className="flex-1 text-center">¿CÓMO SE HACE?</span>
          )}
        </button>
      );
    }

    // v92: panel del ADMIN CENTRAL para ordenar/dimensionar/colorear los bloques fijos de la portada.
    const PORTADA_COLORS = ['', '#7f1d1d', '#1e3a8a', '#14532d', '#9a3412', '#581c87', '#374151']; // misma paleta que noticias ('' = AUTO)
    const PORTADA_NOMBRES = {
      marchas:  { nombre: '🚴 Próximas marchas',   cfg: true },
      publicar: { nombre: '🚴 Publica tu marcha',  cfg: true },
      noticias: { nombre: '📰 Noticias de abajo',  cfg: false },
      ranking:  { nombre: '🏆 Ranking global',     cfg: true },
      proponer: { nombre: '🟡 Proponer grupeta',   cfg: true },
      salgosolo:{ nombre: '🧡 ¿Salgo solo? Te apuntas', cfg: true },
      cajas:    { nombre: '📅 Cajas 3 días / mes', cfg: false }
    };
    function PortadaConfigModal({ onClose, showToast }) {
      showToast = showToast || function(m) { console.log(m); };
      const saved = usePortadaConfig();
      const [items, setItems] = useState(saved);
      const [dirty, setDirty] = useState(false);
      // El snapshot de Firebase llega async: mientras el admin no toque nada, reflejar lo guardado.
      useEffect(function() { if (!dirty) setItems(saved); }, [saved, dirty]);

      function mover(i, dir) {
        const j = i + dir;
        if (j < 0 || j >= items.length) return;
        const copia = items.slice();
        const t = copia[i]; copia[i] = copia[j]; copia[j] = t;
        setItems(copia); setDirty(true);
      }
      function setAncho(i, a) {
        const copia = items.slice();
        copia[i] = Object.assign({}, copia[i], { ancho: a });
        setItems(copia); setDirty(true);
      }
      function setColor(i, c) {
        const copia = items.slice();
        copia[i] = Object.assign({}, copia[i], { color: c });
        setItems(copia); setDirty(true);
      }
      function restablecer() { setItems(PORTADA_DEFAULTS); setDirty(true); }
      function guardar() {
        const obj = {};
        items.forEach(function(b, idx) {
          obj[b.id] = { orden: idx * 10, ancho: (b.ancho === 'half' ? 'half' : 'full'), color: b.color || '' };
        });
        db.ref('config/portada').set(obj).then(function() {
          showToast('Portada guardada 🛠️');
          onClose();
        }).catch(function(err) {
          console.error('portada save error:', err);
          showToast('Error al guardar' + (err && err.code ? ': ' + err.code : ''));
        });
      }

      return (
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-xl tracking-wide text-red-700 mb-1">🛠️ CONSTRUCTOR DE PORTADA</h2>
          <p className="body-font text-[11px] text-gray-500 mb-3">Ordena los bloques fijos de la portada y elige su tamaño y color. Los cambios se ven al instante para todos al guardar.</p>

          <div className="space-y-2 mb-4">
            {items.map(function(b, i) {
              const info = PORTADA_NOMBRES[b.id] || { nombre: b.id, cfg: false };
              return (
                <div key={b.id} className="border-2 border-gray-200 rounded-xl px-3 py-2">
                  <div className="flex items-center gap-2">
                    <div className="flex flex-col gap-0.5 flex-shrink-0">
                      <button onClick={function() { mover(i, -1); }} disabled={i === 0}
                        className="w-8 h-6 rounded bg-gray-100 text-gray-700 text-xs font-bold active:scale-90 disabled:opacity-25">▲</button>
                      <button onClick={function() { mover(i, 1); }} disabled={i === items.length - 1}
                        className="w-8 h-6 rounded bg-gray-100 text-gray-700 text-xs font-bold active:scale-90 disabled:opacity-25">▼</button>
                    </div>
                    <div className="flex-1 min-w-0">
                      <div className="display-font font-bold text-sm text-gray-800 tracking-wide truncate">{info.nombre}</div>
                      {info.cfg ? (
                        <div className="flex items-center gap-1 mt-1 flex-wrap">
                          <button onClick={function() { setAncho(i, 'full'); }}
                            className={'text-[10px] font-bold px-2 py-1 rounded-lg border ' + (b.ancho !== 'half' ? 'bg-red-700 text-white border-red-700' : 'bg-white text-gray-500 border-gray-300')}>█ COMPLETO</button>
                          <button onClick={function() { setAncho(i, 'half'); }}
                            className={'text-[10px] font-bold px-2 py-1 rounded-lg border ' + (b.ancho === 'half' ? 'bg-red-700 text-white border-red-700' : 'bg-white text-gray-500 border-gray-300')}>▌ MITAD</button>
                          <span className="mx-1 text-gray-300">|</span>
                          {PORTADA_COLORS.map(function(c) {
                            const sel = (b.color || '') === c;
                            if (c === '') {
                              return (
                                <button key="auto" onClick={function() { setColor(i, ''); }}
                                  className={'w-7 h-7 rounded-full border-2 bg-white text-[8px] font-bold text-gray-500 flex-shrink-0 ' + (sel ? 'border-black' : 'border-gray-300')}>AUTO</button>
                              );
                            }
                            return (
                              <button key={c} onClick={function() { setColor(i, c); }}
                                style={{ background: c }}
                                className={'w-7 h-7 rounded-full border-2 flex-shrink-0 ' + (sel ? 'border-black' : 'border-transparent')} />
                            );
                          })}
                        </div>
                      ) : (
                        <div className="body-font text-[10px] text-gray-400 mt-0.5">solo posición (su aspecto no cambia desde aquí)</div>
                      )}
                    </div>
                  </div>
                </div>
              );
            })}
          </div>

          <div className="flex gap-2">
            <button onClick={restablecer}
              className="flex-1 display-font font-bold tracking-wider text-xs py-2.5 rounded-xl border-2 border-gray-300 text-gray-600 active:scale-95 transition">↩️ RESTABLECER</button>
            <button onClick={guardar}
              className="flex-1 display-font font-bold tracking-wider text-xs py-2.5 rounded-xl bg-green-600 text-white shadow active:scale-95 transition">💾 GUARDAR</button>
          </div>
        </Modal>
      );
    }

    // v71: modal del ADMIN CENTRAL para editar el rótulo del botón de ayuda.
    function HelpMarqueeConfigModal({ onClose, showToast }) {
      showToast = showToast || function(m) { console.log(m); }; // defensa si no llega el toast
      const saved = useHelpMarquee();
      const [texto, setTexto] = useState(saved.texto);
      const [bg, setBg] = useState(saved.bg);
      const [fg, setFg] = useState(saved.fg);
      const [velocidad, setVelocidad] = useState(saved.velocidad);
      const [activo, setActivo] = useState(saved.activo !== false);
      const MAX_LEN = 300;
      const vel = Math.min(60, Math.max(5, parseInt(velocidad, 10) || 18));

      function guardar() {
        const t = (texto || '').trim();
        if (!t) { showToast('Falta el texto del rótulo'); return; }
        if (t.length > MAX_LEN) { showToast('Texto demasiado largo (máx ' + MAX_LEN + ')'); return; }
        db.ref('config/helpMarquee').set({
          texto: t, bg: bg, fg: fg, velocidad: vel, activo: !!activo
        }).then(function() {
          showToast('Rótulo guardado');
          onClose();
        }).catch(function(err) {
          console.error('helpMarquee save error:', err);
          showToast('Error al guardar' + (err && err.code ? ': ' + err.code : ''));
        });
      }

      return (
        <Modal onClose={onClose}>
          <h2 className="display-font font-bold text-xl tracking-wide text-red-700 mb-1">📜 RÓTULO ¿CÓMO SE HACE?</h2>
          <p className="body-font text-[11px] text-gray-500 mb-3">Configura el botón de ayuda que se ve en la portada, la landing y cada grupeta.</p>

          {/* Vista previa en vivo */}
          <div className="mb-3">
            <p className="body-font text-[11px] font-bold text-gray-600 mb-1">Vista previa</p>
            <div style={{ backgroundColor: bg, color: fg }}
              className="w-full display-font font-bold text-sm tracking-widest py-3 px-4 rounded-2xl flex items-center gap-2 shadow-lg">
              <span className="text-xl flex-shrink-0">❓</span>
              {activo ? (
                <span className="marquee-wrap flex-1 min-w-0">
                  <span className="marquee-track" style={{ animationDuration: vel + 's' }}>{texto || HELP_MARQUEE_TXT}</span>
                </span>
              ) : (
                <span className="flex-1 text-center">¿CÓMO SE HACE?</span>
              )}
            </div>
          </div>

          <label className="flex items-start gap-2 bg-gray-50 border border-gray-200 rounded-lg p-2.5 mb-3 cursor-pointer">
            <input type="checkbox" checked={activo} onChange={function(e) { setActivo(e.target.checked); }}
              className="mt-0.5 w-4 h-4 accent-red-700 flex-shrink-0" />
            <span className="body-font text-[12px] text-gray-700 leading-snug">
              <strong>📜 Texto en movimiento</strong><br />
              Si lo desactivas, el botón vuelve al clásico "¿CÓMO SE HACE?" fijo.
            </span>
          </label>

          <p className="body-font text-[11px] font-bold text-gray-600 mb-1">Texto del rótulo</p>
          <textarea value={texto} onChange={function(e) { setTexto(e.target.value); }} rows={3}
            className="w-full border border-gray-300 rounded-lg p-2.5 body-font text-sm mb-1 focus:outline-none focus:border-red-400" />
          <p className={'body-font text-[10px] mb-3 ' + (texto.length > MAX_LEN ? 'text-red-600 font-bold' : 'text-gray-400')}>{texto.length} / {MAX_LEN}</p>

          <div className="flex gap-3 mb-3">
            <label className="flex-1 body-font text-[11px] font-bold text-gray-600">
              Color de fondo
              <input type="color" value={bg} onChange={function(e) { setBg(e.target.value); }}
                className="block w-full h-10 mt-1 rounded-lg border border-gray-300 cursor-pointer" />
            </label>
            <label className="flex-1 body-font text-[11px] font-bold text-gray-600">
              Color del texto
              <input type="color" value={fg} onChange={function(e) { setFg(e.target.value); }}
                className="block w-full h-10 mt-1 rounded-lg border border-gray-300 cursor-pointer" />
            </label>
          </div>

          <label className="block body-font text-[11px] font-bold text-gray-600 mb-3">
            Velocidad: una vuelta cada <span className="text-red-700">{vel} s</span> (menos segundos = más rápido)
            <input type="range" min="5" max="60" step="1" value={vel}
              onChange={function(e) { setVelocidad(parseInt(e.target.value, 10)); }}
              className="block w-full mt-1 accent-red-700" />
          </label>

          <div className="flex gap-2 mt-4">
            <button onClick={onClose} className={cancelBtn + ' flex-1'}>CANCELAR</button>
            <button onClick={guardar}
              className="flex-1 bg-red-700 hover:bg-red-800 text-white display-font font-bold text-sm tracking-widest py-2.5 rounded-xl active:scale-[0.98] transition shadow-lg">
              GUARDAR
            </button>
          </div>
        </Modal>
      );
    }

    // v76: cuenta atrás de cada salida. ES HOY (rojo pulsante), MAÑANA, Faltan N días.
    // Salidas pasadas: no muestra nada.
    function RideCountdownBadge({ dateKey }) {
      const parts = (dateKey || '').split('-');
      if (parts.length !== 3) return null;
      const target = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
      const now = new Date();
      const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate());
      const diff = Math.round((target - today0) / 86400000);
      if (isNaN(diff) || diff < 0) return null;
      const base = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wider ';
      if (diff === 0) return <span className={base + 'bg-red-600 text-white animate-pulse'}>🔥 ¡ES HOY!</span>;
      if (diff === 1) return <span className={base + 'bg-orange-500 text-white'}>⏰ ¡MAÑANA!</span>;
      if (diff <= 7) return <span className={base + 'bg-amber-100 text-amber-800 border border-amber-300'}>⏳ Faltan {diff} días</span>;
      return <span className={base + 'bg-gray-100 text-gray-600 border border-gray-200'}>📅 Faltan {diff} días</span>;
    }

    const BAIL_REASONS = [
      { id: 'trabajo',  label: 'Tengo trabajo',           emoji: '💼', body: 'Me ha surgido trabajo y no llego.',                              closer: '¡Disfrutad de la ruta! Nos vemos en la próxima 💪',                                       removes: true,  headline: 'no puede ir a la salida' },
      { id: 'dormido',  label: 'Me quedé durmiendo',      emoji: '😴', body: 'Me he quedado dormido/a... 🤦',                                  closer: '¡Disfrutad de la ruta! Nos vemos en la próxima 💪',                                       removes: true,  headline: 'no puede ir a la salida' },
      { id: 'enfermo',  label: 'No me encuentro bien',    emoji: '🤒', body: 'No me encuentro bien hoy. Voy a reposar.',                       closer: '¡Disfrutad de la ruta! Que vaya genial 🚴',                                                removes: true,  headline: 'no puede ir a la salida' },
      { id: 'tarde',    label: 'Llego 5 minutos tarde',   emoji: '⏰', body: 'Voy a llegar unos 5 minutos tarde.',                              closer: '¡Esperadme si podéis! Si no, os alcanzo por el camino 💨',                                 removes: false, headline: 'llega tarde' }
    ];

    function buildBailText(reason, user, ride, dateKey, grupeta) {
      const name = user ? getUserDisplay(user) : 'Un compañero';
      const parts = dateKey.split('-');
      const day = parseInt(parts[2]);
      const monthName = MONTHS[parseInt(parts[1]) - 1].toLowerCase();
      const dateStr = day + ' de ' + monthName;

      // Etiquetas textuales delante de cada dato para que el mensaje siga siendo
      // legible si el cliente del receptor no renderiza algún emoji.
      let m = '🚴 ' + name + ' ' + reason.headline + '\n\n';
      m += '📍 Grupeta: ' + (grupeta.shortName || grupeta.name) + '\n';
      m += '🛣 Ruta: ' + ride.route + '\n';
      m += '📅 Fecha: ' + dateStr + ' a las ' + ride.time + '\n\n';
      m += reason.emoji + ' ' + reason.body + '\n\n';
      m += reason.closer;
      m += '\n\n🔗 ' + APP_URL;
      return m;
    }

    function BailModal({ ride, dateKey, grupeta, user, onClose, onConfirmBail, showToast }) {
      const [reason, setReason] = useState(null);

      function pickReason(r) {
        if (r.removes) onConfirmBail(r); // v70: pasamos el motivo para incluirlo en el aviso push
        setReason(r);
      }

      // ---- PHASE 1: pick a reason ----
      if (!reason) {
        return (
          <Modal onClose={onClose}>
            <div className="flex items-center gap-3 mb-2">
              <div className="bg-orange-100 text-orange-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">
                <span style={{ fontSize: '20px' }}>👋</span>
              </div>
              <div className="min-w-0">
                <h2 className="display-font font-bold text-xl tracking-wide text-red-700 leading-tight">¿NO PUEDES IR?</h2>
                <p className="body-font text-[11px] text-gray-500">Elige el motivo y avisa a la grupeta</p>
              </div>
            </div>

            <div className="bg-gray-50 border border-gray-200 rounded-lg p-2.5 mb-3">
              <p className="body-font text-[11px] text-gray-600 leading-snug truncate">
                <strong>{ride.route}</strong> · {ride.time}
              </p>
            </div>

            <div className="space-y-2 mb-3">
              {BAIL_REASONS.map(function(r) {
                return (
                  <button key={r.id} onClick={function() { pickReason(r); }}
                    className="w-full bg-white hover:bg-red-50 border-2 border-gray-200 hover:border-red-300 text-gray-800 rounded-xl p-3 active:scale-[0.98] transition flex items-center gap-3 text-left">
                    <span style={{ fontSize: '26px' }} className="flex-shrink-0">{r.emoji}</span>
                    <div className="flex-1 min-w-0">
                      <div className="body-font font-bold text-sm">{r.label}</div>
                      <div className={'body-font text-[10px] mt-0.5 ' + (r.removes ? 'text-red-600' : 'text-gray-500')}>
                        {r.removes ? 'Te bajas de la salida' : 'Sigues apuntado, solo avisas'}
                      </div>
                    </div>
                    <ChevronRight size={16} className="text-gray-400 flex-shrink-0" />
                  </button>
                );
              })}
            </div>

            <button onClick={onClose} className={cancelBtn + ' w-full'}>CANCELAR</button>
          </Modal>
        );
      }

      // ---- PHASE 2: share message ----
      const msg = buildBailText(reason, user, ride, dateKey, grupeta);
      const title = (reason.removes ? 'No puedo ir' : 'Llego tarde') + ' a la salida de ' + (grupeta.shortName || grupeta.name);
      const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;

      function handleNativeShare() {
        if (canNativeShare) {
          navigator.share({ title: title, text: msg }).catch(function(e) {
            if (e && e.name !== 'AbortError') {
              console.error('share error', e);
              showToast('No se pudo compartir');
            }
          });
        } else {
          openWhatsAppShare(msg);
        }
      }
      function handleWhatsApp() { openWhatsAppShare(msg); }
      function handleTelegram() {
        const url = APP_URL;
        window.open('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(msg), '_blank');
      }
      function handleCopy() {
        if (navigator.clipboard && navigator.clipboard.writeText) {
          navigator.clipboard.writeText(msg).then(function() { showToast('Copiado al portapapeles'); }).catch(fallbackCopy);
        } else { fallbackCopy(); }
      }
      function fallbackCopy() {
        try {
          const ta = document.createElement('textarea');
          ta.value = msg; ta.style.position = 'fixed'; ta.style.opacity = '0';
          document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
          showToast('Copiado al portapapeles');
        } catch (e) { showToast('No se pudo copiar'); }
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-2">
            <div className={'w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ' + (reason.removes ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700')}>
              <span style={{ fontSize: '20px' }}>{reason.emoji}</span>
            </div>
            <div className="min-w-0">
              <h2 className="display-font font-bold text-xl tracking-wide text-red-700 leading-tight">MENSAJE LISTO</h2>
              <p className="body-font text-[11px] text-gray-500">
                {reason.removes ? 'Te has bajado de la salida' : 'Sigues apuntado · solo avisas'}
              </p>
            </div>
          </div>

          <div className="bg-gray-50 border border-gray-200 rounded-lg p-3 max-h-[35vh] overflow-y-auto mb-3">
            <pre className="body-font text-[11px] text-gray-700 whitespace-pre-wrap leading-relaxed font-sans">{msg}</pre>
          </div>

          <div className="space-y-2 mb-3">
            <button onClick={handleNativeShare}
              className="w-full bg-red-700 hover:bg-red-800 text-white rounded-xl py-3 font-bold display-font tracking-wider text-sm active:scale-[0.98] transition flex items-center justify-center gap-2">
              <Upload size={16} strokeWidth={3} className="rotate-180" /> COMPARTIR
            </button>
            <div className="grid grid-cols-3 gap-2">
              <button onClick={handleWhatsApp}
                className="bg-green-600 hover:bg-green-700 text-white rounded-lg py-2 font-bold body-font text-[11px] tracking-wider active:scale-95 transition flex items-center justify-center">
                WhatsApp
              </button>
              <button onClick={handleTelegram}
                className="bg-sky-500 hover:bg-sky-600 text-white rounded-lg py-2 font-bold body-font text-[11px] tracking-wider active:scale-95 transition flex items-center justify-center">
                Telegram
              </button>
              <button onClick={handleCopy}
                className="bg-gray-100 hover:bg-gray-200 text-gray-700 border border-gray-300 rounded-lg py-2 font-bold body-font text-[11px] tracking-wider active:scale-95 transition flex items-center justify-center">
                Copiar
              </button>
            </div>
          </div>

          <button onClick={onClose} className={cancelBtn + ' w-full'}>CERRAR</button>
        </Modal>
      );
    }

    // ============ SOCIOS MODAL (admin de grupeta o central) ============
    function SociosModal({ grupeta, socios, acceptances, onClose, onClearClub, showToast }) {
      const [search, setSearch] = useState('');
      const grupetaLabel = grupeta.shortName || grupeta.name;

      function handleDownloadAcceptance(uid, u) {
        // Solo aceptaciones de ESTA grupeta — los admins de grupeta no ven aceptaciones cruzadas
        const wrap = {};
        wrap[grupeta.id || 'x'] = grupeta;
        const rideAccs = collectRideAcceptances(uid, wrap, null);
        const legacy = (acceptances || {})[uid] || null;
        const html = buildAcceptanceHTML(u, rideAccs, legacy);
        const win = window.open('', '_blank');
        if (win) {
          win.document.write(html);
          win.document.close();
        } else {
          const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
          const url = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = url;
          a.download = 'autorizacion-' + slugify((u.nombre || '') + ' ' + (u.apellidos || '')) + '.html';
          document.body.appendChild(a); a.click(); document.body.removeChild(a);
          setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
        }
      }

      const filtered = search.trim() ? socios.filter(function(u) {
        const q = search.toLowerCase();
        return ((u.nombre || '').toLowerCase().indexOf(q) !== -1)
          || ((u.apellidos || '').toLowerCase().indexOf(q) !== -1)
          || ((u.telefono || '').toLowerCase().indexOf(q) !== -1)
          || ((u.correo || '').toLowerCase().indexOf(q) !== -1);
      }) : socios;

      // ---- Export helpers ----
      function csvEscape(v) {
        const s = String(v == null ? '' : v);
        if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
        return s;
      }

      function handleExportCSV() {
        const headers = ['Nombre', 'Apellidos', 'Club', 'Teléfono', 'Correo', 'Fecha alta'];
        const lines = [headers.join(',')];
        socios.forEach(function(u) {
          const fecha = u.createdAt ? new Date(u.createdAt).toLocaleDateString('es-ES') : '';
          lines.push([
            csvEscape(u.nombre || ''),
            csvEscape(u.apellidos || ''),
            csvEscape(getUserClubsDisplay(u)),
            csvEscape(u.telefono || ''),
            csvEscape(u.correo || ''),
            csvEscape(fecha)
          ].join(','));
        });
        // UTF-8 BOM so Excel reads accents correctly
        const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
        try {
          const url = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = url;
          a.download = 'socios-' + slugify(grupetaLabel) + '-' + new Date().toISOString().slice(0, 10) + '.csv';
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
          setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
          if (showToast) showToast('CSV descargado');
        } catch (e) {
          console.error('CSV export error:', e);
          if (showToast) showToast('Error al exportar');
        }
      }

      function buildSociosText() {
        let t = '👥 SOCIOS DE ' + grupetaLabel.toUpperCase() + '\n';
        t += '(' + socios.length + ' ' + (socios.length === 1 ? 'persona' : 'personas') + ')\n\n';
        socios.forEach(function(u, i) {
          t += (i + 1) + '. ' + getUserDisplay(u) + '\n';
        });
        t += '\n🔗 ' + APP_URL;
        return t;
      }

      function handleShareList() {
        const text = buildSociosText();
        const title = 'Socios de ' + grupetaLabel;
        if (typeof navigator !== 'undefined' && navigator.share) {
          navigator.share({ title: title, text: text }).catch(function(e) {
            if (e && e.name !== 'AbortError') {
              console.error('share error', e);
              if (showToast) showToast('No se pudo compartir');
            }
          });
        } else {
          openWhatsAppShare(text);
        }
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-1">
            <div className="bg-red-100 text-red-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">
              <Users size={18} strokeWidth={2.5} />
            </div>
            <div className="min-w-0">
              <h2 className="display-font font-bold text-2xl tracking-wide text-red-700 leading-tight">SOCIOS</h2>
              <p className="body-font text-[11px] text-gray-500 truncate">{grupetaLabel}</p>
            </div>
          </div>

          <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2.5 mb-3">
            <p className="body-font text-[11px] text-yellow-800 leading-snug">
              {socios.length} {socios.length === 1 ? 'ciclista registrado' : 'ciclistas registrados'} en esta grupeta. Aparecen aquí cuando eligen <strong>{grupetaLabel}</strong> como su club al crear su perfil.
            </p>
          </div>

          {socios.length > 0 && (
            <div className="grid grid-cols-2 gap-2 mb-3">
              <button onClick={handleShareList}
                className="bg-green-600 hover:bg-green-700 text-white rounded-lg py-2 font-bold body-font text-[11px] tracking-wider active:scale-95 transition flex items-center justify-center gap-1.5">
                <span>📤</span> COMPARTIR LISTA
              </button>
              <button onClick={handleExportCSV}
                className="bg-white hover:bg-gray-100 text-gray-700 border border-gray-300 rounded-lg py-2 font-bold body-font text-[11px] tracking-wider active:scale-95 transition flex items-center justify-center gap-1.5">
                <span>📥</span> DESCARGAR CSV
              </button>
            </div>
          )}

          {socios.length > 5 && (
            <input type="text" value={search} onChange={function(e) { setSearch(e.target.value); }}
              placeholder="Buscar por nombre, teléfono o correo…" className={inputCls + ' mb-3'} />
          )}

          <div className="space-y-2 max-h-[55vh] overflow-y-auto scrollbar-hide -mx-1 px-1">
            {filtered.length === 0 ? (
              <div className="text-center py-8 text-gray-400 body-font text-sm">
                {socios.length === 0 ? (
                  <React.Fragment>
                    <Users size={32} className="mx-auto text-gray-300 mb-2" />
                    <p>Aún no hay socios registrados</p>
                    <p className="text-[10px] mt-1 px-4">Los ciclistas aparecen aquí cuando eligen esta grupeta como su club al crear su perfil.</p>
                  </React.Fragment>
                ) : 'Sin resultados'}
              </div>
            ) : filtered.map(function(u) {
              const display = getUserFullName(u);
              const phoneStripped = (u.telefono || '').replace(/\s/g, '');
              return (
                <div key={u._id} className="bg-gray-50 border border-gray-200 rounded-xl p-3">
                  <div className="flex items-center gap-3 mb-2">
                    <UserAvatar user={u} userId={u._id} size={44} />
                    <div className="flex-1 min-w-0">
                      <div className="body-font text-sm font-bold text-gray-800 truncate">{display}</div>
                      {(u.telefono || u.correo) ? (
                        <div className="body-font text-[11px] text-gray-500 truncate">
                          {u.telefono || u.correo}
                        </div>
                      ) : (
                        <div className="body-font text-[10px] text-gray-400 italic">Sin contacto público</div>
                      )}
                    </div>
                  </div>
                  {(u.telefono || u.correo) && (
                    <div className="flex flex-wrap gap-2 mb-2">
                      {u.telefono && (
                        <a href={'tel:' + phoneStripped}
                          className="flex items-center gap-1 bg-white border border-gray-200 hover:border-red-300 rounded-lg px-2.5 py-1 text-[11px] text-gray-700 active:scale-95 transition">
                          📞 Llamar
                        </a>
                      )}
                      {u.telefono && (
                        <a href={'https://wa.me/' + phoneStripped.replace(/^\+/, '')} target="_blank" rel="noopener noreferrer"
                          className="flex items-center gap-1 bg-white border border-gray-200 hover:border-green-300 rounded-lg px-2.5 py-1 text-[11px] text-gray-700 active:scale-95 transition">
                          💬 WhatsApp
                        </a>
                      )}
                      {u.correo && (
                        <a href={'mailto:' + u.correo}
                          className="flex items-center gap-1 bg-white border border-gray-200 hover:border-red-300 rounded-lg px-2.5 py-1 text-[11px] text-gray-700 active:scale-95 transition">
                          ✉️ Correo
                        </a>
                      )}
                    </div>
                  )}
                  <div className="flex gap-2">
                    <button onClick={function() { handleDownloadAcceptance(u._id, u); }}
                      className="text-[11px] display-font font-bold tracking-wider bg-red-700 hover:bg-red-800 text-white rounded-lg px-3 py-1.5 active:scale-95 transition">
                      📄 AUTORIZ.
                    </button>
                    <button onClick={function() {
                      if (!window.__confirmPending_quitar) { window.__confirmPending_quitar = true; setTimeout(function(){ window.__confirmPending_quitar=false; }, 3500); showToast('Pulsa de nuevo para quitar al socio'); } else { window.__confirmPending_quitar = false;
                        onClearClub(u._id, grupetaLabel);
                      }
                    }}
                      className="text-[11px] display-font font-bold tracking-wider bg-white border border-red-300 hover:bg-red-50 text-red-700 rounded-lg px-3 py-1.5 active:scale-95 transition">
                      QUITAR DE LA GRUPETA
                    </button>
                  </div>
                </div>
              );
            })}
          </div>

          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn + ' w-full'}>CERRAR</button>
          </div>
        </Modal>
      );
    }

    // ======================================================================
    // v28-oct4: MIS DATOS — estadísticas personales del socio en una grupeta
    // ----------------------------------------------------------------------
    // Calcula 5 bloques sobre las salidas COMPLETADAS del año actual en las
    // que el usuario es participante (mismas reglas que el ranking):
    //   1) Resumen del año: salidas, km, racha
    //   2) Última salida (fecha + ruta)
    //   3) Posición en el ranking + diferencias con vecinos
    //   4) Top 5 compañeros con los que más has rodado (si grupeta tiene ≥5 socios)
    //   5) Top 3 rutas favoritas
    // Todo se deriva del estado en vivo (rides + rankings) — sin tocar Firebase.
    // ======================================================================
    function MyStatsModal({ grupeta, rides, rankings, currentUserId, currentUser, allUsers, sociosCount, isSchool, onClose, onViewProfile }) {
      // ------- Helpers locales -------
      // Extrae el primer número (km) de una cadena como "80 km" o "100km" o "80".
      function parseKm(distStr) {
        if (!distStr) return 0;
        const m = String(distStr).match(/(\d+(?:[.,]\d+)?)/);
        if (!m) return 0;
        return parseFloat(m[1].replace(',', '.')) || 0;
      }

      // dateKey YYYY-MM-DD legible "DOM 25 mayo"
      function fmtShort(dk) {
        const p = dk.split('-');
        const d = new Date(parseInt(p[0]), parseInt(p[1]) - 1, parseInt(p[2]));
        const wd = ['DOM', 'LUN', 'MAR', 'MIE', 'JUE', 'VIE', 'SAB'][d.getDay()];
        const m = (typeof MONTHS !== 'undefined' && MONTHS[parseInt(p[1]) - 1])
          ? MONTHS[parseInt(p[1]) - 1].toLowerCase()
          : '';
        return wd + ' ' + parseInt(p[2]) + ' ' + m;
      }

      // ------- Cálculos pesados (con useMemo para que no se rehagan en cada render) -------
      const stats = React.useMemo(function() {
        const today = new Date();
        const yearStart = today.getFullYear() + '-01-01';
        const todayK = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0');

        // Cutoff del ranking: si la grupeta tiene rankingCutoff posterior al 1-ene,
        // respetamos esa fecha (igual que el ranking). Si no, usamos 1-ene.
        const cutoff = grupeta.rankingCutoff && grupeta.rankingCutoff > yearStart
          ? grupeta.rankingCutoff
          : yearStart;

        // Mis salidas completadas del periodo
        const myRides = []; // [{dateKey, ride}]
        Object.entries(rides || {}).forEach(function(entry) {
          const k = entry[0];
          if (k < cutoff) return;
          // Solo salidas pasadas (mismo criterio que el ranking)
          if (!isPastRide(k, todayK, today)) return;
          const dayRides = entry[1];
          if (!Array.isArray(dayRides)) return;
          dayRides.forEach(function(r) {
            if (!r) return;
            if (r.suspended) return;
            if (r.excludeFromRanking) return;
            if (!Array.isArray(r.participants)) return;
            if (r.participants.indexOf(currentUserId) === -1) return;
            myRides.push({ dateKey: k, ride: r });
          });
        });
        // Ordenadas de más reciente a más antigua
        myRides.sort(function(a, b) { return b.dateKey.localeCompare(a.dateKey); });

        // Km totales
        const totalKm = myRides.reduce(function(sum, item) {
          return sum + parseKm(item.ride.distance);
        }, 0);

        // Última salida = la primera del array (más reciente)
        const lastRide = myRides.length > 0 ? myRides[0] : null;

        // Racha de semanas seguidas con al menos una salida.
        // Para cada salida, calculamos su "número de semana ISO" y contamos.
        function weekNum(dateKey) {
          // Devuelve un número entero único por semana (año*100 + semana ISO)
          const p = dateKey.split('-');
          const d = new Date(Date.UTC(parseInt(p[0]), parseInt(p[1]) - 1, parseInt(p[2])));
          // Jueves de la semana ISO
          d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
          const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
          const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
          return d.getUTCFullYear() * 100 + weekNo;
        }
        const weeksSet = {};
        myRides.forEach(function(item) { weeksSet[weekNum(item.dateKey)] = true; });
        // Racha actual: cuenta hacia atrás desde la semana actual hasta que falte una
        const currentWeek = weekNum(todayK);
        let streak = 0;
        let cursor = currentWeek;
        // Si esta semana no hay aún, empezamos por la pasada
        if (!weeksSet[cursor]) {
          // Decrementa una semana (ajustando año si toca)
          const yr = Math.floor(cursor / 100);
          const wk = cursor % 100;
          cursor = wk > 1 ? yr * 100 + (wk - 1) : (yr - 1) * 100 + 52;
        }
        while (weeksSet[cursor]) {
          streak += 1;
          const yr = Math.floor(cursor / 100);
          const wk = cursor % 100;
          cursor = wk > 1 ? yr * 100 + (wk - 1) : (yr - 1) * 100 + 52;
          if (streak > 200) break; // safety
        }

        // Posición en ranking
        let myPos = -1;
        let myEntry = null;
        let aboveDiff = null;   // pts/salidas que me separan del de arriba
        let topDiff = null;     // pts/salidas que me separan del top 3
        rankings.forEach(function(it, idx) {
          if (it.uid === currentUserId) { myPos = idx + 1; myEntry = it; }
        });
        if (myPos > 1 && rankings[myPos - 2]) {
          aboveDiff = rankings[myPos - 2].value - myEntry.value;
        }
        if (myPos > 3 && rankings[2]) {
          topDiff = rankings[2].value - myEntry.value;
        }

        // Compañeros: cuántas veces he coincidido con cada otro UID
        const buddies = {};
        myRides.forEach(function(item) {
          (item.ride.participants || []).forEach(function(uid) {
            if (uid === currentUserId) return;
            if (!allUsers || !allUsers[uid]) return;
            buddies[uid] = (buddies[uid] || 0) + 1;
          });
        });
        const topBuddies = Object.keys(buddies)
          .map(function(uid) { return { uid: uid, count: buddies[uid], user: allUsers[uid] }; })
          .sort(function(a, b) { return b.count - a.count; })
          .slice(0, 5);

        // Rutas favoritas: contamos repeticiones del nombre de ruta (ignorando placeholders)
        const routeCount = {};
        myRides.forEach(function(item) {
          const r = item.ride.route;
          if (!r) return;
          const txt = r.trim();
          if (!txt) return;
          const upper = txt.toUpperCase();
          if (upper === 'FALTA PROPUESTA RUTA' || upper === 'PENDIENTE DE RUTA' || upper === 'SIN RUTA' || upper === 'RUTA PENDIENTE') return;
          routeCount[txt] = (routeCount[txt] || 0) + 1;
        });
        const topRoutes = Object.keys(routeCount)
          .map(function(name) { return { name: name, count: routeCount[name] }; })
          .sort(function(a, b) { return b.count - a.count; })
          .slice(0, 3);

        // ---- ESTA SEMANA (resumen semanal estilo Garmin) ----
        function prevWeek(w) {
          const yr = Math.floor(w / 100);
          const wk = w % 100;
          return wk > 1 ? yr * 100 + (wk - 1) : (yr - 1) * 100 + 52;
        }
        const thisWeekRides = myRides.filter(function(item) { return weekNum(item.dateKey) === currentWeek; });
        const lastWeekNum = prevWeek(currentWeek);
        const lastWeekCount = myRides.filter(function(item) { return weekNum(item.dateKey) === lastWeekNum; }).length;
        const weekKm = thisWeekRides.reduce(function(s, item) { return s + parseKm(item.ride.distance); }, 0);
        // Días de la semana con salida: índice 0=Lun ... 6=Dom
        const weekDayHit = [false, false, false, false, false, false, false];
        thisWeekRides.forEach(function(item) {
          const p = item.dateKey.split('-');
          const dow = new Date(parseInt(p[0]), parseInt(p[1]) - 1, parseInt(p[2])).getDay(); // 0=Dom..6=Sab
          const idx = dow === 0 ? 6 : dow - 1; // Lun=0 ... Dom=6
          weekDayHit[idx] = true;
        });

        return {
          totalRides: myRides.length,
          totalKm: totalKm,
          lastRide: lastRide,
          streak: streak,
          myPos: myPos,
          myEntry: myEntry,
          aboveDiff: aboveDiff,
          topDiff: topDiff,
          topBuddies: topBuddies,
          topRoutes: topRoutes,
          weekRides: thisWeekRides.length,
          weekKm: weekKm,
          lastWeekCount: lastWeekCount,
          weekDayHit: weekDayHit,
        };
      }, [rides, rankings, currentUserId, grupeta && grupeta.rankingCutoff, allUsers]);

      const year = new Date().getFullYear();
      const myName = (currentUser && (currentUser.nombre || '')) || 'Tú';
      const ptsLabel = isSchool ? 'asistencias' : 'puntos';

      return (
        <Modal onClose={onClose}>
          <div className="space-y-4">
            {/* Cabecera */}
            <div className="text-center">
              <h2 className="display-font font-bold text-2xl text-red-700 tracking-wide">📊 MIS DATOS</h2>
              <p className="body-font text-xs text-gray-600 mt-1">
                {myName} · {grupeta.shortName || grupeta.name} · {year}
              </p>
            </div>

            {/* Sin salidas todavía */}
            {stats.totalRides === 0 && (
              <div className="bg-amber-50 border border-amber-200 rounded-xl p-5 text-center">
                <div className="text-4xl mb-2">🚴</div>
                <p className="display-font font-bold text-amber-900 mb-1">¡Aún no tienes salidas en {year}!</p>
                <p className="body-font text-xs text-amber-800">
                  Apúntate a tu primera salida y empezaremos a contar.
                </p>
              </div>
            )}

            {stats.totalRides > 0 && (
              <>
                {/* BLOQUE 0 — Tu semana (estilo Garmin) */}
                <div className="bg-white border border-gray-200 rounded-2xl p-4 shadow-sm">
                  <div className="flex items-center gap-2 mb-3">
                    <span className="text-xl">📅</span>
                    <span className="display-font font-bold text-sm text-gray-900 tracking-wide">TU SEMANA EN GRUPETA</span>
                  </div>
                  {/* Minicalendario L-D */}
                  <div className="flex gap-1.5 justify-center mb-3">
                    {['L', 'M', 'X', 'J', 'V', 'S', 'D'].map(function(d, i) {
                      const on = stats.weekDayHit[i];
                      return (
                        <div key={i} className="w-8 text-center">
                          <div className="text-[9px] font-bold text-gray-400 uppercase mb-1">{d}</div>
                          <div className={'w-6 h-6 mx-auto rounded-full flex items-center justify-center text-xs ' + (on ? 'bg-orange-500 text-white' : 'bg-gray-100 text-gray-300')}>
                            {on ? '🚴' : '·'}
                          </div>
                        </div>
                      );
                    })}
                  </div>
                  {/* Cifras + barras */}
                  <div className="flex justify-between items-baseline mb-1">
                    <span className="body-font text-xs text-gray-600">🚴 Salidas</span>
                    <span className="display-font font-bold text-base text-gray-900">{stats.weekRides}</span>
                  </div>
                  <div className="h-2 rounded-full bg-gray-200 overflow-hidden mb-3">
                    <div className="h-full rounded-full bg-gradient-to-r from-orange-600 to-orange-400" style={{ width: Math.min(100, stats.weekRides * 33) + '%' }}></div>
                  </div>
                  <div className="flex justify-between items-baseline mb-1">
                    <span className="body-font text-xs text-gray-600">📏 Kilómetros</span>
                    <span className="display-font font-bold text-base text-gray-900">{Math.round(stats.weekKm)} km</span>
                  </div>
                  <div className="h-2 rounded-full bg-gray-200 overflow-hidden mb-3">
                    <div className="h-full rounded-full bg-gradient-to-r from-orange-600 to-orange-400" style={{ width: Math.min(100, stats.weekKm / 2) + '%' }}></div>
                  </div>
                  {/* Frase motivadora */}
                  <div className="bg-orange-50 border border-orange-200 rounded-xl px-3 py-2.5 body-font text-xs text-orange-900 leading-relaxed">
                    {stats.weekRides === 0 ? (
                      <span>🚴 Aún no has salido esta semana. {stats.streak >= 1 ? <span>¡No rompas tu racha de <b>{stats.streak} {stats.streak === 1 ? 'semana' : 'semanas'}</b>!</span> : '¡Apúntate a la próxima!'}</span>
                    ) : (
                      <span>💪 <b>¡Buena semana!</b>{stats.streak >= 2 ? <span> Llevas <b>{stats.streak} semanas seguidas</b> sin fallar.</span> : ''}{stats.weekRides > stats.lastWeekCount ? <span> Una salida más que la semana pasada.</span> : (stats.weekRides === stats.lastWeekCount && stats.lastWeekCount > 0 ? <span> Igualas la semana pasada.</span> : '')}</span>
                    )}
                  </div>
                </div>

                {/* BLOQUE 1 — Resumen */}
                <div className="grid grid-cols-3 gap-2">
                  <div className="bg-red-50 border border-red-200 rounded-xl p-3 text-center">
                    <div className="display-font font-bold text-2xl text-red-700 leading-none">{stats.totalRides}</div>
                    <div className="body-font text-[10px] text-red-900 mt-1 uppercase tracking-wide">salidas</div>
                  </div>
                  <div className="bg-blue-50 border border-blue-200 rounded-xl p-3 text-center">
                    <div className="display-font font-bold text-2xl text-blue-700 leading-none">
                      {stats.totalKm > 0 ? Math.round(stats.totalKm) : '—'}
                    </div>
                    <div className="body-font text-[10px] text-blue-900 mt-1 uppercase tracking-wide">km totales</div>
                  </div>
                  <div className="bg-orange-50 border border-orange-200 rounded-xl p-3 text-center">
                    <div className="display-font font-bold text-2xl text-orange-700 leading-none">
                      {stats.streak > 0 ? '🔥' + stats.streak : '—'}
                    </div>
                    <div className="body-font text-[10px] text-orange-900 mt-1 uppercase tracking-wide">
                      {stats.streak === 1 ? 'semana' : 'semanas'} racha
                    </div>
                  </div>
                </div>

                {/* BLOQUE 1b — Logros por constancia (estilo Strava) */}
                {(function() {
                  var n = stats.totalRides;
                  var hitos = [
                    { min: 1, label: '🚴 1ª salida' },
                    { min: 5, label: '🟢 5 salidas' },
                    { min: 10, label: '✅ 10 salidas' },
                    { min: 25, label: '🥈 25 salidas' },
                    { min: 50, label: '🥇 50 salidas' },
                    { min: 100, label: '🏆 100 salidas' }
                  ];
                  var conseguidos = hitos.filter(function(h) { return n >= h.min; });
                  var siguiente = hitos.find(function(h) { return n < h.min; });
                  return (
                    <div className="flex flex-wrap gap-1.5">
                      {conseguidos.map(function(h) {
                        return <span key={h.min} className="text-[10px] font-bold display-font tracking-wide bg-green-100 text-green-700 border border-green-300 px-2 py-1 rounded-full">{h.label}</span>;
                      })}
                      {stats.streak >= 3 && <span className="text-[10px] font-bold display-font tracking-wide bg-orange-100 text-orange-700 border border-orange-300 px-2 py-1 rounded-full">🔥 racha {stats.streak} sem</span>}
                      {siguiente && <span className="text-[10px] font-bold display-font tracking-wide bg-gray-100 text-gray-400 border border-gray-200 px-2 py-1 rounded-full">🔒 {siguiente.label} ({siguiente.min - n} más)</span>}
                    </div>
                  );
                })()}

                {/* BLOQUE 2 — Última salida */}
                {stats.lastRide && (
                  <div className="bg-gray-50 border border-gray-200 rounded-xl p-3">
                    <div className="body-font text-[10px] text-gray-500 uppercase tracking-widest mb-1">Última salida</div>
                    <div className="display-font font-bold text-sm text-gray-900">
                      {stats.lastRide.ride.route || 'Sin nombre'}
                    </div>
                    <div className="body-font text-xs text-gray-600 mt-0.5">
                      {fmtShort(stats.lastRide.dateKey)}
                      {stats.lastRide.ride.distance ? ' · ' + stats.lastRide.ride.distance : ''}
                    </div>
                  </div>
                )}

                {/* BLOQUE 3 — Posición en ranking */}
                {stats.myPos > 0 && (
                  <div className="bg-yellow-50 border border-yellow-300 rounded-xl p-4">
                    <div className="body-font text-[10px] text-yellow-800 uppercase tracking-widest mb-1">Tu posición en el ranking {year}</div>
                    <div className="flex items-center gap-3">
                      <div className="display-font font-bold text-3xl text-red-700 leading-none">
                        {stats.myPos === 1 ? '🥇' : stats.myPos === 2 ? '🥈' : stats.myPos === 3 ? '🥉' : '#' + stats.myPos}
                      </div>
                      <div className="flex-1 min-w-0">
                        <div className="body-font text-sm text-gray-900">
                          <b>{stats.myEntry.value}</b> {ptsLabel} · {rankings.length} {rankings.length === 1 ? 'socio compite' : 'socios compiten'}
                        </div>
                      </div>
                    </div>
                    {(stats.aboveDiff != null || stats.topDiff != null) && (
                      <div className="mt-2 pt-2 border-t border-yellow-200 space-y-1">
                        {stats.aboveDiff != null && stats.aboveDiff > 0 && (
                          <div className="body-font text-xs text-yellow-900">
                            ⬆️ <b>{stats.aboveDiff}</b> {ptsLabel} para alcanzar al #{stats.myPos - 1}
                          </div>
                        )}
                        {stats.topDiff != null && stats.topDiff > 0 && stats.myPos > 3 && (
                          <div className="body-font text-xs text-yellow-900">
                            🥉 <b>{stats.topDiff}</b> {ptsLabel} te separan del podio
                          </div>
                        )}
                        {stats.myPos === 1 && (
                          <div className="body-font text-xs text-yellow-900 font-semibold">
                            👑 ¡Líder del ranking!
                          </div>
                        )}
                      </div>
                    )}
                  </div>
                )}

                {/* BLOQUE 4 — Compañeros de pedaleo (solo si grupeta tiene ≥5 socios) */}
                {sociosCount >= 5 && stats.topBuddies.length > 0 && (
                  <div className="bg-gray-50 border border-gray-200 rounded-xl p-3">
                    <div className="body-font text-[10px] text-gray-500 uppercase tracking-widest mb-2">👥 Compañeros de pedaleo</div>
                    <div className="space-y-2">
                      {stats.topBuddies.map(function(b) {
                        return (
                          <div key={b.uid}
                            onClick={function() { if (onViewProfile) onViewProfile(b.uid); }}
                            className="flex items-center gap-2 active:scale-[0.99] transition cursor-pointer">
                            <UserAvatar user={b.user} userId={b.uid} size={28} />
                            <div className="flex-1 min-w-0">
                              <div className="body-font text-sm text-gray-900 truncate">{getUserDisplay(b.user)}</div>
                            </div>
                            <div className="bg-red-100 text-red-800 rounded-lg px-2 py-0.5 text-xs font-bold flex-shrink-0">
                              {b.count}×
                            </div>
                          </div>
                        );
                      })}
                    </div>
                  </div>
                )}

                {/* BLOQUE 5 — Rutas favoritas */}
                {stats.topRoutes.length > 0 && (
                  <div className="bg-gray-50 border border-gray-200 rounded-xl p-3">
                    <div className="body-font text-[10px] text-gray-500 uppercase tracking-widest mb-2">🗺️ Tus rutas favoritas</div>
                    <div className="space-y-1.5">
                      {stats.topRoutes.map(function(r, idx) {
                        const medal = idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉';
                        return (
                          <div key={r.name} className="flex items-center gap-2">
                            <span className="text-sm w-6 text-center flex-shrink-0">{medal}</span>
                            <div className="flex-1 min-w-0 body-font text-sm text-gray-900 truncate">{r.name}</div>
                            <div className="body-font text-xs text-gray-600 flex-shrink-0">
                              {r.count} {r.count === 1 ? 'vez' : 'veces'}
                            </div>
                          </div>
                        );
                      })}
                    </div>
                  </div>
                )}
              </>
            )}

            <button onClick={onClose}
              className="w-full display-font font-bold text-sm tracking-wider bg-red-600 hover:bg-red-700 text-white py-3 rounded-xl active:scale-95 transition">
              CERRAR
            </button>
          </div>
        </Modal>
      );
    }

    // ============ RANKING ENTRE CLUBES (desafío entre grupetas) ============
    // Calcula, para CADA grupeta, asistencias / nº de socios / salidas, en el
    // periodo elegido (temporada = año actual, o este mes). Todo en cliente.
    function InterClubRankingModal({ grupetas, allUsers, currentUser, currentUserId, onClose }) {
      const [scope, setScope] = React.useState('clubs');        // clubs | riders
      const [metric, setMetric] = React.useState('perSocio');   // perSocio | total | salidas (clubs)
      const [riderMetric, setRiderMetric] = React.useState('salidas'); // salidas | km (riders)
      const [period, setPeriod] = React.useState('year');       // year | month

      // Extrae km de "80 km" / "100km" / "80"
      function parseKm(distStr) {
        if (!distStr) return 0;
        const m = String(distStr).match(/(\d+(?:[.,]\d+)?)/);
        if (!m) return 0;
        return parseFloat(m[1].replace(',', '.')) || 0;
      }

      // ---- Ranking de CLUBES ----
      const clubRows = React.useMemo(function() {
        const today = new Date();
        const todayK = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0');
        const yearPrefix = String(today.getFullYear());
        const monthPrefix = yearPrefix + '-' + String(today.getMonth() + 1).padStart(2, '0');
        const usersArr = Object.keys(allUsers || {}).map(function(uid) { return allUsers[uid]; });

        const list = Object.keys(grupetas || {}).map(function(gid) {
          const g = grupetas[gid] || {};
          const names = [g.name, g.shortName].filter(Boolean);
          const socios = usersArr.filter(function(u) { return userHasAnyClub(u, names); }).length;
          let asistencias = 0;
          let salidas = 0;
          const rides = g.rides || {};
          Object.keys(rides).forEach(function(k) {
            if (period === 'year' && k.indexOf(yearPrefix) !== 0) return;
            if (period === 'month' && k.indexOf(monthPrefix) !== 0) return;
            const dayRides = rides[k];
            if (!Array.isArray(dayRides)) return;
            if (g.rankingCutoff && k < g.rankingCutoff) return;
            dayRides.forEach(function(r) {
              if (!r || r.suspended || r.excludeFromRanking) return;
              if (!isPastRide(k, todayK, today)) return;
              if (!Array.isArray(r.participants)) return;
              let counted = 0;
              r.participants.forEach(function(uid) {
                if (typeof uid !== 'string' || !uid || !allUsers || !allUsers[uid]) return;
                counted += 1;
              });
              if (counted > 0) { asistencias += counted; salidas += 1; }
            });
          });
          return {
            id: gid,
            name: g.shortName || g.name || '—',
            socios: socios,
            asistencias: asistencias,
            salidas: salidas,
            perSocio: socios > 0 ? asistencias / socios : 0,
            isMine: userHasAnyClub(currentUser, names),
          };
        });
        const active = list.filter(function(r) { return r.asistencias > 0 || r.salidas > 0; });
        active.sort(function(a, b) { return (b[metric] || 0) - (a[metric] || 0); });
        return active;
      }, [grupetas, allUsers, currentUser, metric, period]);

      // ---- Ranking de CICLISTAS (todas las grupetas) ----
      const riderRows = React.useMemo(function() {
        const today = new Date();
        const todayK = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0');
        const yearPrefix = String(today.getFullYear());
        const monthPrefix = yearPrefix + '-' + String(today.getMonth() + 1).padStart(2, '0');
        const agg = {}; // uid -> { salidas, km }
        Object.keys(grupetas || {}).forEach(function(gid) {
          const g = grupetas[gid] || {};
          const rides = g.rides || {};
          Object.keys(rides).forEach(function(k) {
            if (period === 'year' && k.indexOf(yearPrefix) !== 0) return;
            if (period === 'month' && k.indexOf(monthPrefix) !== 0) return;
            const dayRides = rides[k];
            if (!Array.isArray(dayRides)) return;
            if (g.rankingCutoff && k < g.rankingCutoff) return;
            dayRides.forEach(function(r) {
              if (!r || r.suspended || r.excludeFromRanking) return;
              if (!isPastRide(k, todayK, today)) return;
              if (!Array.isArray(r.participants)) return;
              const km = parseKm(r.distance);
              r.participants.forEach(function(uid) {
                if (typeof uid !== 'string' || !uid || !allUsers || !allUsers[uid]) return;
                const a = agg[uid] || (agg[uid] = { salidas: 0, km: 0 });
                a.salidas += 1;
                a.km += km;
              });
            });
          });
        });
        const list = Object.keys(agg).map(function(uid) {
          const u = allUsers[uid] || {};
          const clubs = getUserClubs(u);
          return {
            id: uid,
            name: ((u.nombre || '') + ' ' + (u.apellidos || '').split(' ')[0]).trim() || 'Ciclista',
            club: clubs.length > 0 ? clubs.join(' · ') : '',
            avatar: u.avatar || '',
            salidas: agg[uid].salidas,
            km: Math.round(agg[uid].km),
            isMine: uid === currentUserId,
          };
        });
        list.sort(function(a, b) { return (b[riderMetric] || 0) - (a[riderMetric] || 0); });
        return list;
      }, [grupetas, allUsers, currentUserId, riderMetric, period]);

      const isClubs = scope === 'clubs';
      const rows = isClubs ? clubRows : riderRows;
      const activeMetric = isClubs ? metric : riderMetric;

      function fmtVal(r) {
        if (isClubs) {
          if (metric === 'perSocio') return (Math.round(r.perSocio * 10) / 10).toLocaleString('es-ES', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
          if (metric === 'total') return r.asistencias;
          return r.salidas;
        }
        return riderMetric === 'km' ? r.km : r.salidas;
      }
      const maxVal = rows.length > 0 ? Math.max.apply(null, rows.map(function(r) { return r[activeMetric] || 0; })) : 0;
      const metricNote = isClubs
        ? (metric === 'perSocio' ? 'Asistencias ÷ nº de socios — justo para clubes pequeños'
          : (metric === 'total' ? 'Suma total de asistencias' : 'Nº de salidas celebradas'))
        : (riderMetric === 'km' ? 'Km acumulados en salidas (aprox., según la distancia de cada ruta)' : 'Nº de veces que se apuntó a una salida');

      const segBtn = function(val, label, cur, setter) {
        return (
          <button onClick={function() { setter(val); }}
            className={'flex-1 text-[11px] font-bold py-1.5 px-1 rounded-full tracking-wide ' + (cur === val ? 'bg-white text-red-700 shadow-sm' : 'text-gray-500')}>
            {label}
          </button>
        );
      };

      return (
        <Modal onClose={onClose}>
          <div className="space-y-4">
            <div className="text-center">
              <h2 className="display-font font-bold text-xl text-red-700 tracking-wide">🏆 RANKING GLOBAL</h2>
              <p className="body-font text-xs text-gray-600 mt-1">
                {period === 'year' ? 'Temporada ' + new Date().getFullYear() : 'Este mes'} · {rows.length} {isClubs ? (rows.length === 1 ? 'grupeta' : 'grupetas') : (rows.length === 1 ? 'ciclista' : 'ciclistas')}
              </p>
            </div>

            {/* Toggle Clubes / Ciclistas */}
            <div className="flex bg-gray-100 rounded-full p-1">
              {segBtn('clubs', '👥 Clubes', scope, setScope)}
              {segBtn('riders', '🚴 Ciclistas', scope, setScope)}
            </div>

            {/* Toggle periodo */}
            <div className="flex bg-gray-100 rounded-full p-1">
              {segBtn('year', 'Temporada', period, setPeriod)}
              {segBtn('month', 'Este mes', period, setPeriod)}
            </div>

            {/* Selector métrica (según scope) */}
            {isClubs ? (
              <div className="flex bg-gray-100 rounded-full p-1">
                {segBtn('perSocio', 'Por socio', metric, setMetric)}
                {segBtn('total', 'Total', metric, setMetric)}
                {segBtn('salidas', 'Salidas', metric, setMetric)}
              </div>
            ) : (
              <div className="flex bg-gray-100 rounded-full p-1">
                {segBtn('salidas', 'Salidas', riderMetric, setRiderMetric)}
                {segBtn('km', 'Kilómetros', riderMetric, setRiderMetric)}
              </div>
            )}
            <p className="text-[10.5px] text-gray-400 text-center -mt-2">{metricNote}</p>

            {rows.length === 0 && (
              <div className="bg-amber-50 border border-amber-200 rounded-xl p-5 text-center">
                <div className="text-3xl mb-2">🚴</div>
                <p className="body-font text-sm text-amber-900">Aún no hay datos en este periodo.</p>
              </div>
            )}

            {rows.length > 0 && (
              <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
                {rows.map(function(r, i) {
                  const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : (i + 1);
                  const isMe = r.isMine;
                  const pct = maxVal > 0 ? Math.max(6, Math.round((r[activeMetric] / maxVal) * 100)) : 0;
                  const valSuffix = (!isClubs && riderMetric === 'km') ? '' : '';
                  return (
                    <div key={r.id} className={'flex items-center gap-2 px-3 py-2.5 ' + (i > 0 ? 'border-t border-gray-100 ' : '') + (isMe ? 'bg-orange-50' : '')}>
                      <div className="w-6 text-center font-bold text-sm text-gray-400">{medal}</div>
                      {!isClubs && (
                        r.avatar
                          ? <img src={r.avatar} alt="" className="w-8 h-8 rounded-full object-cover flex-shrink-0" />
                          : <div className="w-8 h-8 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center text-xs font-bold flex-shrink-0">{(r.name[0] || '?').toUpperCase()}</div>
                      )}
                      <div className="flex-1 min-w-0">
                        <div className={'body-font text-sm font-bold truncate ' + (isMe ? 'text-orange-700' : 'text-gray-800')}>
                          {r.name}{isMe ? (isClubs ? ' (la tuya)' : ' (tú)') : ''}
                        </div>
                        <div className="text-[10px] text-gray-400 truncate">
                          {isClubs ? (r.socios + ' ' + (r.socios === 1 ? 'socio' : 'socios')) : (r.club || '—')}
                        </div>
                      </div>
                      {!isClubs && allUsers[r.id] && <StravaButton user={allUsers[r.id]} />}
                      <div className="w-16 hidden xs:block">
                        <div className="h-1.5 rounded-full bg-gray-200 overflow-hidden">
                          <div className="h-full rounded-full bg-gradient-to-r from-orange-600 to-orange-400" style={{ width: pct + '%' }}></div>
                        </div>
                      </div>
                      <div className="display-font font-bold text-base text-gray-900 w-14 text-right">
                        {fmtVal(r)}{(!isClubs && riderMetric === 'km') ? <span className="text-[10px] text-gray-400 font-normal"> km</span> : ''}
                      </div>
                    </div>
                  );
                })}
              </div>
            )}

            <div className="flex gap-2 mt-2">
              <button onClick={onClose} className={cancelBtn}>CERRAR</button>
            </div>
          </div>
        </Modal>
      );
    }

    // ============ RANKING MODAL (full leaderboard for a grupeta) ============
    function RankingModal({ grupeta, rankings, isSchool, effectiveAdmin, onClose, onViewProfile }) {
      const cutoff = grupeta.rankingCutoff || '';
      const cutoffLabel = cutoff ? (function() {
        const p = cutoff.split('-');
        return p[2] + '/' + p[1] + '/' + p[0];
      })() : '';

      function exportCSV() {
        const header = ['Posicion', 'Nombre', 'Club', 'Telefono', 'Salidas', isSchool ? 'Asistencias' : 'Puntos', 'No terminadas', 'Sin ropa'];
        function csvEscape(v) {
          const s = (v == null ? '' : String(v));
          if (s.indexOf(',') >= 0 || s.indexOf('"') >= 0 || s.indexOf('\n') >= 0) {
            return '"' + s.replace(/"/g, '""') + '"';
          }
          return s;
        }
        const rows = rankings.map(function(it, idx) {
          const u = it.user || {};
          const name = (u.nombre || '') + (u.apellidos ? ' ' + u.apellidos : '');
          return [
            (idx + 1),
            name.trim() || 'Sin nombre',
            getUserClubsDisplay(u),
            u.telefono || '',
            it.rides || 0,
            it.value,
            it.noFinish || 0,
            it.noKit || 0
          ].map(csvEscape).join(',');
        });
        const csv = '\uFEFF' + header.map(csvEscape).join(',') + '\n' + rows.join('\n');
        const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        const today = new Date();
        const dk = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0');
        const safe = (grupeta.shortName || grupeta.name || 'grupeta').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
        a.href = url;
        a.download = 'ranking-' + safe + '-' + dk + '.csv';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-1">
            <div className="bg-yellow-100 text-yellow-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">
              <Crown size={18} strokeWidth={2.5} />
            </div>
            <div className="min-w-0">
              <h2 className="display-font font-bold text-2xl tracking-wide text-red-700 leading-tight">RANKING</h2>
              <p className="body-font text-[11px] text-gray-500 truncate">{grupeta.shortName || grupeta.name}</p>
            </div>
          </div>

          <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2.5 mb-3">
            <p className="body-font text-[11px] text-yellow-800 leading-snug">
              {isSchool
                ? 'El ranking cuenta las asistencias a salidas ya finalizadas. Cada salida en la que estés apuntado al pasar la fecha suma una participación.'
                : 'Los puntos se ganan apuntándose a salidas. Cada ruta otorga los puntos correspondientes a su dificultad (1, 3, 5 o 7).'}
              {cutoff && <span className="block mt-1 font-bold">Solo cuentan salidas desde el {cutoffLabel}.</span>}
            </p>
          </div>

          <div className="space-y-1.5 max-h-[55vh] overflow-y-auto scrollbar-hide -mx-1 px-1">
            {rankings.length === 0 ? (
              <div className="text-center py-8 text-gray-400 body-font text-sm">
                {isSchool ? (
                  <React.Fragment>
                    <p>Aún no hay asistencias registradas.</p>
                    <p className="text-[11px] mt-1">El ranking se llenará cuando pasen las primeras salidas.</p>
                  </React.Fragment>
                ) : (
                  <React.Fragment>
                    <p>Aún no hay ciclistas con puntos.</p>
                    <p className="text-[11px] mt-1">Apuntaros a salidas con dificultad para entrar al ranking.</p>
                  </React.Fragment>
                )}
              </div>
            ) : rankings.map(function(item, idx) {
              const pos = idx + 1;
              const medal = pos === 1 ? '🥇' : pos === 2 ? '🥈' : pos === 3 ? '🥉' : null;
              return (
                <div key={item.uid}
                  onClick={function() { onViewProfile(item.uid); }}
                  className="flex items-center gap-3 bg-gray-50 hover:bg-red-50 border border-gray-200 rounded-xl p-2.5 active:scale-[0.99] transition cursor-pointer">
                  <div className="w-9 flex-shrink-0 text-center">
                    {medal ? (
                      <span className="text-2xl">{medal}</span>
                    ) : (
                      <span className="display-font font-bold text-gray-500 text-sm">{pos}º</span>
                    )}
                  </div>
                  <UserAvatar user={item.user} userId={item.uid} size={36} />
                  <div className="flex-1 min-w-0">
                    <div className="body-font font-bold text-sm text-gray-800 truncate">{getUserDisplay(item.user)}</div>
                    {effectiveAdmin && getUserClubsDisplay(item.user) && <div className="body-font text-[10px] text-gray-500 truncate">{getUserClubsDisplay(item.user)}</div>}
                  </div>
                  <div className="bg-red-700 text-white rounded-lg px-2.5 py-1 text-xs font-bold flex-shrink-0">
                    {item.label}
                  </div>
                </div>
              );
            })}
          </div>

          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn + (effectiveAdmin && rankings.length > 0 ? ' flex-1' : ' w-full')}>CERRAR</button>
            {effectiveAdmin && rankings.length > 0 && (
              <button onClick={exportCSV} className="flex-1 bg-red-700 hover:bg-red-800 text-white rounded-xl py-3 text-sm font-bold tracking-wide display-font active:scale-95 transition">
                📥 EXPORTAR CSV
              </button>
            )}
          </div>
        </Modal>
      );
    }

    // ============ APUNTADOS (lista de participantes de una ruta) ============

    function ParticipantsListModal({ ride, dateKey, rideIdx, grupeta, allUsers, viewerIsAdmin, onAdminMutate, requireAcceptance, showToast, onClose, onViewProfile }) {
      const participants = ride.participants || [];
      const addedByAdmin = ride.addedByAdmin || {};
      // v91: controles de admin (añadir/quitar a mano + km reales)
      const [showAdd, setShowAdd] = React.useState(false);
      const [addQ, setAddQ] = React.useState('');
      const [confirmRm, setConfirmRm] = React.useState(null); // uid pendiente de quitar
      const [editKm, setEditKm] = React.useState(false);
      const [kmVal, setKmVal] = React.useState(ride.distance || '');

      function adminAdd(uid) {
        if (!onAdminMutate) return;
        onAdminMutate(function(r) {
          const parts = (r.participants || []).slice();
          if (parts.indexOf(uid) === -1) parts.push(uid);
          const flags = Object.assign({}, r.addedByAdmin || {});
          flags[uid] = true;
          return Object.assign({}, r, { participants: parts, addedByAdmin: flags });
        });
      }
      // v269: ya no se añade directo. Hay que pasarle el móvil/tablet a la persona y que
      // firme las normas ahí mismo (RulesModal, con SU nombre, no el del admin) — sin firma
      // no se añade. Antes esto era un bypass silencioso ("añadido a mano" sin firma nunca).
      function requestAdminAdd(uid, u) {
        if (!requireAcceptance || rideIdx == null || rideIdx === -1) {
          if (showToast) showToast('No se puede añadir sin firma ahora mismo');
          return;
        }
        const rideRef = { grupetaId: grupeta.id, dateKey: dateKey, rideIdx: rideIdx };
        requireAcceptance(uid, rideRef, function() { adminAdd(uid); }, null, { uid: uid, user: u });
      }
      function adminRemove(uid) {
        if (!onAdminMutate) return;
        onAdminMutate(function(r) {
          const parts = (r.participants || []).filter(function(p) { return p !== uid; });
          const flags = Object.assign({}, r.addedByAdmin || {});
          delete flags[uid];
          // v270-fix: si no se borra también la aceptación, al volver a añadir a la misma
          // persona el sistema cree que ya firmó esta salida y no vuelve a pedir firma.
          const accs2 = Object.assign({}, r.acceptances || {});
          delete accs2[uid];
          return Object.assign({}, r, { participants: parts, addedByAdmin: flags, acceptances: accs2 });
        });
        setConfirmRm(null);
      }
      function adminSaveKm() {
        if (!onAdminMutate) { setEditKm(false); return; }
        const v = (kmVal || '').toString().trim();
        onAdminMutate(function(r) { return Object.assign({}, r, { distance: v }); });
        setEditKm(false);
      }
      // Candidatos para el buscador: ciclistas con perfil, no apuntados ya.
      const addCandidates = React.useMemo(function() {
        const q = addQ.trim().toLowerCase();
        const list = Object.keys(allUsers || {}).map(function(uid) { return { uid: uid, u: allUsers[uid] }; })
          .filter(function(x) { return x.u && (x.u.nombre || x.u.apellidos || x.u.name); });
        const filtered = q.length === 0 ? list : list.filter(function(x) {
          const name = (getUserDisplay(x.u) || '').toLowerCase();
          const clubs = (getUserClubsDisplay(x.u) || '').toLowerCase();
          return name.indexOf(q) !== -1 || clubs.indexOf(q) !== -1;
        });
        filtered.sort(function(a, b) { return (getUserDisplay(a.u) || '').localeCompare(getUserDisplay(b.u) || ''); });
        return filtered.slice(0, 40);
      }, [addQ, allUsers]);
      // v28-oct20: acta de la salida (solo admins). grupetaName para el documento.
      const grupetaName = grupeta ? (grupeta.shortName || grupeta.name || '') : '';
      const accs = (ride && ride.acceptances) || {};
      function abrirActa(onlyUid) {
        try {
          const html = buildRideActaHTML(ride, grupetaName, dateKey, allUsers, onlyUid || null);
          const win = window.open('', '_blank');
          if (win) { win.document.write(html); win.document.close(); }
          else {
            const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'acta-salida.html';
            document.body.appendChild(a); a.click(); document.body.removeChild(a);
            setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
          }
        } catch (e) { /* noop */ }
      }
      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-1">
            <div className="bg-red-100 text-red-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">
              <Users size={18} strokeWidth={2.5} />
            </div>
            <div className="min-w-0">
              <h2 className="display-font font-bold text-2xl tracking-wide text-red-700 leading-tight">APUNTADOS</h2>
              <p className="body-font text-[11px] text-gray-500 truncate">{ride.route}</p>
            </div>
          </div>

          <p className="body-font text-[11px] text-gray-500 mb-3 px-1">
            {participants.length} {participants.length === 1 ? 'ciclista apuntado' : 'ciclistas apuntados'} a esta salida
          </p>

          {/* v268/v272: resumen de puntos de salida — visible siempre para el admin, y para
              cualquier socio si la grupeta tiene activado "mostrar a todos" */}
          {(viewerIsAdmin || puntosSalidaResumenPublico(grupeta)) && tienePuntosSalidaMultiples(grupeta) && (function() {
            const counts = getResumenPuntosSalida(grupeta, ride);
            if (counts.length === 0) return null;
            return (
              <div className="flex flex-wrap gap-2 mb-3 px-1">
                {counts.map(function(c, i) {
                  return (
                    <span key={i} className="text-[10px] body-font font-bold text-orange-800 bg-orange-50 border border-orange-200 rounded-full px-2.5 py-1">
                      📍 {c.n} {c.nombre}
                    </span>
                  );
                })}
              </div>
            );
          })()}

          {/* v269/v272: resumen de variantes — visible siempre para el admin, y para
              cualquier socio si la grupeta tiene activado "mostrar a todos" */}
          {(viewerIsAdmin || variantesApuntarseResumenPublico(grupeta)) && getVariantesApuntarse(grupeta).length > 0 && (function() {
            const counts = getResumenVariantesApuntarse(grupeta, ride);
            if (counts.length === 0) return null;
            return (
              <div className="flex flex-wrap gap-2 mb-3 px-1">
                {counts.map(function(c, i) {
                  return (
                    <span key={i} className="text-[10px] body-font font-bold text-blue-800 bg-blue-50 border border-blue-200 rounded-full px-2.5 py-1">
                      🔘 {c.n} {c.nombre}
                    </span>
                  );
                })}
              </div>
            );
          })()}

          {viewerIsAdmin && (
            <button onClick={function() { abrirActa(null); }}
              className="w-full mb-3 display-font text-[11px] font-bold tracking-wider px-3 py-2 rounded-lg bg-red-700 hover:bg-red-800 text-white flex items-center justify-center gap-1.5 active:scale-95 transition">
              📄 ACTA DE LA SALIDA (todos)
            </button>
          )}

          {viewerIsAdmin && onAdminMutate && (
            <div className="mb-3 bg-amber-50 border border-amber-200 rounded-xl p-2.5">
              {!editKm ? (
                <div className="flex items-center justify-between gap-2">
                  <span className="body-font text-[12px] text-amber-900">
                    📏 Km de la salida: <b>{ride.distance ? ride.distance : 'sin definir'}</b>
                  </span>
                  <button onClick={function() { setKmVal(ride.distance || ''); setEditKm(true); }}
                    className="text-[10px] display-font font-bold tracking-wider rounded-lg px-2.5 py-1.5 bg-white border border-amber-300 text-amber-800 hover:bg-amber-100 active:scale-95 transition">
                    EDITAR
                  </button>
                </div>
              ) : (
                <div className="flex items-center gap-2">
                  <input value={kmVal} onChange={function(e) { setKmVal(e.target.value); }}
                    placeholder="ej: 85 km" autoFocus
                    className="flex-1 min-w-0 border border-amber-300 rounded-lg px-3 py-1.5 text-sm" />
                  <button onClick={adminSaveKm}
                    className="text-[10px] display-font font-bold tracking-wider rounded-lg px-2.5 py-1.5 bg-green-600 text-white active:scale-95 transition">GUARDAR</button>
                  <button onClick={function() { setEditKm(false); }}
                    className="text-[10px] display-font font-bold tracking-wider rounded-lg px-2.5 py-1.5 bg-gray-200 text-gray-600 active:scale-95 transition">✕</button>
                </div>
              )}
              <p className="body-font text-[10px] text-amber-700 mt-1">Si la ruta cambió o no tenía km, ponlos aquí. Cuenta en el ranking de km.</p>
            </div>
          )}

          <div className="space-y-1.5 max-h-[55vh] overflow-y-auto scrollbar-hide -mx-1 px-1">
            {participants.length === 0 ? (
              <div className="text-center py-8 text-gray-400 body-font text-sm">
                <p>Aún no hay nadie apuntado.</p>
              </div>
            ) : participants.map(function(pid, idx) {
              // v28-quatre: la rama "Apuntado anónimo" solo aplica al marcador legacy 'Tú' de v27.
              // Para cualquier otro UID se intenta cargar perfil; si no existe, se muestra "Ciclista no disponible".
              if (pid === 'Tú') {
                return (
                  <div key={'leg-' + idx}
                    className="flex items-center gap-3 bg-gray-50 border border-gray-200 rounded-xl p-2.5">
                    <div className="w-9 h-9 rounded-full bg-gray-300 text-gray-600 flex items-center justify-center text-sm font-bold flex-shrink-0">?</div>
                    <div className="flex-1 min-w-0">
                      <div className="body-font font-bold text-sm text-gray-500 italic truncate">Apuntado anónimo</div>
                    </div>
                  </div>
                );
              }
              const u = allUsers[pid];
              if (!u) {
                return (
                  <div key={pid}
                    className="flex items-center gap-3 bg-gray-50 border border-gray-200 rounded-xl p-2.5">
                    <div className="w-9 h-9 rounded-full bg-gray-300 text-gray-600 flex items-center justify-center text-sm font-bold flex-shrink-0">?</div>
                    <div className="flex-1 min-w-0">
                      <div className="body-font font-bold text-sm text-gray-500 italic truncate">Ciclista no disponible</div>
                    </div>
                  </div>
                );
              }
              const haAceptado = !!(accs[pid] && accs[pid].acceptedAt);
              return (
                <div key={pid}
                  className="flex items-center gap-3 bg-gray-50 hover:bg-red-50 border border-gray-200 rounded-xl p-2.5 transition">
                  <div onClick={function() { onViewProfile(pid); }} className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer active:scale-[0.99]">
                    <UserAvatar user={u} userId={pid} size={36} />
                    <div className="flex-1 min-w-0">
                      <div className="body-font font-bold text-sm text-gray-800 truncate">
                        {getUserDisplay(u)}
                        {addedByAdmin[pid] && <span className="text-[10px] text-green-600 font-semibold"> · añadido a mano</span>}
                      </div>
                      {viewerIsAdmin && getUserClubsDisplay(u) && <div className="body-font text-[10px] text-gray-500 truncate">{getUserClubsDisplay(u)}</div>}
                      {tienePuntosSalidaMultiples(grupeta) && (function() {
                        const puntos = getPuntosSalida(grupeta);
                        const pId = (ride.puntoSalida && ride.puntoSalida[pid]) || (puntos[0] && puntos[0].id);
                        const nombre = nombrePuntoSalida(grupeta, pId);
                        return nombre ? <div className="body-font text-[10px] text-orange-700 truncate">📍 {nombre}</div> : null;
                      })()}
                      {(function() {
                        const vId = ride.variante && ride.variante[pid];
                        const nombre = nombreVarianteApuntarse(grupeta, vId);
                        return nombre ? <div className="body-font text-[10px] text-blue-700 truncate">🔘 {nombre}</div> : null;
                      })()}
                    </div>
                  </div>
                  <StravaButton user={u} />
                  {viewerIsAdmin && haAceptado && (
                    <button onClick={function(e) { e.stopPropagation(); abrirActa(pid); }}
                      title="Justificante de este ciclista en esta salida"
                      className="flex-shrink-0 text-[10px] display-font font-bold tracking-wider rounded-lg px-2 py-1.5 bg-white border border-red-200 text-red-700 hover:bg-red-50 active:scale-95 transition">
                      📄
                    </button>
                  )}
                  {viewerIsAdmin && onAdminMutate && (
                    confirmRm === pid ? (
                      <div className="flex-shrink-0 flex items-center gap-1">
                        <button onClick={function(e) { e.stopPropagation(); adminRemove(pid); }}
                          className="text-[10px] display-font font-bold rounded-lg px-2 py-1.5 bg-red-600 text-white active:scale-95 transition">SÍ, QUITAR</button>
                        <button onClick={function(e) { e.stopPropagation(); setConfirmRm(null); }}
                          className="text-[10px] display-font font-bold rounded-lg px-2 py-1.5 bg-gray-200 text-gray-600 active:scale-95 transition">NO</button>
                      </div>
                    ) : (
                      <button onClick={function(e) { e.stopPropagation(); setConfirmRm(pid); }}
                        title="Quitar de la salida"
                        className="flex-shrink-0 w-7 h-7 rounded-full bg-red-100 text-red-600 font-bold flex items-center justify-center active:scale-95 transition">✕</button>
                    )
                  )}
                </div>
              );
            })}
          </div>

          {viewerIsAdmin && onAdminMutate && (
            <div className="mt-3">
              {!showAdd ? (
                <button onClick={function() { setShowAdd(true); setAddQ(''); }}
                  className="w-full py-3 rounded-xl border-2 border-dashed border-red-300 bg-red-50 text-red-700 display-font font-bold text-[13px] tracking-wide active:scale-[0.99] transition">
                  ➕ AÑADIR CICLISTA
                </button>
              ) : (
                <div className="border border-gray-200 rounded-xl p-2.5 bg-white">
                  <div className="flex items-center justify-between mb-2">
                    <span className="display-font font-bold text-[12px] text-red-700 tracking-wide">AÑADIR CICLISTA</span>
                    <button onClick={function() { setShowAdd(false); }} className="text-[11px] text-gray-500 font-bold">✕ cerrar</button>
                  </div>
                  <input value={addQ} onChange={function(e) { setAddQ(e.target.value); }}
                    placeholder="Buscar por nombre…" autoFocus
                    className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm mb-2" />
                  <p className="text-[10px] text-gray-400 mb-2 leading-snug">
                    Al elegir a alguien, se le pedirá firmar las normas en este dispositivo
                    (pásale el móvil/tablet). Sin firma no se añade.
                  </p>
                  <div className="max-h-[40vh] overflow-y-auto scrollbar-hide space-y-1">
                    {addCandidates.length === 0 ? (
                      <p className="text-center text-gray-400 text-xs py-4">Sin resultados.</p>
                    ) : addCandidates.map(function(x) {
                      const yaEsta = participants.indexOf(x.uid) !== -1;
                      return (
                        <div key={x.uid}
                          className={'flex items-center gap-2 rounded-lg px-2 py-1.5 ' + (yaEsta ? 'opacity-50' : 'active:bg-red-50 cursor-pointer')}
                          onClick={yaEsta ? null : function() { requestAdminAdd(x.uid, x.u); }}>
                          <UserAvatar user={x.u} userId={x.uid} size={30} />
                          <div className="flex-1 min-w-0">
                            <div className="body-font text-sm font-semibold text-gray-800 truncate">{getUserDisplay(x.u)}</div>
                            <div className="text-[10px] text-gray-400 truncate">{getUserClubsDisplay(x.u) || (yaEsta ? 'Ya apuntado' : '—')}</div>
                          </div>
                          <div className={'w-7 h-7 rounded-full flex items-center justify-center font-bold text-sm flex-shrink-0 ' + (yaEsta ? 'bg-gray-200 text-gray-400' : 'bg-green-600 text-white')}>
                            {yaEsta ? '✓' : '✍️'}
                          </div>
                        </div>
                      );
                    })}
                  </div>
                </div>
              )}
            </div>
          )}

          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn + ' w-full'}>CERRAR</button>
          </div>
        </Modal>
      );
    }

    // ============ HISTORIAL (rutas finalizadas + participantes) ============

    function PenaltyConfigModal({ config, canEdit, onClose, onSave }) {
      const editable = canEdit !== false;
      const [noFinish, setNoFinish] = useState(String(config.noFinish != null ? config.noFinish : 1));
      const [noKit, setNoKit] = useState(String(config.noKit != null ? config.noKit : 1));
      const [photoBonus, setPhotoBonus] = useState(String(config.photoBonus != null ? config.photoBonus : 1));
      const [photoBonusSchool, setPhotoBonusSchool] = useState(String(config.photoBonusSchool != null ? config.photoBonusSchool : 1));
      const [err, setErr] = useState('');

      function submit() {
        const nf = parseInt(noFinish);
        const nk = parseInt(noKit);
        const pb = parseInt(photoBonus);
        const pbs = parseInt(photoBonusSchool);
        if (isNaN(nf) || nf < 0 || isNaN(nk) || nk < 0 || isNaN(pb) || pb < 0 || isNaN(pbs) || pbs < 0) {
          setErr('Los valores deben ser números enteros ≥ 0');
          return;
        }
        onSave({ noFinish: nf, noKit: nk, photoBonus: pb, photoBonusSchool: pbs });
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-1">
            <div className="bg-red-100 text-red-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">
              <span className="text-lg">⚙️</span>
            </div>
            <div className="min-w-0">
              <h2 className="display-font font-bold text-2xl tracking-wide text-red-700 leading-tight">AJUSTES DE RANKING</h2>
              <p className="body-font text-[11px] text-gray-500">Penalizaciones y bonificaciones</p>
            </div>
          </div>

          <div className="bg-amber-50 border border-amber-200 rounded-lg p-2.5 mb-3 mt-3">
            <p className="body-font text-[11px] text-amber-900 leading-snug">
              <span className="font-bold">Penalizaciones:</span> los admins de cada grupeta marcan estas infracciones desde el HISTORIAL. En grupetas normales se restan estos puntos por salida; en escuelas se restan asistencias.
              {!editable && <span className="block mt-1 font-bold">Solo el administrador central puede modificar estos valores.</span>}
            </p>
          </div>

          <FormField label="❌ No terminó la ruta">
            <input type="number" min="0" inputMode="numeric" value={noFinish}
              readOnly={!editable} disabled={!editable}
              onChange={function(e) { setNoFinish(e.target.value); setErr(''); }}
              className={inputCls + (!editable ? ' bg-gray-100 text-gray-600 cursor-not-allowed' : '')} />
          </FormField>

          <FormField label="👕 Sin ropa del club">
            <input type="number" min="0" inputMode="numeric" value={noKit}
              readOnly={!editable} disabled={!editable}
              onChange={function(e) { setNoKit(e.target.value); setErr(''); }}
              className={inputCls + (!editable ? ' bg-gray-100 text-gray-600 cursor-not-allowed' : '')} />
          </FormField>

          <div className="bg-green-50 border border-green-200 rounded-lg p-2.5 mb-3 mt-1">
            <p className="body-font text-[11px] text-green-900 leading-snug">
              <span className="font-bold">Bonus por foto:</span> cuando un participante sube una foto en el HISTORIAL de una salida, todos los que fueron reciben estos puntos extra. Solo cuenta una foto por salida.
            </p>
          </div>

          <FormField label="📸 Bonus por foto (grupetas normales)">
            <input type="number" min="0" inputMode="numeric" value={photoBonus}
              readOnly={!editable} disabled={!editable}
              onChange={function(e) { setPhotoBonus(e.target.value); setErr(''); }}
              className={inputCls + (!editable ? ' bg-gray-100 text-gray-600 cursor-not-allowed' : '')} />
          </FormField>

          <FormField label="📸 Bonus por foto (escuelas)">
            <input type="number" min="0" inputMode="numeric" value={photoBonusSchool}
              readOnly={!editable} disabled={!editable}
              onChange={function(e) { setPhotoBonusSchool(e.target.value); setErr(''); }}
              className={inputCls + (!editable ? ' bg-gray-100 text-gray-600 cursor-not-allowed' : '')} />
          </FormField>

          {err && <p className="text-red-600 text-xs body-font mt-2 font-medium">{err}</p>}

          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn + (editable ? '' : ' w-full')}>{editable ? 'CANCELAR' : 'CERRAR'}</button>
            {editable && <button onClick={submit} className={primaryBtn}>GUARDAR</button>}
          </div>
        </Modal>
      );
    }


    // HistoryModal → js/modals-a.js
    function HazteSocioModal({ grupeta, currentUser, onClose, onSubmit }) {
      const [nombre, setNombre] = useState(currentUser ? currentUser.nombre || '' : '');
      const [apellidos, setApellidos] = useState(currentUser ? currentUser.apellidos || '' : '');
      const [dni, setDni] = useState('');
      const [fechaNacimiento, setFechaNacimiento] = useState('');
      const [direccion, setDireccion] = useState('');
      const [cp, setCp] = useState('');
      const [ciudad, setCiudad] = useState('');
      const [telefono, setTelefono] = useState(currentUser ? currentUser.telefono || '' : '');
      const [correo, setCorreo] = useState(currentUser ? currentUser.correo || '' : '');
      const [emergenciaNombre, setEmergenciaNombre] = useState('');
      const [emergenciaTelefono, setEmergenciaTelefono] = useState('');
      const [talla, setTalla] = useState('M');
      const [federado, setFederado] = useState(false);
      const [observaciones, setObservaciones] = useState('');
      const [err, setErr] = useState('');

      function submit() {
        if (!nombre.trim() || !apellidos.trim()) { setErr('Nombre y apellidos son obligatorios'); return; }
        if (!dni.trim()) { setErr('DNI / NIE es obligatorio'); return; }
        if (!fechaNacimiento) { setErr('Fecha de nacimiento es obligatoria'); return; }
        if (!telefono.trim()) { setErr('Teléfono es obligatorio'); return; }
        if (correo.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(correo.trim())) { setErr('Correo no válido'); return; }
        onSubmit({
          nombre: nombre.trim(), apellidos: apellidos.trim(),
          dni: dni.trim(), fechaNacimiento: fechaNacimiento,
          direccion: direccion.trim(), cp: cp.trim(), ciudad: ciudad.trim(),
          telefono: telefono.trim(), correo: correo.trim(),
          emergenciaNombre: emergenciaNombre.trim(),
          emergenciaTelefono: emergenciaTelefono.trim(),
          talla: talla, federado: federado,
          observaciones: observaciones.trim()
        });
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-1">
            <div className="bg-red-100 text-red-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">
              <Users size={18} strokeWidth={2.5} />
            </div>
            <div className="min-w-0">
              <h2 className="display-font font-bold text-2xl tracking-wide text-red-700 leading-tight">HAZTE SOCIO</h2>
              <p className="body-font text-[11px] text-gray-500 truncate">{grupeta.shortName || grupeta.name}</p>
            </div>
          </div>

          <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2.5 mb-3">
            <p className="body-font text-[11px] text-yellow-800 leading-snug">
              Rellena los datos para enviar tu solicitud de ingreso. La revisará el responsable de la grupeta. <strong>No se piden datos bancarios</strong> — esos los gestionará el club por separado si tu solicitud es aceptada.
            </p>
          </div>

          <div className="space-y-3">
            <h3 className="text-[10px] font-bold tracking-widest text-gray-500 uppercase border-b border-gray-200 pb-1">Datos personales</h3>
            <div className="grid grid-cols-2 gap-3">
              <FormField label="Nombre *">
                <input type="text" value={nombre} onChange={function(e) { setNombre(e.target.value); setErr(''); }} className={inputCls} />
              </FormField>
              <FormField label="Apellidos *">
                <input type="text" value={apellidos} onChange={function(e) { setApellidos(e.target.value); setErr(''); }} className={inputCls} />
              </FormField>
            </div>
            <div className="grid grid-cols-2 gap-3">
              <FormField label="DNI / NIE *">
                <input type="text" value={dni} onChange={function(e) { setDni(e.target.value); setErr(''); }} placeholder="12345678X" className={inputCls} />
              </FormField>
              <FormField label="Fecha nacimiento *">
                <input type="date" value={fechaNacimiento} onChange={function(e) { setFechaNacimiento(e.target.value); setErr(''); }} className={inputCls} />
              </FormField>
            </div>

            <h3 className="text-[10px] font-bold tracking-widest text-gray-500 uppercase border-b border-gray-200 pb-1 mt-4">Contacto</h3>
            <FormField label="Teléfono *">
              <input type="tel" value={telefono} onChange={function(e) { setTelefono(e.target.value); setErr(''); }} placeholder="+34 600 000 000" className={inputCls} />
            </FormField>
            <FormField label="Correo">
              <input type="email" value={correo} onChange={function(e) { setCorreo(e.target.value); setErr(''); }} placeholder="tu@correo.com" className={inputCls} />
            </FormField>
            <FormField label="Dirección">
              <input type="text" value={direccion} onChange={function(e) { setDireccion(e.target.value); }} placeholder="Calle, número, piso" className={inputCls} />
            </FormField>
            <div className="grid grid-cols-3 gap-3">
              <FormField label="C.P.">
                <input type="text" value={cp} onChange={function(e) { setCp(e.target.value); }} placeholder="30203" className={inputCls} />
              </FormField>
              <div className="col-span-2">
                <FormField label="Ciudad">
                  <input type="text" value={ciudad} onChange={function(e) { setCiudad(e.target.value); }} placeholder="Cartagena" className={inputCls} />
                </FormField>
              </div>
            </div>

            <h3 className="text-[10px] font-bold tracking-widest text-gray-500 uppercase border-b border-gray-200 pb-1 mt-4">Contacto de emergencia</h3>
            <div className="grid grid-cols-2 gap-3">
              <FormField label="Nombre">
                <input type="text" value={emergenciaNombre} onChange={function(e) { setEmergenciaNombre(e.target.value); }} className={inputCls} />
              </FormField>
              <FormField label="Teléfono">
                <input type="tel" value={emergenciaTelefono} onChange={function(e) { setEmergenciaTelefono(e.target.value); }} className={inputCls} />
              </FormField>
            </div>

            <h3 className="text-[10px] font-bold tracking-widest text-gray-500 uppercase border-b border-gray-200 pb-1 mt-4">Datos deportivos</h3>
            <div className="grid grid-cols-2 gap-3">
              <FormField label="Talla maillot">
                <select value={talla} onChange={function(e) { setTalla(e.target.value); }} className={inputCls}>
                  {['XS','S','M','L','XL','XXL','XXXL'].map(function(s) { return <option key={s} value={s}>{s}</option>; })}
                </select>
              </FormField>
              <FormField label="Federado">
                <select value={federado ? 'si' : 'no'} onChange={function(e) { setFederado(e.target.value === 'si'); }} className={inputCls}>
                  <option value="no">No</option>
                  <option value="si">Sí</option>
                </select>
              </FormField>
            </div>
            <FormField label="Observaciones">
              <textarea value={observaciones} onChange={function(e) { setObservaciones(e.target.value); }} rows={3}
                placeholder="Experiencia, motivación o cualquier dato relevante" className={inputCls + ' resize-none'} />
            </FormField>
          </div>

          {err && <p className="text-red-600 text-xs body-font mt-3 font-medium">{err}</p>}

          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn}>CANCELAR</button>
            <button onClick={submit} className={primaryBtn}>ENVIAR SOLICITUD</button>
          </div>
        </Modal>
      );
    }

    function FuturosSociosModal({ grupeta, applications, onClose, onDelete, onAccept }) {
      const list = (applications || []).slice().sort(function(a, b) {
        return (b.submittedAt || '').localeCompare(a.submittedAt || '');
      });

      function handleDownload(app) {
        const html = buildApplicationHTML(app, grupeta);
        const win = window.open('', '_blank');
        if (win) {
          win.document.write(html);
          win.document.close();
        } else {
          // Popup blocked: fall back to download
          const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
          const url = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = url;
          a.download = 'solicitud-' + slugify((app.nombre || '') + ' ' + (app.apellidos || '')) + '.html';
          document.body.appendChild(a); a.click(); document.body.removeChild(a);
          setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
        }
      }

      return (
        <Modal onClose={onClose}>
          <div className="flex items-center gap-3 mb-1">
            <div className="bg-yellow-100 text-yellow-700 w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0">
              <Users size={18} strokeWidth={2.5} />
            </div>
            <div className="min-w-0">
              <h2 className="display-font font-bold text-2xl tracking-wide text-red-700 leading-tight">FUTUROS SOCIOS</h2>
              <p className="body-font text-[11px] text-gray-500 truncate">
                {list.length} {list.length === 1 ? 'solicitud pendiente' : 'solicitudes pendientes'}
              </p>
            </div>
          </div>

          <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2.5 mb-3">
            <p className="body-font text-[11px] text-yellow-800 leading-snug">
              Personas que han enviado su solicitud para hacerse socias de <strong>{grupeta.shortName || grupeta.name}</strong>. Pulsa <strong>DESCARGAR</strong> para abrir la solicitud completa e imprimirla o guardarla como PDF.
            </p>
          </div>

          <div className="space-y-2 max-h-[55vh] overflow-y-auto scrollbar-hide -mx-1 px-1">
            {list.length === 0 ? (
              <div className="text-center py-8 text-gray-400 body-font text-sm">
                <p>No hay solicitudes pendientes</p>
              </div>
            ) : list.map(function(app) {
              const display = (app.nombre || '') + ' ' + (app.apellidos || '');
              const date = app.submittedAt ? new Date(app.submittedAt).toLocaleDateString('es-ES') : '';
              return (
                <div key={app.id} className="bg-gray-50 border border-gray-200 rounded-xl p-3">
                  <div className="flex items-start justify-between gap-2 mb-2">
                    <div className="flex-1 min-w-0">
                      <div className="body-font text-sm font-bold text-gray-800 truncate">{display}</div>
                      <div className="body-font text-[10px] text-gray-500">
                        Enviada el {date} · DNI {app.dni}
                      </div>
                    </div>
                  </div>
                  <div className="body-font text-[11px] text-gray-600 mb-2">
                    {app.telefono && <span>📞 {app.telefono} </span>}
                    {app.correo && <span>· ✉️ {app.correo}</span>}
                  </div>
                  <div className="flex gap-2">
                    <button onClick={function() {
                      if (typeof onAccept === 'function') onAccept(app);
                    }}
                      className="flex-1 bg-green-600 hover:bg-green-700 text-white rounded-lg py-2 font-bold body-font text-[11px] tracking-wider active:scale-95 transition flex items-center justify-center gap-1.5">
                      ✓ ACEPTAR
                    </button>
                    <button onClick={function() { handleDownload(app); }}
                      className="flex-1 bg-red-700 hover:bg-red-800 text-white rounded-lg py-2 font-bold body-font text-[11px] tracking-wider active:scale-95 transition flex items-center justify-center gap-1.5">
                      📥 DESCARGAR
                    </button>
                    <button onClick={function() {
                      if (!window.__confirmPending_delapp) { window.__confirmPending_delapp=true; setTimeout(function(){ window.__confirmPending_delapp=false; },3500); showToast('Pulsa de nuevo para borrar'); } else { window.__confirmPending_delapp=false; onDelete(app.id); }
                    }}
                      className="bg-white border border-red-300 text-red-700 hover:bg-red-50 rounded-lg px-3 py-2 active:scale-95 transition">
                      <Trash2 size={14} />
                    </button>
                  </div>
                </div>
              );
            })}
          </div>

          <div className="flex gap-2 mt-5">
            <button onClick={onClose} className={cancelBtn + ' w-full'}>CERRAR</button>
          </div>
        </Modal>
      );
    }

    // ============ MULTI-CLUB HELPERS (v28-quinque) ============
    // Un perfil puede tener `clubs` (array) en la BD nueva, o `club` (string) en la BD vieja.
    // Estos helpers son la ÚNICA fuente de verdad para leer/escribir/comparar la pertenencia a
    // grupetas. La regla: si existe `clubs` array, ese manda; si no, se cae al `club` string.

    // Devuelve un array de strings con los clubes a los que pertenece el usuario.
    // Compatible con el formato viejo (string) y el nuevo (array).
    // Normaliza el valor de Strava del perfil a una URL válida.
    // Acepta: URL completa, "strava.com/...", o un usuario/ID suelto.
    function stravaUrl(raw) {
      var s = (raw == null ? '' : String(raw)).trim();
      if (!s) return '';
      if (/^https?:\/\//i.test(s)) return s;
      if (/^(www\.)?strava\.com\//i.test(s)) return 'https://' + s.replace(/^www\./i, '');
      var u = s.replace(/^@/, '').replace(/\s+/g, '');
      if (!u) return '';
      // Solo dígitos → perfil por ID de atleta; si no, lo tratamos como ruta/usuario.
      return /^\d+$/.test(u) ? ('https://www.strava.com/athletes/' + u) : ('https://www.strava.com/athletes/' + u);
    }

    function StravaButton(props) {
      var url = stravaUrl(props.user && props.user.strava);
      if (!url) return null;
      var label = props.label || 'Strava';
      return (
        <a href={url} target="_blank" rel="noopener noreferrer"
          onClick={function(e) { e.stopPropagation(); }}
          title="Ver perfil en Strava"
          className="flex-shrink-0 inline-flex items-center gap-1 text-[10.5px] font-bold tracking-wide rounded-lg px-2 py-1.5 text-white active:scale-95 transition"
          style={{ background: '#fc4c02' }}>
          <svg viewBox="0 0 24 24" width="11" height="11" fill="#fff" aria-hidden="true"><path d="M15.4 1L9 13.2h3.8L15.4 8l2.6 5.2H21L15.4 1zm0 16.8L13.6 14h-2.4l4.2 8 4.2-8h-2.4l-1.8 3.8z"/></svg>
          {label}
        </a>
      );
    }

    function getUserClubs(user) {
      if (!user || typeof user !== 'object') return [];
      if (Array.isArray(user.clubs)) {
        return user.clubs.filter(function(c) { return typeof c === 'string' && c.trim(); });
      }
      if (typeof user.club === 'string' && user.club.trim()) {
        return [user.club.trim()];
      }
      return [];
    }

    // ¿El usuario pertenece a este club/grupeta? Comparación case-insensitive.
    // `name` puede ser el shortName o el name de la grupeta.
    function userHasClub(user, name) {
      if (!name) return false;
      const target = String(name).trim().toLowerCase();
      if (!target) return false;
      const list = getUserClubs(user);
      for (let i = 0; i < list.length; i++) {
        if (list[i].toLowerCase() === target) return true;
      }
      return false;
    }

    // ¿El usuario pertenece a cualquiera de los nombres dados? Útil para grupetas que
    // se identifican por shortName Y por name (ej. "CAMANTES CARTAGENA" / "Salidas Camantes").
    function userHasAnyClub(user, names) {
      if (!Array.isArray(names)) return false;
      for (let i = 0; i < names.length; i++) {
        if (userHasClub(user, names[i])) return true;
      }
      return false;
    }

    // ¿Tiene al menos un club asignado? (útil para "Sin club")
    function userHasAnyClubAssigned(user) {
      return getUserClubs(user).length > 0;
    }

    // Devuelve la representación EN STRING de los clubes del usuario (para mostrar en UI / CSV).
    // p.ej. "CAMANTES CARTAGENA, CLUB CICLISTA LOS DOLORES" o "" si ninguno.
    function getUserClubsDisplay(user) {
      const list = getUserClubs(user);
      return list.join(', ');
    }

    // ============ AUTH HELPERS (v28-ter) ============
    function authErrorMessage(err) {
      const code = (err && err.code) || '';
      switch (code) {
        case 'auth/email-already-in-use':
          return 'Este correo ya tiene cuenta. ¿Olvidaste la contraseña?';
        case 'auth/invalid-email':
          return 'El correo no tiene un formato válido.';
        case 'auth/weak-password':
          return 'La contraseña debe tener al menos 8 caracteres.';
        case 'auth/user-not-found':
        case 'auth/wrong-password':
        case 'auth/invalid-credential':
        case 'auth/invalid-login-credentials':
          return 'Correo o contraseña incorrectos.';
        case 'auth/too-many-requests':
          return 'Demasiados intentos. Inténtalo más tarde.';
        case 'auth/network-request-failed':
          return 'Sin conexión. Comprueba tu internet.';
        case 'auth/operation-not-allowed':
          return 'El acceso por correo está deshabilitado. Avisa al administrador.';
        default:
          return (err && err.message) || 'Algo ha ido mal. Inténtalo de nuevo.';
      }
    }

    function findExistingProfileByEmail(email, allUsers) {
      const target = String(email || '').trim().toLowerCase();
      if (!target) return null;
      const keys = Object.keys(allUsers || {});
      for (let i = 0; i < keys.length; i++) {
        const uid = keys[i];
        const u = allUsers[uid];
        if (!u) continue;
        const c = String(u.correo || '').trim().toLowerCase();
        if (c && c === target) return { uid: uid, profile: u };
      }
      return null;
    }

    // v28-quinque: devuelve TODOS los perfiles con ese correo. Excluye el UID `excludeUid`
    // (típicamente el UID actual de Auth) para no devolverse a sí mismo.
    function findAllProfilesByEmail(email, allUsers, excludeUid) {
      const target = String(email || '').trim().toLowerCase();
      if (!target) return [];
      const out = [];
      const keys = Object.keys(allUsers || {});
      for (let i = 0; i < keys.length; i++) {
        const uid = keys[i];
        if (uid === excludeUid) continue;
        const u = allUsers[uid];
        if (!u) continue;
        const c = String(u.correo || '').trim().toLowerCase();
        if (c && c === target) out.push({ uid: uid, profile: u });
      }
      return out;
    }

    // Construye un objeto multi-path para mover datos del UID viejo al nuevo
    // de forma atómica (un solo db.ref().update(...)).
    // v28-quinque: ahora acepta UNA LISTA de UIDs viejos a fusionar (no solo uno).
    // - oldUids: array de strings con todos los UIDs duplicados a absorber.
    // - oldProfiles: array (en el mismo orden) con sus perfiles, para combinar campos.
    // - Si solo se pasa uno, funciona exactamente como antes (retrocompatible).
    function buildMigrationUpdates(oldUids, newUid, oldProfiles, grupetasSnap, acceptancesSnap, verifiedEmail) {
      // Retro-compat: si llaman con (uid, newUid, profile, ...) tipo viejo, convertir.
      if (typeof oldUids === 'string') {
        oldUids = [oldUids];
        oldProfiles = [oldProfiles];
      }
      oldUids = (oldUids || []).filter(Boolean);
      oldProfiles = oldProfiles || [];

      const updates = {};

      // 1) Combinar perfiles: el primero es la base, el resto rellena campos vacíos.
      //    También combinar clubs (union deduplicada, preserva orden de aparición).
      let merged = Object.assign({}, oldProfiles[0] || {});
      function combineFields(into, from) {
        ['nombre', 'apellidos', 'telefono', 'avatar'].forEach(function(k) {
          if (!into[k] && from && from[k]) into[k] = from[k];
        });
      }
      function unionClubs(into, from) {
        const cur = Array.isArray(into.clubs) ? into.clubs.slice() : (typeof into.club === 'string' && into.club.trim() ? [into.club.trim()] : []);
        let extras = [];
        if (from) {
          if (Array.isArray(from.clubs)) extras = from.clubs;
          else if (typeof from.club === 'string' && from.club.trim()) extras = [from.club.trim()];
        }
        const seen = {};
        const out = [];
        cur.concat(extras).forEach(function(c) {
          const t = (c == null) ? '' : String(c).trim();
          if (!t) return;
          const k = t.toLowerCase();
          if (seen[k]) return;
          seen[k] = true;
          out.push(t);
        });
        into.clubs = out;
        delete into.club;
      }
      for (let i = 1; i < oldProfiles.length; i++) {
        combineFields(merged, oldProfiles[i] || {});
      }
      // Aplicar union de clubs sobre la base y luego cada uno
      unionClubs(merged, null);
      for (let i = 1; i < oldProfiles.length; i++) {
        unionClubs(merged, oldProfiles[i] || {});
      }

      const newProfile = Object.assign({}, merged, {
        correo: verifiedEmail,
        updatedAt: new Date().toISOString(),
        mergedFromUid: oldUids[0],
        mergedFromUids: oldUids,
        mergedAt: new Date().toISOString()
      });
      // Limpiar campos internos de migración por si venía con basura
      delete newProfile.migrationEmailSent;
      delete newProfile.migrationEmailError;
      delete newProfile.club;
      if (!Array.isArray(newProfile.clubs)) newProfile.clubs = [];

      updates['ciclistas/' + newUid] = newProfile;
      oldUids.forEach(function(oldUid) {
        if (oldUid !== newUid) updates['ciclistas/' + oldUid] = null;
      });

      // 2) Reescribir participants / acceptances / photoBy / proposedBy de TODOS los rides.
      const oldSet = {};
      oldUids.forEach(function(u) { oldSet[u] = true; });

      const gs = grupetasSnap || {};
      Object.keys(gs).forEach(function(gid) {
        const g = gs[gid] || {};
        const rides = g.rides || {};
        Object.keys(rides).forEach(function(dk) {
          let dayRides = rides[dk];
          if (dayRides && !Array.isArray(dayRides)) dayRides = Object.values(dayRides);
          if (!Array.isArray(dayRides)) return;
          dayRides.forEach(function(r, idx) {
            if (!r) return;
            const base = 'grupetas/' + gid + '/rides/' + dk + '/' + idx + '/';
            const parts = Array.isArray(r.participants) ? r.participants : (r.participants ? Object.values(r.participants) : []);
            const hasAnyOld = parts.some(function(p) { return oldSet[p]; });
            if (hasAnyOld) {
              const newParts = parts.map(function(p) { return oldSet[p] ? newUid : p; });
              const seen = {};
              const dedup = [];
              newParts.forEach(function(p) { if (p && !seen[p]) { seen[p] = true; dedup.push(p); } });
              updates[base + 'participants'] = dedup;
            }
            if (r.acceptances) {
              oldUids.forEach(function(oldUid) {
                if (r.acceptances[oldUid] !== undefined) {
                  // No pisar acceptance del newUid si ya existe
                  if (r.acceptances[newUid] === undefined && updates[base + 'acceptances/' + newUid] === undefined) {
                    updates[base + 'acceptances/' + newUid] = r.acceptances[oldUid];
                  }
                  updates[base + 'acceptances/' + oldUid] = null;
                }
              });
            }
            if (oldSet[r.photoBy]) {
              updates[base + 'photoBy'] = newUid;
            }
            if (oldSet[r.proposedBy]) {
              updates[base + 'proposedBy'] = newUid;
            }
          });
        });
      });

      // 3) Acceptances globales
      const accs = acceptancesSnap || {};
      oldUids.forEach(function(oldUid) {
        if (accs[oldUid] !== undefined) {
          if (accs[newUid] === undefined && updates['acceptances/' + newUid] === undefined) {
            updates['acceptances/' + newUid] = accs[oldUid];
          }
          updates['acceptances/' + oldUid] = null;
        }
      });

      return updates;
    }

    // ============ LOGIN SCREEN ============
    // v28-oct14c: Landing pública previa al login. La primera vez que alguien entra a
    // grupetas.com (sin sesión activa) ve esta pantalla. Tras pulsar APÚNTATE o "Ya tengo cuenta"
    // pasa al LoginScreen normal. Tras un logout, el flujo va directo al login (saltándose
    // esta landing) gracias al flag landingDismissed.
    function LandingScreen({ onRegisterGrupeta, onRegisterSolo, onLogin }) {
      const [showHelp, setShowHelp] = useState(false);
      const [helpTab, setHelpTab] = useState('socios');
      return (
        <React.Fragment>
          <div style={{ background: 'linear-gradient(180deg, #312e81 0%, #1e1b4b 50%, #2e1065 100%)', fontFamily: "'Inter',system-ui,sans-serif" }}
               className="fixed inset-0 overflow-y-auto text-white">
            <div className="min-h-full flex flex-col items-center justify-center px-6 py-10 text-center">
              <div className="max-w-md w-full">
                {/* Logo / título */}
                <img src="/img/logo-grupetas.jpeg" alt="GRUPETAS.COM — La app de las grupetas ciclistas"
                  className="w-full max-w-xs mx-auto rounded-2xl mb-7" />
                <p className="body-font text-white/70 text-sm mb-7 hidden">
                  La app de las grupetas ciclistas
                </p>

                {/* Texto de bienvenida */}
                <div className="bg-white/5 border border-white/10 rounded-2xl p-5 mb-6 text-left backdrop-blur-sm">
                  <h2 className="display-font font-bold text-base tracking-wide text-pink-300 mb-2 text-center">
                    🌅 Cómo nace grupetas.com
                  </h2>
                  <p className="body-font text-sm text-white/85 leading-relaxed mb-2">
                    Esta web nació en <strong className="text-white">Cartagena</strong>, en una pequeña grupeta llamada <strong className="text-white">Camantes</strong>. Era un grupo de WhatsApp más, lleno de mensajes sueltos preguntando "¿quién sale mañana?" o "¿por dónde tiramos?". Faltaba algo que pusiera orden.
                  </p>
                  <p className="body-font text-sm text-white/85 leading-relaxed mb-2">
                    Empezamos a construirla para nosotros. Poco a poco se han ido apuntando <strong className="text-white">otros clubs de la ciudad de Cartagena</strong>, y vimos que el problema lo tenían todos. Así que decidimos abrirla.
                  </p>
                  <p className="body-font text-sm text-white/85 leading-relaxed mb-2">
                    <strong className="text-white">Cómo funciona, en una frase:</strong> cada club tiene su propio espacio, su admin lo organiza, y los socios se apuntan a las salidas en un par de clics. Calendario, rutas con GPX, recordatorios, todo claro y a la vista.
                  </p>
                  <p className="body-font text-sm text-white/85 leading-relaxed mb-2">
                    <strong className="text-white">Es gratuita.</strong> Y queremos que siga siéndolo. Nuestro objetivo es encontrar un <strong className="text-white">patrocinador a nivel nacional</strong> que apueste por nosotros y nos ayude a convertir grupetas.com en <strong className="text-white">la app de referencia para clubs ciclistas en España</strong>.
                  </p>
                  <p className="body-font text-sm text-white/85 leading-relaxed">
                    Hasta entonces, seguimos rodando — y nos encantaría que rodaras con nosotros 🚴‍♀️🚴‍♂️
                  </p>
                </div>

                {/* Botones principales */}
                <HelpMarqueeButton onClick={function() { setShowHelp(true); }} extraClass="mb-3" />
                <button onClick={onRegisterSolo}
                  className="w-full bg-pink-500 hover:bg-pink-400 text-white display-font font-bold text-sm tracking-wide rounded-2xl px-5 py-3 shadow-lg shadow-pink-500/30 active:scale-[0.98] transition mb-3">
                  🚴 QUIERO APUNTARME
                </button>
                <button onClick={onLogin}
                  className="w-full bg-sky-300 hover:bg-sky-200 text-black display-font font-bold text-sm tracking-wide rounded-2xl px-5 py-3 active:scale-[0.98] transition mb-5">
                  Ya tengo cuenta · Iniciar sesión
                </button>
              </div>
            </div>
          </div>
          {showHelp && (
            <HelpModal
              effectiveAdmin={false}
              tab={helpTab}
              setTab={setHelpTab}
              onClose={function() { setShowHelp(false); }} />
          )}
        </React.Fragment>
      );
    }

    function LoginScreen({ initialMode, onBackToLanding }) {
      const [mode, setMode] = useState(initialMode || 'login'); // 'login' | 'register' | 'forgot' | 'sent-verify' | 'sent-reset'
      const [email, setEmail] = useState('');
      const [pwd, setPwd] = useState('');
      const [pwd2, setPwd2] = useState('');
      const [busy, setBusy] = useState(false);
      const [err, setErr] = useState('');
      const [info, setInfo] = useState('');

      function reset() { setErr(''); setInfo(''); }

      function doLogin() {
        reset();
        const e = email.trim();
        if (!e || !pwd) { setErr('Introduce correo y contraseña'); return; }
        setBusy(true);
        firebase.auth().signInWithEmailAndPassword(e, pwd)
          .then(function() { /* onAuthStateChanged se encarga */ })
          .catch(function(error) {
            setBusy(false);
            setErr(authErrorMessage(error));
          });
      }

      function doRegister() {
        reset();
        const e = email.trim();
        if (!e) { setErr('Introduce un correo'); return; }
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) { setErr('El correo no tiene un formato válido.'); return; }
        if (pwd.length < 8) { setErr('La contraseña debe tener al menos 8 caracteres.'); return; }
        if (pwd !== pwd2) { setErr('Las contraseñas no coinciden.'); return; }
        setBusy(true);
        firebase.auth().createUserWithEmailAndPassword(e, pwd)
          .then(function(cred) { return cred.user.sendEmailVerification(); })
          .then(function() {
            setBusy(false);
            setMode('sent-verify');
            setInfo('Te hemos enviado un enlace a ' + e + '. Pulsa el enlace y vuelve a la app.');
          })
          .catch(function(error) {
            setBusy(false);
            setErr(authErrorMessage(error));
          });
      }

      function doForgot() {
        reset();
        const e = email.trim();
        if (!e) { setErr('Introduce tu correo'); return; }
        setBusy(true);
        firebase.auth().sendPasswordResetEmail(e)
          .then(function() {
            setBusy(false);
            setMode('sent-reset');
            setInfo('Si ese correo tiene cuenta, te llegará un enlace para restablecer la contraseña.');
          })
          .catch(function(error) {
            setBusy(false);
            // Por privacidad, no revelamos si el correo existe o no
            if (error && (error.code === 'auth/user-not-found' || error.code === 'auth/invalid-email')) {
              setMode('sent-reset');
              setInfo('Si ese correo tiene cuenta, te llegará un enlace para restablecer la contraseña.');
            } else {
              setErr(authErrorMessage(error));
            }
          });
      }

      const card = "w-full max-w-sm bg-white rounded-2xl shadow-xl p-6 mx-auto";

      return (
        <div style={{ background: 'linear-gradient(180deg, #312e81 0%, #1e1b4b 50%, #2e1065 100%)' }}
             className="fixed inset-0 overflow-auto py-8 px-4 flex items-center justify-center">
          <div className={card}>
            {onBackToLanding && (
              <div className="mb-1">
                <button onClick={onBackToLanding}
                  className="body-font text-xs text-gray-500 hover:text-gray-700 active:scale-95 transition">
                  ← Volver
                </button>
              </div>
            )}
            <div className="text-center mb-5">
              <div className="text-4xl mb-1">🚴‍♂️</div>
              <h1 className="display-font font-bold text-2xl text-red-700 tracking-wide">GRUPETAS<span className="text-pink-400">.COM</span></h1>
              <p className="body-font text-xs text-gray-500 mt-1">La app de las grupetas ciclistas</p>
            </div>

            {mode === 'login' && (
              <React.Fragment>
                <h2 className="display-font font-bold text-lg text-gray-800 mb-3">ENTRAR</h2>
                <div className="space-y-3">
                  <FormField label="Correo">
                    <input type="email" autoComplete="email" value={email}
                      onChange={function(e) { setEmail(e.target.value); reset(); }}
                      placeholder="tu@correo.com" className={inputCls} />
                  </FormField>
                  <FormField label="Contraseña">
                    <input type="password" autoComplete="current-password" value={pwd}
                      onChange={function(e) { setPwd(e.target.value); reset(); }}
                      placeholder="Tu contraseña" className={inputCls}
                      onKeyDown={function(e) { if (e.key === 'Enter') doLogin(); }} />
                  </FormField>
                </div>
                {err && <p className="text-red-600 text-xs body-font mt-3 font-medium">{err}</p>}
                <button onClick={doLogin} disabled={busy} className={primaryBtn + ' w-full mt-4'}>
                  {busy ? 'ENTRANDO…' : 'ENTRAR'}
                </button>
                <div className="text-center mt-3">
                  <button onClick={function() { reset(); setMode('forgot'); }}
                    className="body-font text-xs text-red-700 underline">¿Olvidaste tu contraseña?</button>
                </div>
                <div className="text-center mt-5 pt-4 border-t border-gray-100">
                  <span className="body-font text-xs text-gray-500">¿No tienes cuenta? </span>
                  <button onClick={function() { reset(); setMode('register'); }}
                    className="body-font text-xs font-bold text-red-700 underline">CREAR CUENTA</button>
                </div>
              </React.Fragment>
            )}

            {mode === 'register' && (
              <React.Fragment>
                <h2 className="display-font font-bold text-lg text-gray-800 mb-3">CREAR CUENTA</h2>
                <div className="space-y-3">
                  <FormField label="Correo">
                    <input type="email" autoComplete="email" value={email}
                      onChange={function(e) { setEmail(e.target.value); reset(); }}
                      placeholder="tu@correo.com" className={inputCls} />
                  </FormField>
                  <FormField label="Contraseña (mínimo 8)">
                    <input type="password" autoComplete="new-password" value={pwd}
                      onChange={function(e) { setPwd(e.target.value); reset(); }}
                      placeholder="Mínimo 8 caracteres" className={inputCls} />
                  </FormField>
                  <FormField label="Repetir contraseña">
                    <input type="password" autoComplete="new-password" value={pwd2}
                      onChange={function(e) { setPwd2(e.target.value); reset(); }}
                      placeholder="Repite la contraseña" className={inputCls}
                      onKeyDown={function(e) { if (e.key === 'Enter') doRegister(); }} />
                  </FormField>
                </div>
                {err && <p className="text-red-600 text-xs body-font mt-3 font-medium">{err}</p>}
                <button onClick={doRegister} disabled={busy} className={primaryBtn + ' w-full mt-4'}>
                  {busy ? 'CREANDO…' : 'CREAR CUENTA'}
                </button>
                <div className="text-center mt-4 pt-4 border-t border-gray-100">
                  <button onClick={function() { reset(); setMode('login'); }}
                    className="body-font text-xs font-bold text-red-700 underline">← Volver a entrar</button>
                </div>
              </React.Fragment>
            )}

            {mode === 'forgot' && (
              <React.Fragment>
                <h2 className="display-font font-bold text-lg text-gray-800 mb-3">RECUPERAR CONTRASEÑA</h2>
                <p className="body-font text-xs text-gray-500 mb-3">Te enviaremos un enlace a tu correo para restablecerla.</p>
                <FormField label="Correo">
                  <input type="email" autoComplete="email" value={email}
                    onChange={function(e) { setEmail(e.target.value); reset(); }}
                    placeholder="tu@correo.com" className={inputCls}
                    onKeyDown={function(e) { if (e.key === 'Enter') doForgot(); }} />
                </FormField>
                {err && <p className="text-red-600 text-xs body-font mt-3 font-medium">{err}</p>}
                <button onClick={doForgot} disabled={busy} className={primaryBtn + ' w-full mt-4'}>
                  {busy ? 'ENVIANDO…' : 'ENVIAR ENLACE'}
                </button>
                <div className="text-center mt-4 pt-4 border-t border-gray-100">
                  <button onClick={function() { reset(); setMode('login'); }}
                    className="body-font text-xs font-bold text-red-700 underline">← Volver a entrar</button>
                </div>
              </React.Fragment>
            )}

            {mode === 'sent-verify' && (
              <React.Fragment>
                <div className="text-center py-2">
                  <div className="text-4xl mb-2">📬</div>
                  <h2 className="display-font font-bold text-lg text-gray-800 mb-2">REVISA TU CORREO</h2>
                  <p className="body-font text-sm text-gray-600">{info}</p>
                  <p className="body-font text-xs text-gray-500 mt-3">Cuando hayas verificado, vuelve aquí y pulsa entrar.</p>
                </div>
                <button onClick={function() { reset(); setMode('login'); setPwd(''); setPwd2(''); }}
                  className={primaryBtn + ' w-full mt-4'}>VOLVER A ENTRAR</button>
              </React.Fragment>
            )}

            {mode === 'sent-reset' && (
              <React.Fragment>
                <div className="text-center py-2">
                  <div className="text-4xl mb-2">📬</div>
                  <h2 className="display-font font-bold text-lg text-gray-800 mb-2">REVISA TU CORREO</h2>
                  <p className="body-font text-sm text-gray-600">{info}</p>
                </div>
                <button onClick={function() { reset(); setMode('login'); }}
                  className={primaryBtn + ' w-full mt-4'}>VOLVER A ENTRAR</button>
              </React.Fragment>
            )}
          </div>
        </div>
      );
    }

    // ============ VERIFY PENDING SCREEN ============
    function VerifyPendingScreen({ email, onResend, onSignOut, onCheck, onSkip }) {
      const [busy, setBusy] = useState(false);
      const [msg, setMsg] = useState('');
      var isHotmail = email && (email.toLowerCase().includes('hotmail') || email.toLowerCase().includes('outlook') || email.toLowerCase().includes('live.'));
      function handleResend() {
        setBusy(true); setMsg('');
        Promise.resolve(onResend()).then(function() {
          setBusy(false); setMsg('Enlace reenviado a ' + email);
        }).catch(function(err) {
          setBusy(false); setMsg(authErrorMessage(err) || 'No se pudo reenviar');
        });
      }
      return (
        <div style={{ background: 'linear-gradient(180deg, #312e81 0%, #1e1b4b 50%, #2e1065 100%)' }}
             className="fixed inset-0 overflow-auto py-8 px-4 flex items-center justify-center">
          <div className="w-full max-w-sm bg-white rounded-2xl shadow-xl p-6">
            <div className="text-center mb-4">
              <div className="text-4xl mb-2">📬</div>
              <h2 className="display-font font-bold text-xl text-gray-800">VERIFICA TU CORREO</h2>
              <p className="body-font text-sm text-gray-600 mt-2">
                Te enviamos un enlace a <span className="font-bold">{email}</span>.
                Pulsa el enlace para confirmar y luego vuelve aquí.
              </p>
              {isHotmail && (
                <p className="body-font text-xs text-amber-600 mt-2 bg-amber-50 rounded-lg p-2">
                  ⚠️ Si usas Hotmail u Outlook, revisa también tu carpeta de <strong>correo no deseado</strong>.
                </p>
              )}
            </div>
            {msg && <p className="body-font text-xs text-gray-600 text-center my-2">{msg}</p>}
            <button onClick={onCheck} className={primaryBtn + ' w-full mt-2'}>YA HE VERIFICADO</button>
            <button onClick={handleResend} disabled={busy} className={cancelBtn + ' w-full mt-2'}>
              {busy ? 'ENVIANDO…' : 'REENVIAR ENLACE'}
            </button>
            {onSkip && (
              <button onClick={onSkip} className="w-full mt-2 body-font text-xs font-bold text-purple-700 border border-purple-200 rounded-xl py-2.5 hover:bg-purple-50 transition">
                ENTRAR SIN VERIFICAR (cuenta existente)
              </button>
            )}
            <div className="text-center mt-4 pt-4 border-t border-gray-100">
              <button onClick={onSignOut} className="body-font text-xs font-bold text-red-700 underline">CERRAR SESIÓN</button>
            </div>
          </div>
        </div>
      );
    }

    // ============ BULK ACTIVATION MODAL (admin central, una sola vez) ============
    // Permite al admin enviar emails de "fija tu contraseña" a todos los socios
    // que tengan correo en /ciclistas pero aún no tengan cuenta de Auth.

    // BulkActivationModal → js/modals-a.js
  </script>
  <script type="text/babel" data-presets="react" src="/js/modals-a.js"></script>
  <script type="text/babel" data-presets="react" src="/js/app.js"></script>
  <script type="text/babel" data-presets="react" src="/js/home-page.js"></script>
  <script type="text/babel" data-presets="react" src="/js/grupeta-page.js"></script>
<div id="gnav">
  <button id="gnav-btn-home" onclick="gnavSetActive('home');window._gnav&&window._gnav.closeAllModals&&window._gnav.closeAllModals();window._gnav&&window._gnav.home()">
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9.5z"/><path d="M9 21V12h6v9"/></svg>
    <span>Inicio</span>
  </button>
  <button id="gnav-btn-salida" onclick="event.stopPropagation();window._gnav&&window._gnav.closeAllModals&&window._gnav.closeAllModals();gnavSalida()">
    <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
    <span>Salida</span>
  </button>
  <button id="gnav-btn-ranking" onclick="gnavSetActive('ranking');window._gnav&&window._gnav.closeAllModals&&window._gnav.closeAllModals();window._gnav&&window._gnav.ranking&&window._gnav.ranking()">
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 21H5a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1h3"/><path d="M13 21h-3V9a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v12z"/><path d="M16 21h3a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1h-3"/></svg>
    <span>Ranking</span>
  </button>
  <button id="gnav-btn-yo" onclick="gnavSetActive('yo');window._gnav&&window._gnav.closeAllModals&&window._gnav.closeAllModals();window._gnav&&window._gnav.profile()">
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
    <span>Yo</span>
  </button>
  <button id="gnav-btn-msg" onclick="gnavSetActive('msg');window._gnav&&window._gnav.closeAllModals&&window._gnav.closeAllModals();gnavAbrirMensajes()" style="position:relative">
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
    <span id="gnav-msg-badge" style="display:none;position:absolute;top:4px;right:6px;min-width:16px;height:16px;background:#ef4444;color:#fff;border-radius:50%;font-size:9px;font-weight:700;align-items:center;justify-content:center;padding:0 3px"></span>
    <span>Mensajes</span>
  </button>
</div>
<div id="gnav-modal-root"></div>
<div id="gnav-spacer"></div>

<div id="gnav-ol" onclick="document.getElementById('gnav-ol').style.display='none';gnavSetActive(null)" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:9001;background:rgba(0,0,0,0.35);align-items:flex-end;justify-content:center;padding-bottom:68px;touch-action:none">
  <div onclick="event.stopPropagation()" style="background:#fff;border-radius:16px;overflow:hidden;min-width:240px;max-width:90vw" id="gnav-ol-list"></div>
</div>
<div id="gnav-msg-modal" onclick="document.getElementById('gnav-msg-modal').style.display='none'" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:9002;background:rgba(0,0,0,0.5);align-items:flex-end;justify-content:center">
  <div onclick="event.stopPropagation()" style="background:#fff;border-radius:16px 16px 0 0;width:100%;max-width:480px;max-height:70vh;overflow-y:auto;padding-bottom:8px">
    <div style="padding:14px 16px 10px;border-bottom:1px solid #f3f4f6;display:flex;align-items:center;justify-content:space-between">
      <span style="font-weight:700;font-size:15px;color:#1f2937">&#128172; Mensajes</span>
      <button onclick="document.getElementById('gnav-msg-modal').style.display='none'" style="background:none;border:none;font-size:18px;color:#9ca3af;cursor:pointer">&#10005;</button>
    </div>
    <div id="gnav-msg-list" style="padding:8px 0"></div>
  </div>
</div>
<script type="text/javascript">
function gnavAbrirMensajes(){
  // Si ya estamos en home, abrir directo
  if(window.location.pathname === '/' || window.location.pathname === ''){
    gnavMensajes(); return;
  }
  // Navegar a home y esperar a que la URL cambie
  window._gnav && window._gnav.home && window._gnav.home();
  var tries=0;
  function waitHome(){
    if(window.location.pathname==='/' || window.location.pathname===''){
      setTimeout(gnavMensajes, 100); // pequeño delay para que React renderice
    } else if(tries++<20){
      setTimeout(waitHome, 100);
    }
  }
  setTimeout(waitHome, 100);
}
function gnavSetActive(id){
  ['home','salida','ranking','yo','msg'].forEach(function(k){
    var b=document.getElementById('gnav-btn-'+k);
    if(b) b.classList.toggle('gnav-active', k===id);
  });
}
function gnavLogoHTML(g){
  if(g.customLogo) return '<img src="'+g.customLogo+'" style="width:40px;height:40px;border-radius:8px;object-fit:cover;flex-shrink:0"/>';
  var name=g.shortName||g.name||'?';
  var w=name.trim().split(' ').filter(function(x){return x.length>0;});
  var ini=w.length===1?w[0].slice(0,2).toUpperCase():(w[0][0]+w[w.length-1][0]).toUpperCase();
  var c=(typeof COLORS!=='undefined'&&COLORS[g.color])?COLORS[g.color]:{from:'#dc2626',to:'#7f1d1d'};
  return '<div style="width:40px;height:40px;border-radius:8px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:15px;color:#fff;background:linear-gradient(135deg,'+c.from+','+c.to+')">'+ini+'</div>';
}
function gnavSalida(){
  var nav=window._gnav; if(!nav) return;
  var gs=nav.getMyGrupetas();
  if(gs.length===0) return;
  if(gs.length===1){ sessionStorage.setItem('gnavProponer','1'); nav.goGrupeta(gs[0].id); return; }
  var ol=document.getElementById('gnav-ol');
  var list=document.getElementById('gnav-ol-list');
  list.innerHTML='<div style="padding:10px 16px;border-bottom:1px solid #f3f4f6;font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase">Proponer salida en...</div>';
  gs.forEach(function(g){
    var row=document.createElement('div');
    row.style.cssText='display:flex;align-items:center;gap:12px;padding:11px 16px;border-bottom:1px solid #f9fafb;cursor:pointer';
    row.innerHTML=gnavLogoHTML(g)+'<div><div style="font-weight:700;font-size:14px;color:#1f2937">'+(g.shortName||g.name)+'</div>'+(g.city?'<div style="font-size:11px;color:#9ca3af">'+g.city+'</div>':'')+'</div>';
    row.onclick=function(){ ol.style.display='none'; gnavSetActive(null); sessionStorage.setItem('gnavProponer','1'); nav.goGrupeta(g.id); };
    list.appendChild(row);
  });
  ol.style.display='flex';
  gnavSetActive('salida');
}
function gnavMensajes(){
  var nav=window._gnav; if(!nav) return;
  var gs=nav.getMyGrupetas();
  var modal=document.getElementById('gnav-msg-modal');
  var list=document.getElementById('gnav-msg-list');
  list.innerHTML='<div style="padding:20px;text-align:center;color:#9ca3af;font-size:13px">Cargando...</div>';
  modal.style.display='flex';
  var items=[];
  gs.forEach(function(g){
    var rides=g.rides||{};
    Object.keys(rides).forEach(function(dateKey){
      var arr=rides[dateKey];
      if(!Array.isArray(arr)) return;
      arr.forEach(function(ride){
        if(!ride) return;
        items.push({g:g,ride:ride,dateKey:dateKey});
      });
    });
  });
  var results=[]; var pending=items.length;
  if(pending===0){ list.innerHTML='<div style="padding:24px;text-align:center;color:#9ca3af;font-size:13px">No hay salidas con comentarios</div>'; return; }
  items.forEach(function(item){
    var rid=item.ride.id||item.dateKey;
    db.ref('rideComments/'+item.g.id+'/'+rid).once('value',function(snap){
      var val=snap.val()||{};
      var cms=Object.values(val).sort(function(a,b){return (a.createdAt||0)-(b.createdAt||0);});
      if(cms.length>0){
        var lastRead=getLastReadComment(item.g.id,rid);
        var myUid=(nav&&nav.currentUserId)||'';
        // v107: no contar mensajes propios
        var unread=cms.filter(function(c){return (c.createdAt||0)>lastRead && c.authorId!==myUid;}).length;
        results.push({item:item,cms:cms,unread:unread,last:cms[cms.length-1]});
      }
      pending--;
      if(pending===0){
        results.sort(function(a,b){return (b.last.createdAt||0)-(a.last.createdAt||0);});
        if(results.length===0){ list.innerHTML='<div style="padding:24px;text-align:center;color:#9ca3af;font-size:13px">No hay comentarios recientes</div>'; return; }
        list.innerHTML='';
        results.forEach(function(r){
          var d=new Date(r.last.createdAt||0);
          var hora=d.getHours()+':'+(d.getMinutes()<10?'0':'')+d.getMinutes();
          var row=document.createElement('div');
          row.style.cssText='padding:12px 16px;border-bottom:1px solid #f9fafb;cursor:pointer;display:flex;align-items:center;gap:10px';
          var badge=r.unread>0?'<span style="min-width:20px;height:20px;background:#ef4444;color:#fff;border-radius:50%;font-size:10px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">'+r.unread+'</span>':'<span style="width:20px;flex-shrink:0"></span>';
          var ruta=r.item.ride.route||r.item.ride.name||'Salida';
          row.innerHTML=badge+'<div style="flex:1;min-width:0"><div style="font-weight:700;font-size:13px;color:#1f2937;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+_esc(r.item.g.shortName||r.item.g.name)+' &middot; '+_esc(ruta)+'</div><div style="font-size:11px;color:#6b7280;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+_esc(r.last.authorName)+': '+_esc(r.last.text)+'</div></div><span style="font-size:10px;color:#9ca3af;flex-shrink:0">'+_esc(hora)+'</span>';
          row.onclick=function(){
            modal.style.display='none';
            var _g=r.item.g; var _ride=r.item.ride; var _dk=r.item.dateKey;
            var _rid=_ride.id||_dk;
            // v107: marcar leído al abrir — badge desaparece inmediatamente
            setLastReadComment(_g.id, _rid, Date.now());
            try{ localStorage.removeItem('gnav_badge_'+_g.id+'_'+_rid); }catch(e){}
            setTimeout(gnavUpdateBadge, 300);
            if (window._gnav && typeof window._gnav.goRide === 'function') {
              window._gnav.goRide(_g.id, _dk, _rid);
            }
          };
          list.appendChild(row);
        });
      }
    });
  });
}
// Escape HTML para evitar XSS en innerHTML dinámico
function _esc(s){ var d=document.createElement('div'); d.appendChild(document.createTextNode(String(s||''))); return d.innerHTML; }
// Badge no leídos — actualizar cada 60s y al abrir la app
function gnavUpdateBadge(){
  var nav=window._gnav; if(!nav) return;
  var gs=nav.getMyGrupetas(); var total=0; var pending=0;
  // Solo salidas de los últimos 30 días para evitar lecturas masivas
  var cutoff = new Date(); cutoff.setDate(cutoff.getDate()-30);
  var cutoffKey = cutoff.toISOString().slice(0,10);
  gs.forEach(function(g){
    var rides=g.rides||{};
    Object.keys(rides).forEach(function(dateKey){
      if(dateKey < cutoffKey) return; // ignorar salidas antiguas
      var arr=rides[dateKey];
      if(!Array.isArray(arr)) return;
      arr.forEach(function(ride){
        if(!ride) return;
        var rid=ride.id||dateKey;
        // Si ya leímos este ride hace menos de 5 min, usar caché
        var cacheKey='gnav_badge_'+g.id+'_'+rid;
        var cached=null;
        try{ cached=JSON.parse(localStorage.getItem(cacheKey)||'null'); }catch(e){}
        if(cached && (Date.now()-cached.ts)<300000){
          total+=cached.unread; return;
        }
        pending++;
        db.ref('rideComments/'+g.id+'/'+rid).once('value',function(snap){
          var val=snap.val()||{};
          var lastRead=getLastReadComment(g.id,rid);
          var myUid=nav.currentUserId||'';
          var unread=0;
          // v107: no contar mensajes propios ni ya leídos
          Object.values(val).forEach(function(c){ if((c.createdAt||0)>lastRead && c.authorId!==myUid) unread++; });
          total+=unread;
          try{ localStorage.setItem(cacheKey,JSON.stringify({ts:Date.now(),unread:unread})); }catch(e){}
          pending--;
          if(pending===0){
            var badge=document.getElementById('gnav-msg-badge');
            if(badge){ if(total>0){ badge.textContent=total; badge.style.display='flex'; } else { badge.style.display='none'; } }
          }
        });
      });
    });
  });
  if(pending===0){ var badge=document.getElementById('gnav-msg-badge'); if(badge) badge.style.display='none'; }
}
setTimeout(gnavUpdateBadge, 5000);
setInterval(gnavUpdateBadge, 120000); // cada 2 min en lugar de 1
</script>

<script>
// ===== MERCADILLO FORM — vanilla JS (fuera de Babel para evitar límite 500KB) =====
(function() {
  var CATS = ['Bicicletas','Ruedas','Ropa','Componentes','Accesorios','Otro'];
  var TIPOS = ['Venta','Búsqueda','Cambio'];
  var _ctx = null;
  var _fotos = [];
  var _tipo = 'Venta';

  function resize(file, cb) {
    var r = new FileReader();
    r.onload = function(ev) {
      var img = new Image();
      img.onload = function() {
        var w = img.width, h = img.height, max = 900;
        if (w > max || h > max) { var ratio = Math.min(max/w, max/h); w = Math.round(w*ratio); h = Math.round(h*ratio); }
        var cv = document.createElement('canvas'); cv.width = w; cv.height = h;
        cv.getContext('2d').drawImage(img, 0, 0, w, h);
        cb(cv.toDataURL('image/jpeg', 0.82));
      };
      img.src = ev.target.result;
    };
    r.readAsDataURL(file);
  }

  function renderFotos() {
    var c = document.getElementById('mf-fotos-wrap');
    if (!c) return;
    c.innerHTML = '';
    _fotos.forEach(function(f, i) {
      var d = document.createElement('div');
      d.style.cssText = 'position:relative;width:64px;height:64px;border-radius:8px;overflow:hidden;border:1px solid #e5e7eb;flex-shrink:0';
      var img = document.createElement('img');
      img.src = f; img.style.cssText = 'width:100%;height:100%;object-fit:cover';
      var btn = document.createElement('button');
      btn.textContent = '×';
      btn.style.cssText = 'position:absolute;top:0;right:0;background:#ef4444;color:white;width:20px;height:20px;border:none;cursor:pointer;font-size:14px;line-height:1;border-radius:0 0 0 6px';
      btn.onclick = (function(idx){ return function(e){ e.stopPropagation(); _fotos.splice(idx,1); renderFotos(); }; })(i);
      d.appendChild(img); d.appendChild(btn); c.appendChild(d);
    });
    if (_fotos.length < 5) {
      var lbl = document.createElement('label');
      lbl.style.cssText = 'width:64px;height:64px;border-radius:8px;border:2px dashed #d1d5db;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0';
      lbl.innerHTML = '<span style="font-size:22px">📷</span><span style="font-size:9px;color:#9ca3af;margin-top:2px">Añadir</span>';
      var inp = document.createElement('input');
      inp.type = 'file'; inp.accept = 'image/*'; inp.multiple = true; inp.style.display = 'none';
      inp.onchange = function(e) {
        var files = Array.from(e.target.files || []);
        var rem = 5 - _fotos.length;
        var done = 0;
        files.slice(0, rem).forEach(function(f) {
          resize(f, function(b64) { _fotos.push(b64); done++; if (done === Math.min(files.length, rem)) renderFotos(); });
        });
        inp.value = '';
      };
      lbl.appendChild(inp); c.appendChild(lbl);
    }
  }

  function setTipo(t) {
    _tipo = t;
    TIPOS.forEach(function(tt) {
      var btn = document.getElementById('mf-tipo-' + tt);
      if (!btn) return;
      if (tt === t) {
        btn.style.background = '#7c3aed'; btn.style.color = 'white'; btn.style.borderColor = '#7c3aed';
      } else {
        btn.style.background = '#f9fafb'; btn.style.color = '#4b5563'; btn.style.borderColor = '#e5e7eb';
      }
    });
  }

  function build(ctx) {
    _ctx = ctx; _fotos = (ctx.editData && ctx.editData.fotos) ? ctx.editData.fotos.slice() : []; _tipo = (ctx.editData && ctx.editData.tipo) || 'Venta';
    var ed = ctx.editData || {};
    var overlay = document.createElement('div');
    overlay.id = 'mf-overlay';
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Inter,system-ui,sans-serif';
    overlay.onclick = function(e) { if (e.target === overlay) close(); };

    var modal = document.createElement('div');
    modal.style.cssText = 'background:white;border-radius:16px;width:100%;max-width:420px;max-height:92vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,0.3)';
    modal.onclick = function(e) { e.stopPropagation(); };

    var INP = 'width:100%;box-sizing:border-box;font-family:Inter,system-ui,sans-serif;font-size:14px;border:1px solid #e5e7eb;border-radius:8px;padding:8px 12px;outline:none';
    var LBL = 'font-family:Inter,system-ui,sans-serif;font-size:11px;color:#6b7280;font-weight:700;text-transform:uppercase;letter-spacing:.05em;display:block;margin-bottom:6px';

    modal.innerHTML = [
      '<div style="display:flex;align-items:center;justify-content:space-between;padding:16px;border-bottom:1px solid #f3f4f6">',
        '<h2 style="font-family:Oswald,sans-serif;font-weight:700;font-size:20px;color:#7c3aed;margin:0">' + (ctx.editId ? '✏️ EDITAR ANUNCIO' : '🛒 NUEVO ANUNCIO') + '</h2>',
        '<button id="mf-close" style="background:none;border:none;font-size:24px;color:#9ca3af;cursor:pointer;line-height:1;padding:0">×</button>',
      '</div>',
      '<div style="padding:16px;display:flex;flex-direction:column;gap:14px">',
        '<div>',
          '<span style="' + LBL + '">Tipo de anuncio</span>',
          '<div style="display:flex;gap:8px" id="mf-tipos"></div>',
        '</div>',
        '<div>',
          '<span style="' + LBL + '">Fotos <span style="font-weight:400;color:#9ca3af">(máx. 5)</span></span>',
          '<div id="mf-fotos-wrap" style="display:flex;gap:8px;flex-wrap:wrap"></div>',
        '</div>',
        '<div>',
          '<label style="' + LBL + '" for="mf-cat">Categoría</label>',
          '<select id="mf-cat" style="' + INP + '">' + CATS.map(function(c){ return '<option value="' + c + '"' + (c===(ed.categoria||'Bicicletas')?' selected':'') + '>' + c + '</option>'; }).join('') + '</select>',
        '</div>',
        '<div>',
          '<label style="' + LBL + '" for="mf-titulo">Título *</label>',
          '<input id="mf-titulo" style="' + INP + '" maxlength="80" placeholder="Ej: Trek Domane SL5 2022 talla 54" value="' + (ed.titulo||'').replace(/"/g,'&quot;') + '">',
        '</div>',
        '<div>',
          '<label style="' + LBL + '" for="mf-desc">Descripción</label>',
          '<textarea id="mf-desc" style="' + INP + ';resize:none" rows="3" maxlength="500" placeholder="Estado, características, motivo de venta…">' + (ed.descripcion||'') + '</textarea>',
        '</div>',
        '<div style="display:flex;gap:12px">',
          '<div style="flex:1"><label style="' + LBL + '" for="mf-precio">Precio (€)</label><input id="mf-precio" type="number" min="0" style="' + INP + '" placeholder="0" value="' + (ed.precio!==undefined&&ed.precio!==null?ed.precio:'') + '"></div>',
          '<div style="flex:1"><label style="' + LBL + '" for="mf-local">Localidad</label><input id="mf-local" style="' + INP + '" maxlength="40" placeholder="Ej: Murcia" value="' + (ed.localidad||'').replace(/"/g,'&quot;') + '"></div>',
        '</div>',
        '<div>',
          '<label style="' + LBL + '" for="mf-tel">Teléfono / WhatsApp</label>',
          '<input id="mf-tel" type="tel" style="' + INP + '" maxlength="20" placeholder="+34 600 000 000" value="' + (ed.telefono||'').replace(/"/g,'&quot;') + '">',
        '</div>',
        '<button id="mf-save" style="width:100%;background:#7c3aed;color:white;border:none;border-radius:12px;padding:14px;font-family:Oswald,sans-serif;font-weight:700;font-size:15px;letter-spacing:.05em;cursor:pointer">',
          (ctx.editId ? '✅ GUARDAR CAMBIOS' : '🛒 PUBLICAR ANUNCIO'),
        '</button>',
      '</div>',
    ].join('');

    overlay.appendChild(modal);
    document.body.appendChild(overlay);

    document.getElementById('mf-close').onclick = close;

    // Tipos
    var tiposEl = document.getElementById('mf-tipos');
    TIPOS.forEach(function(t) {
      var btn = document.createElement('button');
      btn.id = 'mf-tipo-' + t; btn.textContent = t;
      btn.style.cssText = 'flex:1;font-family:Inter,sans-serif;font-size:12px;font-weight:700;padding:8px;border-radius:8px;border:1px solid #e5e7eb;cursor:pointer;background:#f9fafb;color:#4b5563;transition:all .15s';
      btn.onclick = function() { setTipo(t); };
      tiposEl.appendChild(btn);
    });
    setTipo(_tipo);
    renderFotos();

    document.getElementById('mf-save').onclick = save;
  }

  function save() {
    var titulo = (document.getElementById('mf-titulo') || {}).value || '';
    if (!titulo.trim()) { _ctx && _ctx.showToast('⚠️ El título es obligatorio'); return; }
    var saveBtn = document.getElementById('mf-save');
    if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'GUARDANDO...'; }
    var nombre = (_ctx.currentUser && (_ctx.currentUser.nombre || _ctx.currentUser.displayName)) || 'Ciclista';
    var precioVal = (document.getElementById('mf-precio') || {}).value || '';
    var data = {
      tipo: _tipo,
      categoria: (document.getElementById('mf-cat') || {}).value || 'Bicicletas',
      titulo: titulo.trim(),
      descripcion: ((document.getElementById('mf-desc') || {}).value || '').trim(),
      precio: precioVal !== '' ? parseFloat(precioVal) : null,
      localidad: ((document.getElementById('mf-local') || {}).value || '').trim(),
      telefono: ((document.getElementById('mf-tel') || {}).value || '').trim(),
      fotos: _fotos,
      firma: _ctx.firmaBase64 || null,
      autorId: _ctx.currentUserId,
      autorNombre: nombre,
      ts: Date.now(),
    };
    var ref = _ctx.editId ? _ctx.db.ref('mercadillo/' + _ctx.editId) : _ctx.db.ref('mercadillo').push();
    ref.set(data)
      .then(function() { _ctx.showToast(_ctx.editId ? '✅ Anuncio actualizado' : '✅ Anuncio publicado'); close(); _ctx.onClose && _ctx.onClose(); })
      .catch(function(err) { _ctx.showToast('❌ Error: ' + err.message); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = _ctx.editId ? '✅ GUARDAR CAMBIOS' : '🛒 PUBLICAR ANUNCIO'; } });
  }

  function close() {
    var el = document.getElementById('mf-overlay');
    if (el) el.remove();
    _fotos = []; _ctx = null;
  }

  window._mercadilloOpenForm = function(ctx) { close(); build(ctx); };
  window._mercadilloCloseForm = close;
})();
</script>

<script>
// Aviso Legal + Firma — vanilla JS
(function() {
  var _lctx = null;
  var _drawing = false;
  var _firmada = false;

  function getPos(e, canvas) {
    var rect = canvas.getBoundingClientRect();
    var scaleX = canvas.width / rect.width;
    var scaleY = canvas.height / rect.height;
    if (e.touches) {
      return { x: (e.touches[0].clientX - rect.left) * scaleX, y: (e.touches[0].clientY - rect.top) * scaleY };
    }
    return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
  }

  function closeLegal() {
    var el = document.getElementById('ml-overlay');
    if (el) el.remove();
    _lctx = null; _drawing = false; _firmada = false;
  }

  function build(ctx) {
    _lctx = ctx;
    _drawing = false; _firmada = false;

    var overlay = document.createElement('div');
    overlay.id = 'ml-overlay';
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9998;display:flex;align-items:center;justify-content:center;padding:16px';
    overlay.onclick = function(e) { if (e.target === overlay) closeLegal(); };

    var modal = document.createElement('div');
    modal.style.cssText = 'background:white;border-radius:16px;width:100%;max-width:440px;max-height:92vh;overflow-y:auto;font-family:Inter,system-ui,sans-serif';
    modal.onclick = function(e) { e.stopPropagation(); };

    modal.innerHTML = '<div style="display:flex;align-items:center;justify-content:space-between;padding:16px;border-bottom:1px solid #f3f4f6">'
      + '<h2 style="font-family:Oswald,sans-serif;font-weight:700;font-size:17px;color:#6d28d9;margin:0">\u26a0\ufe0f AVISO LEGAL</h2>'
      + '<button onclick="window._mercadilloCloseLegal()" style="background:none;border:none;font-size:24px;color:#9ca3af;cursor:pointer;line-height:1">&times;</button>'
      + '</div>'
      + '<div style="padding:16px;display:flex;flex-direction:column;gap:12px">'
      + '<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:12px;padding:12px;font-size:12px;color:#374151;line-height:1.6">'
      + '<p style="font-weight:700;color:#92400e;margin:0 0 8px">Al publicar este anuncio confirmas que:</p>'
      + '<p style="margin:0 0 4px">1. Eres el propietario leg\u00edtimo del art\u00edculo y tienes derecho a venderlo.</p>'
      + '<p style="margin:0 0 4px">2. La informaci\u00f3n que proporcionas es veraz y el art\u00edculo est\u00e1 correctamente descrito.</p>'
      + '<p style="margin:0 0 4px">3. Esta transacci\u00f3n se realiza <strong>entre particulares</strong>. No aplica la legislaci\u00f3n de consumo sino el C\u00f3digo Civil espa\u00f1ol (art. 1484 sobre vicios ocultos).</p>'
      + '<p style="margin:0 0 4px">4. <strong>Grupetas.com act\u00faa como mero intermediario</strong> y no asume ninguna responsabilidad sobre la transacci\u00f3n, el estado del art\u00edculo, el pago ni la entrega.</p>'
      + '<p style="margin:0 0 4px">5. Cualquier disputa entre comprador y vendedor deber\u00e1 resolverse directamente entre las partes.</p>'
      + '<p style="margin:0">6. Tus datos (nombre y localidad) ser\u00e1n visibles para otros usuarios registrados conforme al RGPD.</p>'
      + '</div>'
      + '<div>'
      + '<p style="font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.05em;margin:0 0 6px">\u270d\ufe0f Firma aqu\u00ed para confirmar</p>'
      + '<div style="position:relative;border:2px dashed #c4b5fd;border-radius:12px;background:#f9fafb;overflow:hidden;height:120px">'
      + '<canvas id="ml-canvas" width="600" height="200" style="width:100%;height:100%;display:block;touch-action:none;cursor:crosshair"></canvas>'
      + '<div id="ml-placeholder" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none"><p style="font-size:14px;color:#d1d5db;margin:0">Dibuja tu firma con el dedo</p></div>'
      + '</div>'
      + '<button onclick="window._mercadilloLegalLimpiar()" style="margin-top:6px;font-size:12px;color:#9ca3af;background:none;border:none;cursor:pointer">\uD83D\uDDD1 Borrar firma</button>'
      + '</div>'
      + '<button id="ml-btn-aceptar" onclick="window._mercadilloLegalAceptar()" disabled style="width:100%;background:#6d28d9;color:white;border:none;border-radius:12px;padding:12px;font-family:Oswald,sans-serif;font-weight:700;font-size:14px;letter-spacing:.05em;cursor:not-allowed;opacity:.4">FIRMA PARA CONTINUAR</button>'
      + '</div>';

    overlay.appendChild(modal);
    document.body.appendChild(overlay);

    var canvas = document.getElementById('ml-canvas');
    if (!canvas) return;

    canvas.addEventListener('mousedown', function(e) {
      e.preventDefault(); _drawing = true;
      var ctx2 = canvas.getContext('2d'); var pos = getPos(e, canvas);
      ctx2.beginPath(); ctx2.moveTo(pos.x, pos.y);
    });
    canvas.addEventListener('mousemove', function(e) {
      e.preventDefault(); if (!_drawing) return;
      var ctx2 = canvas.getContext('2d'); var pos = getPos(e, canvas);
      ctx2.lineWidth = 2.5; ctx2.lineCap = 'round'; ctx2.strokeStyle = '#1e1b4b';
      ctx2.lineTo(pos.x, pos.y); ctx2.stroke();
      setFirmada(true);
    });
    canvas.addEventListener('mouseup', function(e) { e.preventDefault(); _drawing = false; });
    canvas.addEventListener('mouseleave', function(e) { e.preventDefault(); _drawing = false; });
    canvas.addEventListener('touchstart', function(e) {
      e.preventDefault(); _drawing = true;
      var ctx2 = canvas.getContext('2d'); var pos = getPos(e, canvas);
      ctx2.beginPath(); ctx2.moveTo(pos.x, pos.y);
    }, { passive: false });
    canvas.addEventListener('touchmove', function(e) {
      e.preventDefault(); if (!_drawing) return;
      var ctx2 = canvas.getContext('2d'); var pos = getPos(e, canvas);
      ctx2.lineWidth = 2.5; ctx2.lineCap = 'round'; ctx2.strokeStyle = '#1e1b4b';
      ctx2.lineTo(pos.x, pos.y); ctx2.stroke();
      setFirmada(true);
    }, { passive: false });
    canvas.addEventListener('touchend', function(e) { e.preventDefault(); _drawing = false; }, { passive: false });
  }

  function setFirmada(v) {
    _firmada = v;
    var ph = document.getElementById('ml-placeholder');
    var btn = document.getElementById('ml-btn-aceptar');
    if (ph) ph.style.display = v ? 'none' : 'flex';
    if (btn) {
      btn.disabled = !v;
      btn.style.opacity = v ? '1' : '.4';
      btn.style.cursor = v ? 'pointer' : 'not-allowed';
      btn.textContent = v ? '\u2705 ACEPTO Y PUBLICAR ANUNCIO' : 'FIRMA PARA CONTINUAR';
    }
  }

  window._mercadilloOpenLegal = function(ctx) { closeLegal(); build(ctx); };
  window._mercadilloCloseLegal = closeLegal;
  window._mercadilloLegalLimpiar = function() {
    var canvas = document.getElementById('ml-canvas');
    if (canvas) canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
    setFirmada(false);
  };
  window._mercadilloLegalAceptar = function() {
    if (!_firmada || !_lctx) return;
    var canvas = document.getElementById('ml-canvas');
    var firma = canvas ? canvas.toDataURL('image/png') : null;
    var ctx = _lctx;
    closeLegal();
    window._mercadilloOpenForm({
      db: ctx.db,
      currentUserId: ctx.currentUserId,
      currentUser: ctx.currentUser,
      showToast: ctx.showToast,
      editId: ctx.editId || null,
      editData: ctx.editData || null,
      firmaBase64: firma,
    });
  };
})();
</script>

<script>
// Panel de Firmas v2 - solo admin central - vanilla JS
(function() {
  var _mfItems = {};

  function mfEsc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

  function mfClose() {
    var el = document.getElementById('mf2-overlay');
    if (el) el.remove();
  }

  window._mercadilloOpenFirmas = function(db) {
    mfClose();
    var overlay = document.createElement('div');
    overlay.id = 'mf2-overlay';
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:flex-start;justify-content:center;padding:16px;overflow-y:auto';
    overlay.onclick = function(e) { if (e.target === overlay) mfClose(); };

    var modal = document.createElement('div');
    modal.style.cssText = 'background:white;border-radius:16px;width:100%;max-width:560px;margin:auto;font-family:Inter,system-ui,sans-serif';
    modal.onclick = function(e) { e.stopPropagation(); };
    modal.innerHTML = '<div style="display:flex;align-items:center;justify-content:space-between;padding:16px;border-bottom:1px solid #f3f4f6">'
      + '<h2 style="font-family:Oswald,sans-serif;font-weight:700;font-size:18px;color:#3730a3;margin:0">FIRMAS LEGALES</h2>'
      + '<button onclick="(function(){var e=document.getElementById(\'mf2-overlay\');if(e)e.remove();})()" style="background:none;border:none;font-size:24px;color:#9ca3af;cursor:pointer;line-height:1">&times;</button>'
      + '</div>'
      + '<div id="mf2-body" style="padding:16px"><p style="color:#9ca3af;font-size:13px;text-align:center">Cargando...</p></div>';

    overlay.appendChild(modal);
    document.body.appendChild(overlay);

    db.ref('mercadillo').once('value', function(snap) {
      _mfItems = {};
      var data = snap.val() || {};
      var body = document.getElementById('mf2-body');
      if (!body) return;

      var items = Object.entries(data)
        .map(function(e) { _mfItems[e[0]] = e[1]; return Object.assign({ _id: e[0] }, e[1]); })
        .sort(function(a, b) { return (b.ts||0) - (a.ts||0); });

      if (items.length === 0) {
        body.innerHTML = '<p style="color:#9ca3af;font-size:13px;text-align:center;padding:20px">No hay anuncios</p>';
        return;
      }

      var sinFirma = items.filter(function(i) { return !i.firma; }).length;
      var html = sinFirma > 0
        ? '<p style="font-size:11px;color:#ef4444;font-weight:600;margin:0 0 8px">! ' + sinFirma + ' anuncio(s) sin firma</p>'
        : '';
      html += '<p style="font-size:11px;color:#6b7280;margin:0 0 12px">' + items.length + ' anuncios. Pulsa para ver la firma.</p>';

      items.forEach(function(it) {
        var fecha = it.ts ? new Date(it.ts).toLocaleDateString('es-ES') : '';
        var aid = it._id;
        html += '<div style="border:1px solid #e5e7eb;border-radius:10px;padding:12px;margin-bottom:8px;cursor:pointer" onclick="window._mfToggle2(\'' + aid + '\')">'
          + '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">'
          + '<div style="flex:1;min-width:0">'
          + '<p style="font-size:13px;font-weight:600;color:#111827;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + mfEsc(it.titulo) + '</p>'
          + '<p style="font-size:11px;color:#6b7280;margin:2px 0 0">' + mfEsc(it.autorNombre||'') + ' &middot; ' + mfEsc(it.localidad||'') + ' &middot; ' + fecha + '</p>'
          + (it.telefono ? '<p style="font-size:11px;color:#6b7280;margin:1px 0 0">Tel: ' + mfEsc(it.telefono) + '</p>' : '')
          + '</div>'
          + (it.firma
            ? '<span style="flex-shrink:0;font-size:11px;font-weight:700;color:#16a34a;background:#dcfce7;padding:2px 8px;border-radius:20px">FIRMADO</span>'
            : '<span style="flex-shrink:0;font-size:11px;font-weight:700;color:#dc2626;background:#fee2e2;padding:2px 8px;border-radius:20px">SIN FIRMA</span>')
          + '</div>';
        if (it.firma) {
          html += '<div id="mf2-firma-' + aid + '" style="display:none;margin-top:10px;text-align:center">'
            + '<img src="' + it.firma + '" style="max-width:100%;border:1px solid #e5e7eb;border-radius:8px;background:#f9fafb" />'
            + '<div style="margin-top:8px;display:flex;gap:8px;justify-content:center">'
            + '<button onclick="event.stopPropagation();window._mfPrint2(\'' + aid + '\')" style="background:#3730a3;color:white;border:none;border-radius:8px;padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer">Imprimir / PDF</button>'
            + '<a href="' + it.firma + '" download="firma-' + aid + '.png" onclick="event.stopPropagation()" style="background:#0ea5e9;color:white;border-radius:8px;padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block">Descargar PNG</a>'
            + '</div></div>';
        }
        html += '</div>';
      });

      body.innerHTML = html;
    });
  };

  window._mfToggle2 = function(id) {
    var el = document.getElementById('mf2-firma-' + id);
    if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
  };

  window._mfPrint2 = function(id) {
    var img = document.querySelector('#mf2-firma-' + id + ' img');
    var it = _mfItems[id];
    if (!img || !it) return;
    var fecha = it.ts ? new Date(it.ts).toLocaleDateString('es-ES', {day:'2-digit',month:'long',year:'numeric'}) : '';
    var precio = (it.precio != null) ? Number(it.precio).toLocaleString('es-ES') + ' EUR' : 'A convenir';
    var win = window.open('', '_blank');
    win.document.write('<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Firma legal - ' + mfEsc(it.titulo||'') + '</title>'
      + '<style>'
      + 'body{font-family:Arial,sans-serif;padding:40px;max-width:640px;margin:auto;color:#111;font-size:13px}'
      + 'h1{color:#3730a3;font-size:20px;margin:0 0 2px}'
      + '.sub{color:#6b7280;font-size:11px;margin:0 0 24px;text-transform:uppercase;letter-spacing:.05em}'
      + 'table{width:100%;border-collapse:collapse;margin-bottom:24px}'
      + 'td{padding:7px 10px;border-bottom:1px solid #f3f4f6;font-size:13px;vertical-align:top}'
      + 'td.lbl{color:#6b7280;width:35%;font-weight:700}'
      + 'h3{font-size:13px;font-weight:700;color:#374151;margin:24px 0 10px;border-top:2px solid #3730a3;padding-top:12px}'
      + '.clausula{margin:0 0 8px;padding-left:16px;line-height:1.6}'
      + '.clausula strong{color:#111}'
      + '.firma-box{margin:24px 0 8px;text-align:center}'
      + '.firma-box img{max-width:100%;border:2px solid #3730a3;border-radius:8px;background:#f9fafb;padding:8px}'
      + '.firma-label{font-size:11px;font-weight:700;color:#3730a3;text-align:center;margin:4px 0 0}'
      + '.footer{margin-top:20px;font-size:10px;color:#9ca3af;line-height:1.6;border-top:1px solid #e5e7eb;padding-top:12px;text-align:center}'
      + '.pdf-hint{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 14px;font-size:12px;color:#92400e;margin-bottom:20px}'
      + 'button{padding:8px 20px;background:#3730a3;color:white;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:700;margin-right:8px}'
      + '@media print{button,.pdf-hint{display:none}}'
      + '</style></head><body>'
      + '<h1>DECLARACION DE RESPONSABILIDAD DEL VENDEDOR</h1>'
      + '<p class="sub">Mercadillo Grupetas.com &mdash; Documento generado el ' + new Date().toLocaleDateString('es-ES', {day:'2-digit',month:'long',year:'numeric'}) + '</p>'
      + '<div class="pdf-hint">Para guardar como PDF: pulsa <strong>Imprimir</strong> &rarr; en destino selecciona <strong>"Guardar como PDF"</strong></div>'
      + '<h3>1. DATOS DEL ANUNCIO</h3>'
      + '<table>'
      + '<tr><td class="lbl">Titulo</td><td><strong>' + mfEsc(it.titulo||'') + '</strong></td></tr>'
      + '<tr><td class="lbl">Tipo</td><td>' + mfEsc(it.tipo||'') + '</td></tr>'
      + '<tr><td class="lbl">Categoria</td><td>' + mfEsc(it.categoria||'') + '</td></tr>'
      + '<tr><td class="lbl">Precio</td><td>' + precio + '</td></tr>'
      + '<tr><td class="lbl">Descripcion</td><td>' + mfEsc(it.descripcion||'') + '</td></tr>'
      + '</table>'
      + '<h3>2. DATOS DEL VENDEDOR</h3>'
      + '<table>'
      + '<tr><td class="lbl">Nombre</td><td><strong>' + mfEsc(it.autorNombre||'') + '</strong></td></tr>'
      + '<tr><td class="lbl">Localidad</td><td>' + mfEsc(it.localidad||'') + '</td></tr>'
      + '<tr><td class="lbl">Telefono</td><td>' + mfEsc(it.telefono||'') + '</td></tr>'
      + '<tr><td class="lbl">Fecha publicacion</td><td>' + fecha + '</td></tr>'
      + '</table>'
      + '<h3>3. CLAUSULAS ACEPTADAS</h3>'
      + '<p class="clausula"><strong>1.</strong> El vendedor declara ser el propietario legitimo del articulo y tener derecho a venderlo.</p>'
      + '<p class="clausula"><strong>2.</strong> La informacion proporcionada es veraz y el articulo esta correctamente descrito.</p>'
      + '<p class="clausula"><strong>3.</strong> Esta transaccion se realiza entre particulares. No aplica la legislacion de consumo sino el Codigo Civil espanol (art. 1484 sobre vicios ocultos).</p>'
      + '<p class="clausula"><strong>4.</strong> Grupetas.com actua como mero intermediario y no asume ninguna responsabilidad sobre la transaccion, el estado del articulo, el pago ni la entrega.</p>'
      + '<p class="clausula"><strong>5.</strong> Cualquier disputa entre comprador y vendedor debera resolverse directamente entre las partes.</p>'
      + '<p class="clausula"><strong>6.</strong> Los datos del vendedor (nombre y localidad) seran visibles para otros usuarios registrados conforme al RGPD.</p>'
      + '<h3>4. FIRMA DEL VENDEDOR</h3>'
      + '<p style="font-size:12px;color:#6b7280;margin:0 0 12px">Firma manuscrita recogida digitalmente mediante canvas HTML5 en el momento de publicar el anuncio:</p>'
      + '<div class="firma-box"><img src="' + img.src + '" /></div>'
      + '<p class="firma-label">Firmado por: ' + mfEsc(it.autorNombre||'') + ' &mdash; ' + fecha + '</p>'
      + '<p class="footer">Documento generado por Grupetas.com &middot; Firma digital recogida via canvas HTML5 &middot; Codigo Civil art. 1484 &middot; RGPD<br>Este documento tiene validez como declaracion de responsabilidad entre particulares.</p>'
      + '<br><button onclick="window.print()">Imprimir / Guardar PDF</button>'
      + '</body></html>');
    win.document.close();
    win.focus();
    setTimeout(function() { win.print(); }, 600);
  };
})();
</script>



<script>
(function() {
  var _ctx = null;
  var _chatRef = null;
  var _chatListener = null;
  var _sellerRef = null;
  var _sellerListener = null;
  var _activeChatKey = null;
    _compradorCache = {};
  var _fotoIdx = 0;

  function esc(s) {
    return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }
  function fmt(ts) { return new Date(ts).toLocaleTimeString('es-ES', {hour: '2-digit', minute: '2-digit'}); }
  function fmtDate(ts) { return new Date(ts).toLocaleDateString('es-ES'); }

  // Cache de datos de compradores
  var _compradorCache = {};
  var _sellerChatsData = {};

  function getCompradorInfo(uid, cb) {
    if (_compradorCache[uid]) { cb(_compradorCache[uid]); return; }
    _ctx.db.ref('ciclistas/' + uid).once('value', function(snap) {
      var d = snap.val() || {};
      var nombre = d.nombre || d.displayName || 'Ciclista';
      var ciudad = d.localidad || d.ciudad || '';
      // clubs es array con los nombres de grupetas a las que pertenece
      var grupeta = '';
      if (Array.isArray(d.clubs) && d.clubs.length > 0) {
        grupeta = d.clubs[0] || '';
      } else if (typeof d.clubs === 'string' && d.clubs.trim()) {
        grupeta = d.clubs.trim();
      }
      var info = { nombre: nombre, ciudad: ciudad, grupeta: grupeta };
      _compradorCache[uid] = info;
      cb(info);
    }, function() {
      var info = { nombre: 'Ciclista', ciudad: '', grupeta: '' };
      _compradorCache[uid] = info;
      cb(info);
    });
  }

  function renderSellerChats(allChats) {
    if (allChats !== null) { _sellerChatsData = allChats || {}; }
    var list = document.getElementById('md-seller-list');
    if (!list) return;
    var data = _sellerChatsData;
    var keys = Object.keys(data);
    if (keys.length === 0) {
      list.innerHTML = '<p style="font-family:Inter,sans-serif;font-size:12px;color:#9ca3af;text-align:center;padding:20px 0">Aún no tienes mensajes de compradores</p>';
      return;
    }
    list.innerHTML = '';
    keys.forEach(function(key) {
      var msgs = Object.values(data[key] || {}).sort(function(a,b){return a.ts-b.ts;});
      if (!msgs.length) return;
      var last = msgs[msgs.length-1];
      var parts = key.split('_');
      var compradorId = parts.find(function(p){ return p !== _ctx.currentUserId; }) || parts[0];

      function makeBtnHTML(nombre, meta, ts, texto) {
        return '<div style="display:flex;justify-content:space-between;align-items:center"><div>'
          + '<p style="font-family:Inter,sans-serif;font-size:13px;font-weight:600;color:#111827;margin:0">&#x1F464; ' + esc(nombre) + '</p>'
          + (meta ? '<p style="font-family:Inter,sans-serif;font-size:10px;color:#9ca3af;margin:1px 0 0">' + esc(meta) + '</p>' : '')
          + '</div><span style="font-family:Inter,sans-serif;font-size:10px;color:#9ca3af">' + fmt(ts) + '</span></div>'
          + '<p style="font-family:Inter,sans-serif;font-size:12px;color:#6b7280;margin:4px 0 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(texto) + '</p>';
      }

      var btn = document.createElement('div');
      btn.id = 'seller-btn-' + key;
      btn.style.cssText = 'padding:10px 12px;border-radius:10px;cursor:pointer;background:' + (_activeChatKey===key?'#f3e8ff':'#f9fafb') + ';border:1px solid ' + (_activeChatKey===key?'#ddd6fe':'#f3f4f6') + ';margin-bottom:6px';

      // Pintar con caché si existe, si no placeholder — en ambos casos se añade al DOM YA
      var cachedInfo = _compradorCache[compradorId];
      if (cachedInfo) {
        var cm = cachedInfo.ciudad || '';
        if (cachedInfo.grupeta) cm += (cm ? ' · ' : '') + cachedInfo.grupeta;
        btn.innerHTML = makeBtnHTML(cachedInfo.nombre, cm, last.ts, last.texto);
      } else {
        btn.innerHTML = makeBtnHTML('...', '', last.ts, last.texto);
      }

      btn.onclick = (function(k, cid) { return function() {
        _activeChatKey = k;
        renderSellerChats(null);
        var chatArea = document.getElementById('md-seller-msgs');
        var inputArea = document.getElementById('md-seller-input-wrap');
        var headerEl = document.getElementById('md-seller-chat-header');
        if (chatArea) chatArea.innerHTML = '<p style="font-family:Inter,sans-serif;font-size:12px;color:#9ca3af;text-align:center;padding:20px 0">Cargando...</p>';
        if (inputArea) inputArea.style.display = 'flex';
        getCompradorInfo(cid, function(info) {
          if (!headerEl) return;
          var meta = info.ciudad ? info.ciudad : '';
          if (info.grupeta) meta += (meta ? ' · ' : '') + info.grupeta;
          headerEl.innerHTML = '<div style="width:28px;height:28px;border-radius:50%;background:#f3e8ff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#7c3aed;flex-shrink:0">' + esc(info.nombre.charAt(0).toUpperCase()) + '</div>'
            + '<div>'
              + '<p style="font-family:Inter,sans-serif;font-size:13px;font-weight:600;color:#111827;margin:0">' + esc(info.nombre) + '</p>'
              + (meta ? '<p style="font-family:Inter,sans-serif;font-size:10px;color:#9ca3af;margin:0">' + esc(meta) + '</p>' : '')
            + '</div>';
        });
        if (_chatRef && _chatListener) _chatRef.off('value', _chatListener);
        _chatRef = _ctx.db.ref('mercadilloChat/' + _ctx.aid + '/' + k);
        _chatListener = _chatRef.on('value', function(snap) {
          var ms = Object.values(snap.val() || {}).sort(function(a,b){return a.ts-b.ts;});
          if (!chatArea) return;
          if (!ms.length) { chatArea.innerHTML = '<p style="font-family:Inter,sans-serif;font-size:12px;color:#9ca3af;text-align:center;padding:20px 0">Sin mensajes</p>'; return; }
          getCompradorInfo(cid, function(info) {
            var h = '';
            ms.forEach(function(m) {
              var mine = m.uid === _ctx.currentUserId;
              h += '<div style="display:flex;flex-direction:column;align-items:' + (mine?'flex-end':'flex-start') + ';margin-bottom:6px">'
                + '<div style="font-family:Inter,sans-serif;font-size:13px;padding:7px 11px;border-radius:14px;max-width:78%;background:' + (mine?'#ede9fe':'#f3f4f6') + ';color:' + (mine?'#4c1d95':'#1f2937') + '">' + esc(m.texto) + '</div>'
                + '<span style="font-family:Inter,sans-serif;font-size:10px;color:#9ca3af;margin-top:2px">' + (mine?'Tú':esc(info.nombre)) + ' · ' + fmt(m.ts) + '</span>'
                + '</div>';
            });
            if (chatArea) { chatArea.innerHTML = h; chatArea.scrollTop = chatArea.scrollHeight; }
          });
        });
        if (_sellerRef) _sellerRef.once('value', function(s){ renderSellerChats(s.val()); });
      }; })(key, compradorId);
      list.appendChild(btn);
      // Si no estaba en caché, pedir ahora que ya está en el DOM
      if (!cachedInfo) {
        getCompradorInfo(compradorId, (function(k, ts, texto) { return function(info) {
          var b = document.getElementById('seller-btn-' + k);
          if (!b) return;
          var meta = info.ciudad || '';
          if (info.grupeta) meta += (meta ? ' · ' : '') + info.grupeta;
          b.innerHTML = makeBtnHTML(info.nombre, meta, ts, texto);
        }; })(key, last.ts, last.texto));
      }
    });
  }

  function renderGaleria() {
    var el = document.getElementById('md-galeria');
    if (!el || !_ctx) return;
    var fotos = (_ctx.anuncio.fotos || []);
    if (fotos.length === 0) {
      el.innerHTML = '<div style="width:100%;height:128px;background:#f9fafb;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:48px">🚴</div>';
      return;
    }
    var main = '<img src="' + esc(fotos[_fotoIdx]) + '" style="width:100%;height:192px;object-fit:cover;border-radius:12px">';
    var thumbs = '';
    if (fotos.length > 1) {
      thumbs = '<div style="display:flex;gap:6px;margin-top:8px;justify-content:center">';
      fotos.forEach(function(f, i) {
        thumbs += '<img src="' + esc(f) + '" onclick="window._mdFoto(' + i + ')" style="width:40px;height:40px;object-fit:cover;border-radius:8px;cursor:pointer;border:2px solid ' + (i === _fotoIdx ? '#7c3aed' : 'transparent') + ';opacity:' + (i === _fotoIdx ? '1' : '0.6') + '">';
      });
      thumbs += '</div>';
    }
    el.innerHTML = main + thumbs;
  }
  window._mdFoto = function(i) { _fotoIdx = i; renderGaleria(); };

  function renderChat(msgs) {
    var el = document.getElementById('md-chat-msgs');
    if (!el || !_ctx) return;
    var uid = _ctx.currentUserId;
    var autorNombre = _ctx.anuncio.autorNombre || 'Vendedor';
    if (!msgs || msgs.length === 0) {
      el.innerHTML = '<p style="font-family:Inter,sans-serif;font-size:12px;color:#9ca3af;text-align:center;margin-top:24px">Escribe el primer mensaje al vendedor</p>';
      return;
    }
    msgs.sort(function(a, b) { return a.ts - b.ts; });
    var html2 = '';
    msgs.forEach(function(m) {
      var mine = m.uid === uid;
      var align = mine ? 'flex-end' : 'flex-start';
      var bg = mine ? '#ede9fe' : '#f3f4f6';
      var clr = mine ? '#4c1d95' : '#1f2937';
      var quien = mine ? 'Tú' : esc(autorNombre);
      html2 += '<div style="display:flex;flex-direction:column;align-items:' + align + ';margin-bottom:8px">';
      html2 += '<div style="font-family:Inter,sans-serif;font-size:14px;padding:8px 12px;border-radius:16px;max-width:75%;background:' + bg + ';color:' + clr + '">' + esc(m.texto) + '</div>';
      html2 += '<span style="font-family:Inter,sans-serif;font-size:10px;color:#9ca3af;margin-top:2px;padding:0 4px">' + quien + ' &middot; ' + fmt(m.ts) + '</span>';
      html2 += '</div>';
    });
    el.innerHTML = html2;
    el.scrollTop = el.scrollHeight;
  }

  function switchTab(tab) {
    var pInfo = document.getElementById('md-panel-info');
    var pChat = document.getElementById('md-panel-chat');
    var pSeller = document.getElementById('md-panel-seller');
    var tInfo = document.getElementById('md-tab-info');
    var tChat = document.getElementById('md-tab-chat');
    var tSeller = document.getElementById('md-tab-seller');
    if (pInfo) pInfo.style.display = tab === 'info' ? 'block' : 'none';
    if (pChat) pChat.style.display = tab === 'chat' ? 'flex' : 'none';
    if (pSeller) pSeller.style.display = tab === 'seller' ? 'flex' : 'none';
    if (tInfo) { tInfo.style.borderBottomColor = tab === 'info' ? '#7c3aed' : 'transparent'; tInfo.style.color = tab === 'info' ? '#7c3aed' : '#9ca3af'; }
    if (tChat) { tChat.style.borderBottomColor = tab === 'chat' ? '#7c3aed' : 'transparent'; tChat.style.color = tab === 'chat' ? '#7c3aed' : '#9ca3af'; }
    if (tSeller) { tSeller.style.borderBottomColor = tab === 'seller' ? '#7c3aed' : 'transparent'; tSeller.style.color = tab === 'seller' ? '#7c3aed' : '#9ca3af'; }
    if (tab === 'chat') { var msgs = document.getElementById('md-chat-msgs'); if (msgs) msgs.scrollTop = msgs.scrollHeight; }
    if (tab === 'seller') {
      var pSel = document.getElementById('md-panel-seller');
      if (pSel) pSel.style.display = 'flex';
      renderSellerChats(null);
      var sl = document.getElementById('md-seller-msgs');
      if (sl) sl.scrollTop = sl.scrollHeight;
    }
  }
  window._mdTab = function(t) { switchTab(t); };

  function sendMsg() {
    var inp = document.getElementById('md-chat-input');
    if (!inp || !inp.value.trim() || !_chatRef) return;
    var txt = inp.value.trim();
    inp.value = '';
    _chatRef.push({ uid: _ctx.currentUserId, texto: txt, ts: Date.now() })
      .then(function() {
        // Push al vendedor
        var vendedorUid = _ctx.anuncio.autorId;
        if (vendedorUid && vendedorUid !== _ctx.currentUserId) {
          pushMercadillo(vendedorUid,
            '🛒 Nuevo mensaje en tu anuncio',
            (_ctx.anuncio.titulo || 'Anuncio') + ': ' + txt.slice(0, 80));
        }
      })
      .catch(function() { if (_ctx) _ctx.showToast('Error al enviar'); });
  }
  window._mdSend = sendMsg;

  function pushMercadillo(toUid, title, body) {
    fetch('/.netlify/functions/send-push', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ uid: toUid, title: title, body: body, url: '/' })
    }).catch(function() {});
  }

  window._mdBorrar = function() {
    if (!window.confirm('\u00bfEliminar este anuncio?')) return;
    _ctx.db.ref('mercadillo/' + _ctx.aid).remove()
      .then(function() { var cb = _ctx.onClose; _ctx.showToast('Anuncio eliminado'); close(); if (cb) cb(); })
      .catch(function(err) { if (_ctx) _ctx.showToast('Error: ' + err.message); });
  };

  window._mdEditar = function() {
    var ctx = _ctx;
    close();
    if (ctx.onEdit) ctx.onEdit(ctx.aid, ctx.anuncio);
  };

  window._mdSellerSend = function() {
    var inp = document.getElementById('md-seller-input');
    if (!inp || !inp.value.trim() || !_chatRef) return;
    var txt = inp.value.trim();
    inp.value = '';
    _chatRef.push({ uid: _ctx.currentUserId, texto: txt, ts: Date.now() })
      .then(function() {
        // Push al comprador: su uid está en el chatKey (el que no soy yo)
        if (_activeChatKey) {
          var parts = _activeChatKey.split('_');
          var compradorUid = parts.find(function(p){ return p !== _ctx.currentUserId; });
          if (compradorUid) {
            pushMercadillo(compradorUid,
              '🛒 El vendedor te ha respondido',
              (_ctx.anuncio.titulo || 'Anuncio') + ': ' + txt.slice(0, 80));
          }
        }
      })
      .catch(function() { if (_ctx) _ctx.showToast('Error al enviar'); });
  };

  window._mdClose = function() {
    var cb = _ctx && _ctx.onClose;
    close();
    if (cb) cb();
  };

  function close() {
    if (_chatRef && _chatListener) { _chatRef.off('value', _chatListener); }
    if (_sellerRef && _sellerListener) { _sellerRef.off('value', _sellerListener); }
    _chatRef = null; _chatListener = null;
    _sellerRef = null; _sellerListener = null;
    _activeChatKey = null;
    _sellerChatsData = {};
    _compradorCache = {};
    _ctx = null; _fotoIdx = 0;
    var el = document.getElementById('md-overlay');
    if (el) el.remove();
  }

  function build(ctx) {
    _ctx = ctx;
    _fotoIdx = 0;
    var a = ctx.anuncio;
    var esPropio = a.autorId === ctx.currentUserId;

    var tipoBg = a.tipo === 'Venta' ? '#dcfce7' : a.tipo === 'Búsqueda' ? '#fef3c7' : '#dbeafe';
    var tipoClr = a.tipo === 'Venta' ? '#166534' : a.tipo === 'Búsqueda' ? '#92400e' : '#1e40af';
    var precioStr = (a.precio !== null && a.precio !== undefined) ? Number(a.precio).toLocaleString('es-ES') + ' \u20ac' : 'Precio a convenir';
    var inicialAutor = esc((a.autorNombre || '?').charAt(0).toUpperCase());

    var overlay = document.createElement('div');
    overlay.id = 'md-overlay';
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Inter,system-ui,sans-serif';
    overlay.onclick = function(e) { if (e.target === overlay) window._mdClose(); };

    var modal = document.createElement('div');
    modal.style.cssText = 'background:white;border-radius:16px;width:100%;max-width:420px;max-height:92vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,0.3)';
    modal.onclick = function(e) { e.stopPropagation(); };

    var INP_STYLE = 'flex:1;font-family:Inter,sans-serif;font-size:14px;border:1px solid #e5e7eb;border-radius:12px;padding:8px 12px;outline:none;min-width:0';

    var tabsHTML = '';
    if (esPropio) {
      tabsHTML = '<div style="display:flex;border-bottom:1px solid #f3f4f6">'
        + '<button id="md-tab-info" onclick="window._mdTab(&apos;info&apos;)" style="flex:1;padding:10px;font-family:Inter,sans-serif;font-size:12px;font-weight:700;border:none;border-bottom:2px solid #7c3aed;color:#7c3aed;background:transparent;cursor:pointer">Anuncio</button>'
        + '<button id="md-tab-seller" onclick="window._mdTab(&apos;seller&apos;)" style="flex:1;padding:10px;font-family:Inter,sans-serif;font-size:12px;font-weight:700;border:none;border-bottom:2px solid transparent;color:#9ca3af;background:transparent;cursor:pointer">&#x1F4AC; Mensajes</button>'
        + '</div>';
    } else if (!esPropio) {
      tabsHTML = '<div style="display:flex;border-bottom:1px solid #f3f4f6">'
        + '<button id="md-tab-info" onclick="window._mdTab(&apos;info&apos;)" style="flex:1;padding:10px;font-family:Inter,sans-serif;font-size:12px;font-weight:700;border:none;border-bottom:2px solid #7c3aed;color:#7c3aed;background:transparent;cursor:pointer">Anuncio</button>'
        + '<button id="md-tab-chat" onclick="window._mdTab(&apos;chat&apos;)" style="flex:1;padding:10px;font-family:Inter,sans-serif;font-size:12px;font-weight:700;border:none;border-bottom:2px solid transparent;color:#9ca3af;background:transparent;cursor:pointer">&#x1F4AC; Chat privado</button>'
        + '</div>';
    }

    var acciones = '';
    if (a.telefono) {
      acciones += '<a href="https://wa.me/' + a.telefono.replace(/\D/g, '') + '" target="_blank" rel="noopener noreferrer" style="flex:1;background:#22c55e;color:white;border:none;border-radius:12px;padding:10px;font-family:Oswald,sans-serif;font-weight:700;font-size:12px;letter-spacing:.05em;cursor:pointer;text-align:center;text-decoration:none;display:flex;align-items:center;justify-content:center">&#x1F4F1; WhatsApp</a>';
    }
    if (!esPropio) {
      acciones += '<button onclick="window._mdTab(&apos;chat&apos;)" style="flex:1;background:#7c3aed;color:white;border:none;border-radius:12px;padding:10px;font-family:Oswald,sans-serif;font-weight:700;font-size:12px;letter-spacing:.05em;cursor:pointer">&#x1F4AC; Chat privado</button>';
    }

    var gestion = '';
    if (esPropio || ctx.isCentral || ctx.isAdmin) {
      if (esPropio) gestion += '<button onclick="window._mdEditar()" style="flex:1;font-family:Inter,sans-serif;font-size:12px;font-weight:700;color:#7c3aed;border:1px solid #ddd6fe;border-radius:12px;padding:8px;background:white;cursor:pointer">&#x270F;&#xFE0F; Editar</button>';
      gestion += '<button onclick="window._mdBorrar()" style="flex:1;font-family:Inter,sans-serif;font-size:12px;font-weight:700;color:#ef4444;border:1px solid #fecaca;border-radius:12px;padding:8px;background:white;cursor:pointer">&#x1F5D1; Eliminar</button>';
    }

    var panelInfo = '<div id="md-panel-info" style="padding:16px;display:block">'
      + '<div id="md-galeria"></div>'
      + '<div style="display:flex;align-items:center;justify-content:space-between;margin-top:12px">'
        + '<span style="font-family:Oswald,sans-serif;font-weight:700;font-size:24px;color:#111827">' + esc(precioStr) + '</span>'
        + '<span style="font-family:Inter,sans-serif;font-size:12px;font-weight:700;padding:4px 10px;border-radius:20px;background:' + tipoBg + ';color:' + tipoClr + '">' + esc(a.tipo) + '</span>'
      + '</div>'
      + '<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">'
        + '<span style="font-family:Inter,sans-serif;font-size:12px;background:#f3e8ff;color:#7c3aed;font-weight:500;padding:4px 10px;border-radius:20px">' + esc(a.categoria) + '</span>'
        + (a.localidad ? '<span style="font-family:Inter,sans-serif;font-size:12px;background:#f3f4f6;color:#4b5563;padding:4px 10px;border-radius:20px">&#x1F4CD; ' + esc(a.localidad) + '</span>' : '')
      + '</div>'
      + (a.descripcion ? '<p style="font-family:Inter,sans-serif;font-size:14px;color:#4b5563;line-height:1.6;margin:12px 0 0">' + esc(a.descripcion) + '</p>' : '')
      + '<div style="display:flex;align-items:center;gap:8px;padding:10px 0;border-top:1px solid #f3f4f6;border-bottom:1px solid #f3f4f6;margin-top:12px">'
        + '<div style="width:32px;height:32px;border-radius:50%;background:#f3e8ff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#7c3aed;flex-shrink:0">' + inicialAutor + '</div>'
        + '<div><p style="font-family:Inter,sans-serif;font-size:13px;font-weight:500;color:#111827;margin:0">' + esc(a.autorNombre || 'Ciclista') + '</p><p style="font-family:Inter,sans-serif;font-size:11px;color:#9ca3af;margin:0">' + fmtDate(a.ts) + '</p></div>'
      + '</div>'
      + '<div style="display:flex;gap:8px;margin-top:12px">' + acciones + '</div>'
      + (gestion ? '<div style="display:flex;gap:8px;margin-top:8px">' + gestion + '</div>' : '')
      + '</div>';

    var panelSeller = '<div id="md-panel-seller" style="padding:16px;display:none;flex-direction:column">'
      + '<div id="md-seller-list" style="max-height:180px;overflow-y:auto;margin-bottom:10px"></div>'
      + '<div id="md-seller-msgs" style="min-height:120px;max-height:200px;overflow-y:auto;display:flex;flex-direction:column;gap:6px;border-top:1px solid #f3f4f6;padding-top:10px;margin-bottom:8px"></div>'
      + '<div id="md-seller-input-wrap" style="display:none;gap:8px;border-top:1px solid #f3f4f6;padding-top:10px">'
        + '<input id="md-seller-input" placeholder="Responder…" style="' + INP_STYLE + '" onkeydown="if(event.key===&apos;Enter&apos;&&!event.shiftKey){event.preventDefault();window._mdSellerSend();}">'
        + '<button onclick="window._mdSellerSend()" style="background:#7c3aed;color:white;border:none;border-radius:12px;padding:8px 14px;cursor:pointer;font-size:16px">&#x27A4;</button>'
      + '</div>'
      + '</div>';

    var panelChat = '<div id="md-panel-chat" style="padding:16px;display:none;flex-direction:column">'
      + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid #f3f4f6">'
        + '<div style="width:28px;height:28px;border-radius:50%;background:#f3e8ff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#7c3aed">' + inicialAutor + '</div>'
        + '<p style="font-family:Inter,sans-serif;font-size:13px;font-weight:500;color:#111827;margin:0">' + esc(a.autorNombre || 'Vendedor') + '</p>'
        + '<span style="font-family:Inter,sans-serif;font-size:11px;color:#9ca3af">&middot; Vendedor</span>'
      + '</div>'
      + '<div id="md-chat-msgs" style="min-height:140px;max-height:240px;overflow-y:auto;display:flex;flex-direction:column;gap:6px;margin-bottom:12px"></div>'
      + '<div style="display:flex;gap:8px;border-top:1px solid #f3f4f6;padding-top:12px">'
        + '<input id="md-chat-input" placeholder="Escribe un mensaje…" style="' + INP_STYLE + '" onkeydown="if(event.key===&apos;Enter&apos;&&!event.shiftKey){event.preventDefault();window._mdSend();}">'
        + '<button onclick="window._mdSend()" style="background:#7c3aed;color:white;border:none;border-radius:12px;padding:8px 14px;cursor:pointer;font-size:16px">&#x27A4;</button>'
      + '</div>'
      + '</div>';

    modal.innerHTML = '<div style="display:flex;align-items:center;justify-content:space-between;padding:16px;border-bottom:1px solid #f3f4f6">'
      + '<h2 style="font-family:Oswald,sans-serif;font-weight:700;font-size:17px;color:#111827;margin:0;padding-right:8px;line-height:1.3">' + esc(a.titulo) + '</h2>'
      + '<button onclick="window._mdClose()" style="background:none;border:none;font-size:24px;color:#9ca3af;cursor:pointer;line-height:1;flex-shrink:0">&times;</button>'
      + '</div>'
      + tabsHTML
      + panelInfo
      + panelSeller
      + panelChat;

    overlay.appendChild(modal);
    document.body.appendChild(overlay);
    renderGaleria();

    if (esPropio && ctx.currentUserId) {
      _sellerRef = ctx.db.ref('mercadilloChat/' + ctx.aid);
      _sellerListener = _sellerRef.on('value', function(snap) {
        var data = snap.val() || {};
        _sellerChatsData = data;
        var list = document.getElementById('md-seller-list');
        if (list) renderSellerChats(null);
      });
    }

    if (!esPropio && ctx.currentUserId) {
      var chatKey = [ctx.currentUserId, ctx.anuncio.autorId].sort().join('_');
      _chatRef = ctx.db.ref('mercadilloChat/' + ctx.aid + '/' + chatKey);
      _chatListener = _chatRef.on('value', function(snap) {
        renderChat(Object.values(snap.val() || {}));
      });
    }
  }

  window._mercadilloOpenDetalle = function(ctx) { close(); build(ctx); };
  window._mercadilloCloseDetalle = close;
})();
</script>
</body>
</html>
