// app.jsx — CDG main app
const { useState, useEffect, useRef } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#ff3d00",
"accent2": "#ffaa00",
"speed": 1,
"displayFont": "Space Grotesk",
"heroVariant": "split",
"palette": "fire"
}/*EDITMODE-END*/;
const PALETTES = {
fire: { accent: "#ff3d00", accent2: "#ffaa00", glow: "rgba(255,61,0,0.55)" },
acid: { accent: "#c6ff00", accent2: "#00e5ff", glow: "rgba(198,255,0,0.55)" },
magma: { accent: "#ff1744", accent2: "#ff6e40", glow: "rgba(255,23,68,0.55)" },
voltage: { accent: "#7c4dff", accent2: "#ff4081", glow: "rgba(124,77,255,0.55)" }
};
const FONT_PAIRS = {
"Space Grotesk": "'Space Grotesk', system-ui, sans-serif",
"Archivo Black": "'Archivo Black', system-ui, sans-serif",
"Bebas Neue": "'Bebas Neue', system-ui, sans-serif",
"Syne": "'Syne', system-ui, sans-serif"
};
// ----- Countdown -----
function useCountdown(target) {
const [t, setT] = useState(() => calc(target));
useEffect(() => {
const id = setInterval(() => setT(calc(target)), 1000);
return () => clearInterval(id);
}, [target]);
return t;
}
function calc(target) {
const diff = Math.max(0, target - Date.now());
const d = Math.floor(diff / 86400000);
const h = Math.floor((diff / 3600000) % 24);
const m = Math.floor((diff / 60000) % 60);
const s = Math.floor((diff / 1000) % 60);
return { d, h, m, s };
}
const pad = n => String(n).padStart(2, "0");
// ----- Reveal on scroll -----
function useReveal() {
useEffect(() => {
const els = document.querySelectorAll(".reveal");
// Mark as pending so they fade in on scroll
els.forEach(el => {
const rect = el.getBoundingClientRect();
// Only hide elements not yet on screen
if (rect.top > window.innerHeight * 0.9) el.classList.add("pending");
else el.classList.add("visible");
});
const obs = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
e.target.classList.remove("pending");
e.target.classList.add("visible");
}
});
}, { threshold: 0.05, rootMargin: "0px 0px -10% 0px" });
els.forEach(el => obs.observe(el));
return () => obs.disconnect();
}, []);
}
// ----- Parallax -----
function useParallax() {
useEffect(() => {
const onScroll = () => {
const y = window.scrollY;
document.querySelectorAll("[data-parallax]").forEach(el => {
const r = parseFloat(el.dataset.parallax);
el.style.transform = `translateY(${y * r}px)`;
});
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
}
// ----- DATA -----
// Calendar oficial CDG · Temporada 2026 (Tiny + Open 5")
// status se calcula automáticamente desde la fecha (no editar manualmente)
const RACES_RAW = [
{ num: "01", date: "2026-02-22", day: "21–22", month: "FEB", name: "Tinywhoop · 1ra fecha", sub: "1ra fecha · Inicio de temporada", loc: "Guatemala City", cat: "TINY", tag: null },
{ num: "02", date: "2026-02-22", day: "21–22", month: "FEB", name: "Gemfan LATAM Guatemala", sub: "Open 5\" · 1ra fecha", loc: "Guatemala City", cat: "5\"", tag: "LATAM" },
{ num: "03", date: "2026-04-24", day: "24", month: "ABR", name: "Tinywhoop · 2da fecha", sub: "Categoría Tinywhoop", loc: "Guatemala City", cat: "TINY", tag: null },
{ num: "04", date: "2026-04-25", day: "25", month: "ABR", name: "Open 5\" · 2da fecha", sub: "Categoría Open 5\"", loc: "Guatemala City", cat: "5\"", tag: null },
{ num: "05", date: "2026-05-15", day: "15", month: "MAY", name: "Tinywhoop · 3ra fecha", sub: "Categoría Tinywhoop", loc: "Por confirmar", cat: "TINY", tag: null },
{ num: "06", date: "2026-05-16", day: "16", month: "MAY", name: "Open 5\" · 3ra fecha", sub: "MultiGP Regional Qualifier", loc: "Por confirmar", cat: "5\"", tag: "RQ" },
{ num: "07", date: "2026-05-29", day: "29", month: "MAY", name: "Tinywhoop · 4ta fecha", sub: "Categoría Tinywhoop", loc: "Por confirmar", cat: "TINY", tag: null },
{ num: "08", date: "2026-05-30", day: "30", month: "MAY", name: "Open 5\" · 4ta fecha", sub: "Global Qualifier", loc: "Por confirmar", cat: "5\"", tag: "GQ" },
{ num: "09", date: "2026-07-03", day: "03", month: "JUL", name: "Tinywhoop · 5ta fecha", sub: "Categoría Tinywhoop", loc: "Por confirmar", cat: "TINY", tag: null },
{ num: "10", date: "2026-07-04", day: "04", month: "JUL", name: "Open 5\" · 5ta fecha", sub: "Global Qualifier", loc: "Por confirmar", cat: "5\"", tag: "GQ" },
{ num: "11", date: "2026-09-11", day: "11", month: "SEP", name: "Tinywhoop · 6ta fecha", sub: "Categoría Tinywhoop", loc: "Por confirmar", cat: "TINY", tag: null },
{ num: "12", date: "2026-09-12", day: "12", month: "SEP", name: "Open 5\" · 6ta fecha", sub: "Categoría Open 5\"", loc: "Por confirmar", cat: "5\"", tag: null },
{ num: "13", date: "2026-11-13", day: "13", month: "NOV", name: "Tinywhoop · 7ma fecha", sub: "Final Tinywhoop", loc: "Por confirmar", cat: "TINY", tag: null },
{ num: "14", date: "2026-11-14", day: "14", month: "NOV", name: "Open 5\" · 7ma fecha", sub: "Final Open 5\"", loc: "Por confirmar", cat: "5\"", tag: null }
];
// Auto-derive status (done / live / upcoming) from today's date AND the real race hours
// Tiny corre 5:00–10:00 PM · 5" corre 8:00 AM–6:00 PM (hora Guatemala, GT-6).
const TODAY = new Date();
const TIME_BY_CAT = { "TINY": "5:00 – 10:00 PM", "5\"": "8:00 AM – 6:00 PM", "AMBAS": "Todo el día" };
const HOURS_BY_CAT = { "TINY": { start: 17, end: 22 }, "5\"": { start: 8, end: 18 }, "AMBAS": { start: 8, end: 22 } };
// Sede por defecto cuando aún no se confirma: 5" → Florencia · Tiny → VIP Location (rotativa)
const DEFAULT_VENUE = { "5\"": "Parque Ecológico Florencia", "TINY": "VIP Location · sede rotativa", "AMBAS": "Centro Español" };
const pad2 = n => String(n).padStart(2, "0");
const RACES = RACES_RAW.map(r => {
const hrs = HOURS_BY_CAT[r.cat] || { start: 8, end: 18 };
const startTs = new Date(`${r.date}T${pad2(hrs.start)}:00:00-06:00`).getTime();
const endTs = new Date(`${r.date}T${pad2(hrs.end)}:00:00-06:00`).getTime();
const now = TODAY.getTime();
let status, label;
if (now < startTs) { status = "upcoming"; label = r.sub?.startsWith("Final") ? "Final" : "Próxima"; }
else if (now <= endTs) { status = "live"; label = "En vivo"; }
else { status = "done"; label = "Completada"; }
const loc = (r.loc === "Por confirmar") ? (DEFAULT_VENUE[r.cat] || r.loc) : r.loc;
return { ...r, loc, status, label, time: TIME_BY_CAT[r.cat] || "Por confirmar", timestamp: startTs, startTs, endTs };
});
// Next upcoming race for hero countdown
function nextUpcomingRace() {
return RACES.find(r => r.status === "upcoming") || RACES[RACES.length - 1];
}
// ¿Hay una fecha corriendo ahora mismo? (status auto = "live", o forzado desde config.liveOverride)
function getActiveRace() {
const cfg = (typeof window !== "undefined" && window.CDG_CONFIG) || {};
const live = RACES.find(r => r.status === "live");
if (live) return { race: live, cfg };
if (cfg.liveOverride) return { race: nextUpcomingRace(), cfg };
return null;
}
// Mapea una carrera del calendario a su entrada de resultados (para deep-link)
function resultForRace(race) {
const list = (typeof window !== "undefined" && window.CDG_RESULTS) || [];
return list.find(r => r.id && r.id.startsWith(race.date) && r.category === race.cat) || null;
}
const VENUES = {
tiny: [
{ name: "C.C. Tikal Futura", loc: "Calz. Roosevelt, Zona 11 · Ciudad de Guatemala", type: "Indoor", note: "Explanada comercial bajo techo.", maps: "Tikal Futura, Calzada Roosevelt, Guatemala" },
{ name: "Centro Español", loc: "Ciudad de Guatemala", type: "Ambas", note: "Club deportivo y social · sede híbrida (Tiny + 5\").", maps: "Centro Español de Guatemala" },
{ name: "Design Center · Sótano", loc: "Blvd. Los Próceres, Zona 10 · Ciudad de Guatemala", type: "Indoor", note: "Pista técnica en niveles de parqueo.", maps: "Design Center, Boulevard Los Próceres, Zona 10, Guatemala" }
],
open: [
{ name: "Parque Ecológico Florencia", loc: "Santa Lucía Milpas Altas, Sacatepéquez", type: "Outdoor", note: "Reserva boscosa · Km 35 ruta a Antigua.", maps: "Parque Ecológico Florencia, Santa Lucía Milpas Altas, Sacatepéquez" },
{ name: "Estadio Carlos Enrique Cuc Cuevas", loc: "Santa Lucía Milpas Altas, Sacatepéquez", type: "Outdoor", note: "Estadio municipal.", maps: "Estadio Carlos Enrique Cuc Cuevas, Santa Lucía Milpas Altas" },
{ name: "Finca Cienaguilla", loc: "Guatemala", type: "Outdoor", note: "Circuito en finca privada.", url: "https://www.instagram.com/fincacienaguillagt/", link: "ig", maps: "Finca Cienaguilla, Guatemala" },
{ name: "Parque Erick Barrondo", loc: "28 av., Zona 7 · Ciudad de Guatemala", type: "Outdoor", note: "El parque más grande de la capital.", maps: "Parque Erick Barrondo, Zona 7, Guatemala" },
{ name: "Tribu Terrace", loc: "Ciudad de Guatemala", type: "Outdoor", note: "Terraza rooftop para eventos.", url: "https://tributerrace.com", link: "web", maps: "Tribu Terrace, Guatemala" },
{ name: "Centro Español", loc: "Ciudad de Guatemala", type: "Ambas", note: "Club deportivo y social · sede híbrida (Tiny + 5\").", maps: "Centro Español de Guatemala" }
]
};
const mapsUrl = q => `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(q)}`;
const VIDEOS = [
{ title: "Highlights · Volcán Pacaya", sub: "RD 02 · 4 min", live: true, duration: "LIVE" },
{ title: "POV · Vuelta rápida", sub: "Lap récord", live: false, duration: "2:14" },
{ title: "Crash compilation", sub: "Temporada 2026", live: false, duration: "3:48" },
{ title: "Tras bambalinas", sub: "CDG · 502", live: false, duration: "5:21" },
{ title: "Tutorial: Setup 5\"", sub: "Guía CDG", live: false, duration: "8:02" }
];
const SPONSORS = [
{ n: "Expression", s: "Patrocinador oficial" },
{ n: "Herbaclinic", s: "Patrocinador oficial" },
{ n: "Schatten", s: "Patrocinador oficial" },
{ n: "BetaFPV", s: "Tinywhoop" },
{ n: "Gemfan", s: "Hélices" }
];
// ----- DIRECTORIO DE PILOTOS -----
// Mapa central: nombre en results.js → { ig, country }
// ig = solo el usuario (sin @, sin link). country = código ISO 2 letras (GT, SV, MX…)
// Pilotos sin ig todavía → se muestran sin link. Pilotos sin country → sin bandera.
// Directorio central de pilotos → data/pilots.js (window.CDG_PILOTS)
const PILOT_DIR = (typeof window !== "undefined" && window.CDG_PILOTS) ? window.CDG_PILOTS : {};
// Lookup tolerante a mayúsculas/espacios
const _PILOTS_NORM = Object.fromEntries(
Object.entries(PILOT_DIR).map(([k, v]) => [k.toLowerCase().trim(), v])
);
function pilotMeta(name) {
if (!name) return {};
return _PILOTS_NORM[name.toLowerCase().trim()] || {};
}
// Código ISO 2 letras → URL de imagen de bandera (se ve igual en todos los SO,
// a diferencia del emoji que en Windows aparece como letras)
function flagUrl(code) {
if (!code || code.length !== 2) return "";
return `https://flagcdn.com/${code.toLowerCase()}.svg`;
}
// ----- HERO -----
function Hero({ t }) {
// ¿Estamos corriendo? → la portada se convierte en un AVISO en vivo
const activeInfo = getActiveRace();
const next = nextUpcomingRace();
const target = next.timestamp;
const c = useCountdown(target);
const countdownLabel = `${next.num} · ${next.name.toUpperCase()} · ${next.day} ${next.month}`;
// Datos del aviso "en vivo"
let live = null;
if (activeInfo) {
const r = activeInfo.race;
const cfg = activeInfo.cfg;
const venue = (cfg.liveVenue && cfg.liveVenue.trim()) || r.loc || "Sede por confirmar";
live = {
name: r.name,
cat: r.cat === "TINY" ? "Tinywhoop" : (r.cat === "5\"" ? "Open 5\"" : "Tiny + Open 5\""),
date: `${r.day} ${r.month}`,
time: r.time,
venue,
note: (cfg.liveNote && cfg.liveNote.trim()) || ""
};
}
return (
Tercera temporada del Campeonato Drones Guatemala. Catorce fechas, dos categorías, un solo objetivo:
llevar el FPV racing centroamericano a los volcanes, las selvas y las pistas más rápidas del 502.
Quads de competición sin restricciones de motor, hasta 6S. Micro drones indoor, 1S–2S. Pista técnica y multinivel.
{live
? <>EstamosCorriendo>
: <>AgendaAnual /26>}
La velocidad
tiene nuevo
código de área.5" Open
Tinywhoop
Los trazados cambian en cada fecha — estas son las sedes donde corre el campeonato.
Esta localidad cambia cada fecha. Confirmamos la ubicación exacta a través de nuestras redes — escríbenos y te pasamos el punto exacto de cada evento.
La clasificación general de la temporada, fecha a fecha. Mira el podio, los movimientos y la tabla completa.
Estamos preparando los mejores POV, highlights y crashes de la temporada. Vuelve pronto — esta sección se llena con material de cada fecha.
Míralo en Instagram →data/config.jsCupos limitados por categoría. La inscripción es por fecha e incluye placa de competidor, telemetría, pase de pits para 2 personas y kit oficial CDG·26.