// Variant A — "Póster cinemático" (con concurso completo) // // El navegador solo recuerda el último piloto registrado. El alta, la subida // de fotos y el ranking viven en el servidor para que todo sea compartido. const CAS_STATE_KEY = 'cas-state-v1'; const CAS_REGISTER_API = '/api/register.php?format=json'; const CAS_UPLOAD_API = '/api/upload.php?format=json'; const CAS_RANKING_API = '/ranking.php?format=json&v=20260509a'; const CAS_RANKING_CACHE_KEY = 'cas-ranking-cache-v6'; const CAS_WHATSAPP_GROUP_URL = 'https://chat.whatsapp.com/FVRHwKoBqTT3ncAlU1DWDk'; function loadState(){ try{ const raw = localStorage.getItem(CAS_STATE_KEY); if(!raw) return defaultState(); const s = JSON.parse(raw); const next = {...defaultState(), ...s}; if (typeof next.me === 'string') next.me = null; if (next.me && typeof next.me === 'object' && !next.me.codigo) next.me = null; return { me: next.me || null }; }catch{return defaultState();} } function defaultState(){ return { me: null }; } function saveState(s){ try{ localStorage.setItem(CAS_STATE_KEY, JSON.stringify(s)); }catch{} } const StateCtx = React.createContext(null); function useCasState(){ const [state, setState] = React.useState(loadState); const update = React.useCallback((fn)=>{ setState(prev=>{ const next = typeof fn==='function' ? fn(prev) : fn; saveState(next); return next; }); },[]); // sync across tabs React.useEffect(()=>{ const onStorage = (e)=>{ if(e.key===CAS_STATE_KEY) setState(loadState()); }; window.addEventListener('storage', onStorage); return ()=>window.removeEventListener('storage', onStorage); },[]); return [state, update]; } // ───────────────────────────────────────────────────────────── function VariantPoster() { const [state, setState] = useCasState(); const [lightbox, setLightbox] = React.useState(null); const [legalOpen, setLegalOpen] = React.useState(false); return ( setLegalOpen(true)}}>
{lightbox && setLightbox(null)}/>} {legalOpen && setLegalOpen(false)}/>}
); } function scrollToSection(id) { const el = document.getElementById(id); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } function getRankChipStyle(slotState, slot) { const isBonus = !!slot?.bonus; const isExtra = !!slot?.extra; const isApproved = slotState === 'aprobado' || slot?.done; const isSubmitted = slotState === 'pendiente' || slotState === 'revision'; const isRejected = slotState === 'rechazado'; if (isBonus) { if (isApproved) return { bg: 'linear-gradient(180deg,#e06a58 0%,#8d2018 100%)', border: '#b24337', color: '#fff4f1' }; if (isSubmitted) return { bg: 'linear-gradient(180deg,#f08a7a 0%,#a63428 100%)', border: '#d56a5d', color: '#fff4f1' }; if (isRejected) return { bg: 'linear-gradient(180deg,#4a231c 0%,#26120f 100%)', border: 'rgba(205,94,74,.32)', color: '#f0c9be' }; return { bg: 'linear-gradient(180deg,rgba(91,28,24,.78) 0%,rgba(47,14,12,.84) 100%)', border: 'rgba(198,76,63,.22)', color: '#f0c9be' }; } if (isExtra) { if (isApproved) return { bg: 'linear-gradient(180deg,#c87f41 0%,#7a4a20 100%)', border: '#a96b34', color: '#1a0f06' }; if (isSubmitted) return { bg: 'linear-gradient(180deg,#d7a358 0%,#8c5a21 100%)', border: '#b97b35', color: '#1a0f06' }; if (isRejected) return { bg: 'linear-gradient(180deg,#422218 0%,#24130f 100%)', border: 'rgba(205,94,74,.28)', color: '#efc9bc' }; return { bg: 'linear-gradient(180deg,rgba(72,45,28,.75) 0%,rgba(36,22,16,.82) 100%)', border: 'rgba(185,123,53,.2)', color: '#c9b29a' }; } if (isApproved) return { bg: 'linear-gradient(180deg,#e28a48 0%,#b35b24 100%)', border: '#c86d31', color: '#1a0f06' }; if (isSubmitted) return { bg: 'linear-gradient(180deg,#fff06a 0%,#f0b700 100%)', border: '#f1c83a', color: '#1a0f06' }; if (isRejected) return { bg: 'linear-gradient(180deg,#4a231c 0%,#26120f 100%)', border: 'rgba(205,94,74,.32)', color: '#f0c9be' }; return { bg: 'rgba(0,0,0,.32)', border: 'var(--cas-border)', color: 'var(--cas-bone-dim)' }; } // ── Global style ────────────────────────────────────────────── function GlobalStyle() { return ( ); } // ── HERO ────────────────────────────────────────────────────── function HeroPoster() { return (
CASCOTERAPIA
{e.preventDefault(); scrollToSection('subir');}}>SUBIR FOTO {e.preventDefault(); scrollToSection('ranking');}}>VER RANKING
Estreno · web + concurso
SOLO PARA LOS QUE SALEN DEL CAPARAZÓN

LA PRUEBA

cascoterapia

spin off del Feel it Challenge del 4º aniversario en 2026. Una pequeña prueba para ir abriendo boca hasta que llegue el tan deseado Challenge.

Los avisos se comunicarán solo en el grupo de WhatsApp y la subida de fotos se hace aquí, por la web.

↓ SIGUE BAJANDO
CÓMO FUNCIONA
); } function LogoSvg(){ return cascoterapia; } const linkStyle = { color:'inherit', textDecoration:'none', transition:'color .15s' }; // ── HOW IT WORKS ────────────────────────────────────────────── function HowItWorks(){ const steps = [ {n:'01',t:'INSCRÍBETE',d:'Nombre y teléfono. Te asignamos un código de 4 dígitos que te identifica en el ranking.'}, {n:'02',t:'MIRA LAS PISTAS EN EL GRUPO WHATSAPP',d:'Las pistas y los avisos se comparten ahí.'}, {n:'03',t:'SUBE LA FOTO',d:'Hazte la foto con la pegatina, usa tu código y selecciona el punto que has encontrado.'}, {n:'04',t:'SUBE EN EL RANKING EN DIRECTO',d:'La barra de progreso crece al subir la foto y se pone naranja cuando el admin la valida.'}, ]; return (
{steps.map((st,i)=>(
{st.n}

{st.t}

{st.d}

))}
); } function SectionHeader({kicker,title,align='left'}){ return (
{kicker}

{title}

); } // ── CLUES ───────────────────────────────────────────────────── const MAIN_POINTS = [ {id:'pista1', n:'PISTA 01', title:'PISTA 01', place:'', points:1}, {id:'pista2', n:'PISTA 02', title:'PISTA 02', place:'', points:1}, {id:'pista3', n:'PISTA 03', title:'PISTA 03', place:'', points:1}, {id:'pista4', n:'PISTA 04', title:'PISTA 04', place:'', points:1}, {id:'pista5', n:'PISTA 05', title:'PISTA 05', place:'', points:1}, {id:'bonus1', n:'BONUS 01', title:'BONUS 01', place:'', points:5, isBonus:true, badge:'★'}, {id:'bonus2', n:'BONUS 02', title:'BONUS 02', place:'', points:5, isBonus:true, badge:'✦'}, ]; const EXTRA_POINTS = Array.from({length:36}, (_, i) => { const n = i + 1; const label = String(n).padStart(2, '0'); return { id:`extra${n}`, n:`CASCO ${label}`, title:`Casco ${label}`, place:`Casco ${label}`, points:2, isExtra:true, badge:'⛑', }; }); const CLUES = MAIN_POINTS; const RANK_SLOTS = [...MAIN_POINTS, ...EXTRA_POINTS]; const RANK_TOTAL_POINTS = 87; const RANK_TOTAL_PHOTOS = 45; function WhatsappSection(){ return (

Los avisos se compartirán en el grupo de WhatsApp. La recogida de fotos y la validación siguen haciéndose por la web para que todo quede ordenado.

Una vez entres al grupo, podrás seguir el avance del reto y subir cada foto desde el formulario de arriba.

MENSAJES + AVISOS
TODO SE COMUNICA AHÍ

Únete al grupo para recibir los avisos en tiempo real.

); } // ── LIGHTBOX ────────────────────────────────────────────────── function Lightbox({src,title,onClose}){ React.useEffect(()=>{ const onKey = (e)=>{ if(e.key==='Escape') onClose(); }; window.addEventListener('keydown',onKey); document.body.style.overflow='hidden'; return ()=>{window.removeEventListener('keydown',onKey);document.body.style.overflow=''}; },[onClose]); return (
e.stopPropagation()} style={{position:'relative',maxWidth:'90vw',maxHeight:'90vh'}}>
{title}
{title}{e.target.style.display='none'}}/>
esc · click fuera para cerrar
); } // ── UPLOAD SECTION ──────────────────────────────────────────── function UploadSection(){ const {state, setState} = React.useContext(StateCtx); const me = state.me && state.me.codigo ? state.me : null; const [pistaId, setPistaId] = React.useState(CLUES[0].id); const [codigo, setCodigo] = React.useState(me?.codigo || ''); const [file, setFile] = React.useState(null); const [preview, setPreview] = React.useState(''); const [sending, setSending] = React.useState(false); const [done, setDone] = React.useState(false); const [err, setErr] = React.useState(''); const uploadRef = React.useRef(null); React.useEffect(()=>{ if (state.me && state.me.codigo) { setCodigo(state.me.codigo); } else { setCodigo(''); } }, [state.me]); const onFile = (e)=>{ const f = e.target.files?.[0]; if(!f) return; setFile(f); const r = new FileReader(); r.onload = ()=>setPreview(r.result); r.readAsDataURL(f); }; const submit = (e)=>{ e.preventDefault(); setErr(''); if(!/^\d{4}$/.test(codigo)){ setErr('Tu código son 4 dígitos.'); return; } if(!file){ setErr('Sube una foto con la pegatina.'); return; } setSending(true); (async ()=>{ try{ const formData = new FormData(); formData.append('codigo', codigo); formData.append('pista', pistaId); formData.append('foto', file); const res = await fetch(CAS_UPLOAD_API, { method:'POST', headers:{ Accept:'application/json' }, body: formData, }); const data = await res.json().catch(()=>null); if(!res.ok || !data?.ok){ throw new Error(data?.error || 'No se pudo subir la foto'); } setDone(true); setFile(null); setPreview(''); if (uploadRef.current) uploadRef.current.value = ''; setTimeout(()=>setDone(false), 4000); }catch(err){ setErr(err.message || 'No se pudo subir la foto.'); }finally{ setSending(false); } })(); }; return (
{me && (
PILOTO · {me.codigo} · {me.nombre}
)}

Cuando encuentres una pegatina, hazte una foto con ella: un selfie o el saludo motero, como prefieras. Mete tu código, selecciona qué punto has encontrado y súbela.

  • ◉ 1 FOTO POR PUNTO
  • ◉ SELFIE O SALUDO MOTERO
  • ◉ HASTA QUE AVISEMOS
{!me && (
▲ ¿Aún no tienes código? Inscríbete aquí y te damos uno.
)}
NUEVA ENTRADA
setCodigo(e.target.value.replace(/\D/g,'').slice(0,4))} style={{letterSpacing:'.4em',fontFamily:'var(--cas-display)',fontSize:22,textAlign:'center'}} />
{err &&
▲ {err}
} {done &&
✓ FOTO ENVIADA
}
); } // ── RANKING ─────────────────────────────────────────────────── function RankingSection(){ const {state} = React.useContext(StateCtx); const [ranking, setRanking] = React.useState([]); const [organizers, setOrganizers] = React.useState([]); const [maxPoints, setMaxPoints] = React.useState(RANK_TOTAL_POINTS); const [maxPhotos, setMaxPhotos] = React.useState(RANK_TOTAL_PHOTOS); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(''); React.useEffect(()=>{ let alive = true; const loadRanking = async ()=>{ try{ setError(''); const res = await fetch(CAS_RANKING_API, { headers:{ Accept:'application/json' } }); const data = await res.json().catch(()=>null); if(!res.ok || !data || !Array.isArray(data.items)){ throw new Error('No se pudo cargar el ranking'); } if (alive) { const safeItems = Array.isArray(data.items) ? data.items.filter(Boolean) : []; const safeOrganizers = Array.isArray(data.organizers) ? data.organizers.filter(Boolean) : []; const safeMaxPoints = Number.isFinite(Number(data.maxPoints)) ? Number(data.maxPoints) : RANK_TOTAL_POINTS; const safeMaxPhotos = Number.isFinite(Number(data.maxPhotos)) ? Number(data.maxPhotos) : RANK_TOTAL_PHOTOS; setRanking(safeItems); setOrganizers(safeOrganizers); setMaxPoints(safeMaxPoints); setMaxPhotos(safeMaxPhotos); try { localStorage.setItem(CAS_RANKING_CACHE_KEY, JSON.stringify({ items: safeItems, organizers: safeOrganizers, maxPoints: safeMaxPoints, maxPhotos: safeMaxPhotos, })); } catch {} } }catch(err){ if (alive) { try { const cached = JSON.parse(localStorage.getItem(CAS_RANKING_CACHE_KEY) || 'null'); if (cached && Array.isArray(cached.items)) { setRanking(cached.items.filter(Boolean)); setOrganizers(Array.isArray(cached.organizers) ? cached.organizers.filter(Boolean) : []); setMaxPoints(Number.isFinite(Number(cached.maxPoints)) ? Number(cached.maxPoints) : RANK_TOTAL_POINTS); setMaxPhotos(Number.isFinite(Number(cached.maxPhotos)) ? Number(cached.maxPhotos) : RANK_TOTAL_PHOTOS); setError(''); return; } } catch {} setError(err.message || 'No se pudo cargar el ranking'); } }finally{ if (alive) setLoading(false); } }; loadRanking(); const id = setInterval(loadRanking, 5000); return ()=>{ alive = false; clearInterval(id); }; },[]); const safeRanking = Array.isArray(ranking) ? ranking.filter(Boolean) : []; const safeOrganizers = Array.isArray(organizers) ? organizers.filter(Boolean) : []; const displayRanking = [...safeRanking, ...safeOrganizers]; const visibleMaxPoints = Number.isFinite(Number(maxPoints)) ? Number(maxPoints) : RANK_TOTAL_POINTS; const visibleMaxPhotos = Number.isFinite(Number(maxPhotos)) ? Number(maxPhotos) : RANK_TOTAL_PHOTOS; return (
ACTUALIZA CADA 5 SEGUNDOS · AMARILLO = ENVIADA / REVISIÓN · NARANJA = VALIDADA · ROJO = RECHAZADA · BARRA HASTA {visibleMaxPoints} PUNTOS · {visibleMaxPhotos} FOTOS
{loading ? (
CARGANDO RANKING
) : error ? (
RANKING NO DISPONIBLE
reintentando en unos segundos
) : displayRanking.length===0 ? (
AÚN NO HAY PILOTOS REGISTRADOS
en cuanto se inscriban, aparecerán aquí ↓
) : (
{displayRanking.length > 0 && (
POS
PILOTO
PROGRESO
RESUMEN
{displayRanking.map((r,i)=>{ const isOrganizer = !!r.organizer; const score = Number(r.puntos ?? 0); const submittedCount = Number.isFinite(Number(r.fotos_subidas)) ? Number(r.fotos_subidas) : (r.slots || []).reduce((total, slot) => total + (Number(slot.photos ?? 0) || ((slot.state || (slot.done ? 'aprobado' : 'empty')) !== 'empty' && (slot.state || (slot.done ? 'aprobado' : 'empty')) !== 'rechazado' ? 1 : 0)), 0); const summaryText = `${score} puntos - ${submittedCount}/${visibleMaxPhotos} fotos`; return (
{isOrganizer ? '—' : (r.puesto || i+1)}
{r.piloto} {isOrganizer && Fuera del ranking} {state.me?.codigo===r.codigo && · tú}
{summaryText}
{(r.slots || RANK_SLOTS.map(s => ({id:s.id, label:s.label, bonus:!!(s.isBonus || s.bonus), extra:!!(s.isExtra || s.extra), state:s.state || 'empty', done:false, points:s.points}))).map(slot => { const slotState = slot.state || (slot.done ? 'aprobado' : 'empty'); const points = Number(slot.points || 1); const chipPalette = getRankChipStyle(slotState, slot); return (
{slot.bonus ? '★' : ''}
); })}
); })}
)}
)}
); } // ── PRIZES ──────────────────────────────────────────────────── function PrizesSection(){ const prizes = [{p:'1º'},{p:'2º'},{p:'3º'}]; return (
{prizes.map((p,i)=>(
{i===0 &&
}
{p.p}
SECRETO
se revela más adelante
))}
ENTREGA · FECHA POR ANUNCIAR · EN FEEL IT MOTORCYCLES
); } // ── REGISTRATION + UPLOAD ──────────────────────────────────── function RegistrationSection(){ const {state, setState, openLegal} = React.useContext(StateCtx); const me = state.me && state.me.codigo ? state.me : null; const [form,setForm] = React.useState({nombre:'',telefono:'',acepta:false}); const [sending,setSending] = React.useState(false); const [regErr,setRegErr] = React.useState(''); const [pistaId, setPistaId] = React.useState(CLUES[0].id); const [pointGroup, setPointGroup] = React.useState('main'); const [extraId, setExtraId] = React.useState(EXTRA_POINTS[0].id); const [codigo, setCodigo] = React.useState(me?.codigo || ''); const [file, setFile] = React.useState(null); const [preview, setPreview] = React.useState(''); const [uploading, setUploading] = React.useState(false); const [done, setDone] = React.useState(false); const [uploadErr, setUploadErr] = React.useState(''); const uploadRef = React.useRef(null); React.useEffect(()=>{ if (me?.codigo) { setCodigo(me.codigo); } else { setCodigo(''); } }, [me]); const submitRegistration = async (e)=>{ e.preventDefault(); setRegErr(''); if(!form.nombre.trim()) return setRegErr('Necesitamos un nombre, aunque sea un alias.'); if(!/^[\d +().-]{6,}$/.test(form.telefono)) return setRegErr('Teléfono con pinta rara.'); if(!form.acepta) return setRegErr('Tienes que aceptar las bases.'); setSending(true); try{ const body = new URLSearchParams({ nombre: form.nombre.trim(), telefono: form.telefono.trim(), }); const res = await fetch(CAS_REGISTER_API, { method:'POST', headers:{ Accept:'application/json' }, body, }); const data = await res.json().catch(()=>null); if(!res.ok || !data?.ok || !data?.participant){ throw new Error(data?.error || 'No se pudo registrar'); } setState({ me: data.participant }); setForm(f=>({...f,acepta:false})); }catch(err){ setRegErr(err.message || 'No se pudo registrar'); }finally{ setSending(false); } }; const onFile = (e)=>{ const f = e.target.files?.[0]; if(!f) return; setFile(f); const r = new FileReader(); r.onload = ()=>setPreview(r.result); r.readAsDataURL(f); }; const submitUpload = async (e)=>{ e.preventDefault(); setUploadErr(''); if(!/^\d{4}$/.test(codigo)){ setUploadErr('Tu código son 4 dígitos.'); return; } if(!file){ setUploadErr('Sube una foto con la pegatina.'); return; } setUploading(true); try{ const selectedPointId = pointGroup === 'extra' ? extraId : pistaId; const formData = new FormData(); formData.append('codigo', codigo); formData.append('pista', selectedPointId); formData.append('foto', file); const res = await fetch(CAS_UPLOAD_API, { method:'POST', headers:{ Accept:'application/json' }, body: formData, }); const data = await res.json().catch(()=>null); if(!res.ok || !data?.ok){ throw new Error(data?.error || 'No se pudo subir la foto'); } setDone(true); setFile(null); setPreview(''); if (uploadRef.current) uploadRef.current.value = ''; setTimeout(()=>setDone(false), 4000); }catch(err){ setUploadErr(err.message || 'No se pudo subir la foto.'); }finally{ setUploading(false); } }; return (
Paso 03

SUBE TU FOTO

inscríbete primero.

Te registras, recibes tu código y en esta misma zona subes la foto de cada punto. Todo queda más limpio y más rápido.

✓ CÓDIGO ÚNICO
✓ SUBIDA DE FOTOS
✓ TODO EN LA MISMA PÁGINA
{!me ? ( <>
FICHA DEL PILOTO
setForm(f=>({...f,nombre:e.target.value}))}/>
setForm(f=>({...f,telefono:e.target.value}))}/>
{regErr &&
▲ {regErr}
} ) : (
TU CÓDIGO
{me.codigo}
{me.nombre}
guárdalo bien · lo necesitas para subir fotos
)}
SUBIR FOTO
setCodigo(e.target.value.replace(/\D/g,'').slice(0,4))} style={{letterSpacing:'.4em',fontFamily:'var(--cas-display)',fontSize:22,textAlign:'center'}} />
LOS EXTRAS SE SUBEN DE UNO EN UNO
{pointGroup === 'main' ? (
) : (
)}
{uploadErr &&
▲ {uploadErr}
} {done &&
✓ FOTO ENVIADA
}
); } // ── LEGAL MODAL ─────────────────────────────────────────────── function LegalModal({onClose}){ React.useEffect(()=>{ const onKey=(e)=>{if(e.key==='Escape')onClose()}; window.addEventListener('keydown',onKey); document.body.style.overflow='hidden'; return()=>{window.removeEventListener('keydown',onKey);document.body.style.overflow=''}; },[onClose]); return (
e.stopPropagation()} style={{maxWidth:760,width:'100%',maxHeight:'88vh',overflowY:'auto',background:'#0d0a08',border:'1px solid var(--cas-border)',padding:'40px 48px'}}>
📝 BASES LEGALES

LA PRUEBA · CASCOTERAPIA

Cascoterapia · grupo privado de aficionados al motociclismo, sin personalidad jurídica y sin ánimo de lucro. Actividad lúdica, social y recreativa. Búsqueda de pegatinas en distintos puntos geográficos dentro del plazo establecido. Voluntaria, gratuita y abierta. El participante declara ser mayor de edad (o contar con autorización), participar bajo su propia responsabilidad y aceptar estas bases. No es competición deportiva oficial, ni federativa, ni comercial. Es una dinámica recreativa entre particulares. Carácter simbólico y no comercial (merch / obsequios). La organización puede modificar premios, ajustar el número de ganadores o introducir categorías. Cascoterapia almacena en el servidor los datos de inscripción (nombre, teléfono y código) y las fotos subidas para gestionar la participación, la validación y el ranking. No se publica el teléfono ni el código en abierto y el contenido compartido (imágenes) es responsabilidad del participante. Cada participante es responsable de su seguridad, del cumplimiento del código de circulación y del uso adecuado del vehículo. Cascoterapia queda exenta de responsabilidad por accidentes, sanciones o daños. Comportamiento respetuoso, no poner en riesgo a terceros, respetar espacios públicos y privados. La organización podrá excluir incumplimientos. Participar implica la aceptación íntegra de estas bases. La organización se reserva el derecho de modificar las bases o cancelar la actividad si las circunstancias lo requieren.
🐌 NOTA FINAL
Cascoterapia es, ante todo, un grupo de amigos. Este evento nace para compartir rutas, desconectar y disfrutar del camino.
Sin presión. Sin excusas. Solo ruta.
↓ DESCARGAR PDF
); } function LegalItem({n,t,children}){ return (
{n}. {t}
{children}
); } // ── FOOTER ──────────────────────────────────────────────────── function FooterSection(){ const {openLegal} = React.useContext(StateCtx); return ( ); } window.VariantPoster = VariantPoster;