diff --git a/app/hackathons/HackathonTimeline.tsx b/app/hackathons/HackathonTimeline.tsx
index 65259a7..c6f5436 100644
--- a/app/hackathons/HackathonTimeline.tsx
+++ b/app/hackathons/HackathonTimeline.tsx
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import {
ArrowRight,
@@ -171,8 +172,8 @@ export default function HackathonTimeline({
aria-label={`${h.name} — ${meta.label}`}
className="group relative flex flex-1 flex-col items-center gap-2 rounded-lg pt-[6px] outline-none focus-visible:ring-2 focus-visible:ring-cyan/60"
>
- {/* Dot */}
-
+ {/* Dot — nudged up to sit centered on the rail line above. */}
+
{selected && (
router.push(`/hackathons/${h.slug}`)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") router.push(`/hackathons/${h.slug}`);
+ }}
className={cn(
- "group relative rounded-3xl border bg-background-card",
+ "group relative cursor-pointer rounded-3xl border bg-background-card outline-none focus-visible:ring-2 focus-visible:ring-cyan/60",
accent.ring,
accent.glow,
)}
@@ -543,8 +551,12 @@ function StageCard({ h }: { h: TimelineHackathon }) {
- {/* Compact icon meta — hover any chip to read the detail. */}
-
+ {/* Compact icon meta — hover any chip to read the detail. Stop
+ * propagation so tapping a chip doesn't trigger the card's nav. */}
+
e.stopPropagation()}
+ className="flex flex-wrap items-center gap-2"
+ >
- {/* CTA */}
-
+ {/* CTA — the whole card already navigates on click; stop propagation
+ * here so the inscription button doesn't also trigger that nav. */}
+
e.stopPropagation()}
+ className="flex flex-col items-start gap-3 border-t border-border pt-4 sm:flex-row sm:items-center"
+ >
{h.inscriptionOpen && (
)}
diff --git a/app/hackathons/[id]/HackathonProjectsList.tsx b/app/hackathons/[id]/HackathonProjectsList.tsx
index 3e05b86..763ad3b 100644
--- a/app/hackathons/[id]/HackathonProjectsList.tsx
+++ b/app/hackathons/[id]/HackathonProjectsList.tsx
@@ -46,6 +46,7 @@ import { useHackathonResults, type WinnerEntry } from "@/lib/nostrReports";
import { GithubIcon } from "@/components/BrandIcons";
import { cn } from "@/lib/cn";
import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks";
+import { useHackathonTab } from "@/lib/hackathonTabsContext";
import {
rememberScrollPosition,
restoreScrollPosition,
@@ -112,6 +113,10 @@ export default function HackathonProjectsList({
initialNostrSubmissions?: HackathonSubmission[];
}) {
const router = useRouter();
+ // Set only inside HackathonTabs (the hackathon detail page) — this list lives
+ // in the "Proyectos" tab, so a "project published" toast must switch there
+ // before scrolling to it.
+ const hackathonTab = useHackathonTab();
const [nostrSubmissions, setNostrSubmissions] = useState<
HackathonSubmission[]
>(initialNostrSubmissions);
@@ -337,9 +342,15 @@ export default function HackathonProjectsList({
}>
).detail;
if (hackathonId !== hackathon.id) return;
- sectionRef.current?.scrollIntoView({
- behavior: "smooth",
- block: "start",
+ if (hackathonTab && hackathonTab.tab !== "proyectos") {
+ hackathonTab.setTab("proyectos");
+ }
+ // Defer so the panel is visible (not `display:none`) before measuring.
+ requestAnimationFrame(() => {
+ sectionRef.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
});
if (project) upsertCommunityProject(project);
refreshFromRelays({ manual: true });
@@ -348,7 +359,7 @@ export default function HackathonProjectsList({
return () =>
window.removeEventListener("labs:project-published", onPublished);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [hackathon.id]);
+ }, [hackathon.id, hackathonTab]);
const curated = merged.filter((p) => !p.nostrEventId);
const nostrCount = merged.length - curated.length;
diff --git a/app/hackathons/[id]/HackathonTabs.tsx b/app/hackathons/[id]/HackathonTabs.tsx
new file mode 100644
index 0000000..8c5b4bf
--- /dev/null
+++ b/app/hackathons/[id]/HackathonTabs.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { useState, type ReactNode } from "react";
+import { FolderGit2, Info, Trophy } from "lucide-react";
+import { cn } from "@/lib/cn";
+import { isDevMode } from "@/lib/devMode";
+import {
+ HackathonTabContext,
+ type HackathonTabId,
+} from "@/lib/hackathonTabsContext";
+
+const TABS: { id: HackathonTabId; label: string; icon: typeof FolderGit2 }[] = [
+ { id: "proyectos", label: "Proyectos", icon: FolderGit2 },
+ { id: "resultados", label: "Resultados", icon: Trophy },
+ { id: "datos", label: "Datos", icon: Info },
+];
+
+/**
+ * Splits the hackathon detail page into three panels (Proyectos / Resultados /
+ * Datos), all server-rendered server-side and passed in as slots — only the
+ * active one is visually shown (the others stay mounted but `hidden`, so the
+ * voting subscriptions and project scan inside them don't restart on every tab
+ * switch). Provides `HackathonTabContext` so a CTA inside one panel (e.g. "Ver
+ * resultados" inside Resultados) can switch to another panel before scrolling
+ * to an element that lives there.
+ */
+export default function HackathonTabs({
+ proyectos,
+ resultados,
+ datos,
+ defaultTab = "proyectos",
+ projectsCount,
+ votingOpen,
+}: {
+ proyectos: ReactNode;
+ resultados: ReactNode;
+ datos: ReactNode;
+ defaultTab?: HackathonTabId;
+ projectsCount: number;
+ votingOpen: boolean;
+}) {
+ const [tab, setTab] = useState
(defaultTab);
+
+ return (
+
+
+
+
+ {TABS.map(({ id, label, icon: Icon }) => {
+ const active = tab === id;
+ return (
+
+ );
+ })}
+
+
+
+
+
+ {proyectos}
+
+
+ {resultados}
+
+ {datos}
+
+ );
+}
diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx
index 95e22f2..dd30193 100644
--- a/app/hackathons/[id]/page.tsx
+++ b/app/hackathons/[id]/page.tsx
@@ -50,6 +50,7 @@ import { getCachedNostrProfile } from "@/lib/nostrProfileCache";
import { getCachedVotingPeriod } from "@/lib/votingCache";
import { nostrVotingTag } from "@/lib/nostrCacheTags";
import HackathonProjectsList from "./HackathonProjectsList";
+import HackathonTabs from "./HackathonTabs";
import { VotingProvider, HackathonVotingActions } from "./VotingSection";
import VotingHero from "@/components/voting/VotingHero";
import HackathonResultsClient from "./HackathonResultsClient";
@@ -578,121 +579,6 @@ export default async function HackathonPage({
{hackathon.sponsors && hackathon.sponsors.length > 0 && (
)}
-
- {!resultsPublished && (
-
-
}
- subtitle={`${formatSats(PROGRAM.prizePerHackathon)} sats`}
- >
- {awards.length > 0 ? (
- <>
-
- {awards.map((a) => {
- const recipientPubkey = primaryProjectPubkey(a.project);
- const badgeRecipient = projectBadgeRecipient(
- a.project,
- prizeSoldierRecipients,
- );
- const recipientLightningAddress = recipientPubkey
- ? prizeProfiles.get(recipientPubkey)?.lud16 ?? null
- : null;
- const prizeBadgeTasks: PrizeBadgeTask[] =
- status === "closed" && badgeRecipient
- ? prizeBadgesForPosition(
- prizeBadgeCatalog,
- a.position,
- ).map((badge) => ({
- badge,
- awarded: false,
- }))
- : [];
- return (
- -
-
-
- {medal(a.position) || (
-
- #{a.position}
-
- )}
-
-
-
- {a.project.name}
-
-
-
- {formatSats(a.prize)} sats
-
- {a.tied && (
-
- empate
-
- )}
-
-
-
- {recipientPubkey && (
-
- )}
- {badgeRecipient && prizeBadgeTasks.length > 0 && (
-
- )}
-
- );
- })}
-
- {awards.some((a) => a.tied) && (
-
- * Los premios de posiciones empatadas se dividen en partes
- iguales.
-
- )}
- >
- ) : (
-
-
-
- )}
-
-
- )}
@@ -716,116 +602,247 @@ export default async function HackathonPage({
hackathonName={hackathon.name}
initialPeriod={votingPeriod}
>
-
- }
- />
-
+
+ }
+ resultados={
+
+
+ }
+ />
+
-
+ }
+ subtitle={`${formatSats(PROGRAM.prizePerHackathon)} sats`}
+ >
+ {awards.length > 0 ? (
+ <>
+
+ {awards.map((a) => {
+ const recipientPubkey = primaryProjectPubkey(
+ a.project,
+ );
+ const badgeRecipient = projectBadgeRecipient(
+ a.project,
+ prizeSoldierRecipients,
+ );
+ const recipientLightningAddress = recipientPubkey
+ ? prizeProfiles.get(recipientPubkey)?.lud16 ??
+ null
+ : null;
+ const prizeBadgeTasks: PrizeBadgeTask[] =
+ status === "closed" && badgeRecipient
+ ? prizeBadgesForPosition(
+ prizeBadgeCatalog,
+ a.position,
+ ).map((badge) => ({
+ badge,
+ awarded: false,
+ }))
+ : [];
+ return (
+ -
+
+
+ {medal(a.position) || (
+
+ #{a.position}
+
+ )}
+
+
+
+ {a.project.name}
+
+
+
+ {formatSats(a.prize)} sats
+
+ {a.tied && (
+
+ empate
+
+ )}
+
+
+
+ {recipientPubkey && (
+
+ )}
+ {badgeRecipient &&
+ prizeBadgeTasks.length > 0 && (
+
+ )}
+
+ );
+ })}
+
+ {awards.some((a) => a.tied) && (
+
+ * Los premios de posiciones empatadas se dividen
+ en partes iguales.
+
+ )}
+ >
+ ) : (
+
+
+
+ )}
+
+
+ )}
+
+ }
+ datos={
+
+
+ {/* Dates timeline */}
+
+
}
+ subtitle={`${hackathon.dates.length} community calls`}
+ >
+
+ {hackathon.dates.map((d) => {
+ const style = EVENT_STYLE[d.type];
+ return (
+ -
+
+
+
+ {d.date.slice(8, 10)} {hackathon.monthShort}
+
+ {style && (
+
+ {style.label}
+
+ )}
+ {d.youtube && (
+
+
+ VIDEO
+
+ )}
+
+
+ {d.title}
+
+
+ {d.description}
+
+
+ );
+ })}
+
+
+
+ {hackathon.topics.length > 0 && (
+
}
+ subtitle={`${hackathon.topics.length} áreas`}
+ >
+
+ {hackathon.topics.map((t) => (
+
+ {t}
+
+ ))}
+
+
+ )}
+
+
+ {/* Rules */}
+
+
}
+ subtitle="Reglas básicas"
+ >
+
+ {programRules().map((rule) => (
+ - {rule}
+ ))}
+
+
+
+
+
+ }
/>
-
-
-
- {/* Dates timeline */}
-
-
}
- subtitle={`${hackathon.dates.length} community calls`}
- >
-
- {hackathon.dates.map((d) => {
- const style = EVENT_STYLE[d.type];
- return (
- -
-
-
-
- {d.date.slice(8, 10)} {hackathon.monthShort}
-
- {style && (
-
- {style.label}
-
- )}
- {d.youtube && (
-
-
- VIDEO
-
- )}
-
- {d.title}
-
- {d.description}
-
-
- );
- })}
-
-
-
- {hackathon.topics.length > 0 && (
-
}
- subtitle={`${hackathon.topics.length} áreas`}
- >
-
- {hackathon.topics.map((t) => (
-
- {t}
-
- ))}
-
-
- )}
-
-
- {/* Rules */}
-
-
}
- subtitle="Reglas básicas"
- >
-
- {programRules().map((rule) => (
- - {rule}
- ))}
-
-
-
-
-
-
);
}
diff --git a/app/hackathons/page.tsx b/app/hackathons/page.tsx
index cd4d0d1..88c6f12 100644
--- a/app/hackathons/page.tsx
+++ b/app/hackathons/page.tsx
@@ -130,15 +130,16 @@ export default async function HackathonsPage() {
return (
<>
}
title={
<>
- 8 hackatones ·{" "}
-
- {formatSats(PROGRAM.totalPrize)} sats
- {" "}
- en premios
+
8 hackatones ·
+
+
+
+ {formatSats(PROGRAM.totalPrize)} sats
+ {" "}
+ en premios
+
>
}
/>
diff --git a/app/projects/page.tsx b/app/projects/page.tsx
index 7cadb89..3667238 100644
--- a/app/projects/page.tsx
+++ b/app/projects/page.tsx
@@ -25,7 +25,6 @@ export default async function ProjectsPage() {
de La Crypta.
}
- description="Proyectos grandes y chicos, lanzados y en progreso, todos open source. Construidos por la comunidad de La Crypta en Buenos Aires y más allá."
/>
>
diff --git a/components/voting/VotingHero.tsx b/components/voting/VotingHero.tsx
index a6bb420..b4f03e0 100644
--- a/components/voting/VotingHero.tsx
+++ b/components/voting/VotingHero.tsx
@@ -17,6 +17,7 @@ import {
Vote,
} from "lucide-react";
import { useAuth } from "@/lib/auth";
+import { useHackathonTab } from "@/lib/hackathonTabsContext";
import { hackathonSlugForId, prizeForPosition, formatSats } from "@/lib/hackathons";
import { useVotingLive } from "@/lib/useVotingLive";
import { useAdminLiveTally } from "@/lib/useAdminLiveTally";
@@ -54,6 +55,10 @@ export default function VotingHero({
const { ready } = useAuth();
const live = useVotingLive(hackathonId, initialPeriod);
const { period, viewer } = live;
+ // Set only inside HackathonTabs (the hackathon detail page) — the ballot
+ // (#votar) lives in the "Proyectos" tab, a different panel than this hero's
+ // "Resultados" tab, so the CTA must switch tabs before it can scroll there.
+ const hackathonTab = useHackathonTab();
const slug = hackathonSlugForId(hackathonId);
// On the hackathon page the CTA scrolls to the ballot; on home it links there.
@@ -62,12 +67,19 @@ export default function VotingHero({
const scrollToBallot = useCallback(
(e: React.MouseEvent) => {
if (variant !== "page") return;
- const el = document.getElementById("votar");
- if (!el) return;
e.preventDefault();
- el.scrollIntoView({ behavior: "smooth", block: "start" });
+ if (hackathonTab && hackathonTab.tab !== "proyectos") {
+ hackathonTab.setTab("proyectos");
+ }
+ // Defer to the next frame so the now-visible "Proyectos" panel is laid
+ // out before we measure/scroll to it (it's `display:none` until then).
+ requestAnimationFrame(() => {
+ document
+ .getElementById("votar")
+ ?.scrollIntoView({ behavior: "smooth", block: "start" });
+ });
},
- [variant],
+ [variant, hackathonTab],
);
if (!period) {
diff --git a/data/hackathons/hackathons.json b/data/hackathons/hackathons.json
index 56385dc..f3b4e0f 100644
--- a/data/hackathons/hackathons.json
+++ b/data/hackathons/hackathons.json
@@ -116,49 +116,50 @@
"dates": [
{ "date": "2026-06-02", "day": 2, "type": "apertura", "title": "Community Call — Apertura", "description": "Se abre el formulario de inscripción. Podés anotarte solo o en grupo (máx 4 personas)." },
{ "date": "2026-06-23", "day": 23, "type": "pitch-final", "title": "Community Call — Pitch Final", "description": "Últimos pitches antes de la entrega. Feedback final de mentores." },
- { "date": "2026-06-30", "day": 30, "type": "premios", "title": "Community Call — Entrega de premios", "description": "El jurado AI evalúa los proyectos. Anuncio de ganadores en vivo." }
+ { "date": "2026-06-29", "day": 29, "type": "premios", "title": "Community Call — Entrega de premios", "description": "El jurado AI evalúa los proyectos. Anuncio de ganadores en vivo." }
]
},
{
"id": "media",
- "number": 5,
- "name": "MEDIA",
- "focus": "Decentralized Storage",
- "description": "Blossom, CDN descentralizado, media + Lightning.",
- "difficulty": "Advanced",
- "stars": 3,
- "month": "Julio",
- "monthShort": "JUL",
+ "slug": "gaming-2",
+ "number": 6,
+ "name": "GAMING 2",
+ "focus": "Game Dev · Temporada 2",
+ "description": "Segunda temporada de GAMING: más juegos, mecánicas interactivas, leaderboards y multiplayer sobre Bitcoin, Lightning y Nostr.",
+ "difficulty": "Intermediate",
+ "stars": 2,
+ "month": "Agosto",
+ "monthShort": "AGO",
"year": 2026,
- "icon": "📸",
- "tags": ["Blossom", "CDN", "Media"],
- "topics": ["Blossom Protocol", "Media Hosting", "CDN Decentralizado", "Monetización"],
+ "icon": "🎮",
+ "tags": ["Gaming", "Lightning", "Nostr"],
+ "topics": ["Game Development", "Game Loops", "Nostr Login", "Lightning Rewards", "Leaderboards", "Multiplayer"],
"dates": [
- { "date": "2026-07-07", "day": 7, "type": "apertura", "title": "Community Call — Apertura", "description": "Se abre el formulario de inscripción. Podés anotarte solo o en grupo (máx 4 personas)." },
- { "date": "2026-07-14", "day": 14, "type": "pitch", "title": "Community Call — Pitches", "description": "Presentá tu proyecto en 3 minutos. Recibí feedback de la comunidad y los mentores." },
- { "date": "2026-07-21", "day": 21, "type": "cierre", "title": "Community Call — Cierre inscripciones", "description": "Último día para inscribirte con un proyecto completo." },
- { "date": "2026-07-28", "day": 28, "type": "premios", "title": "Community Call — Entrega de premios", "description": "El jurado AI evalúa los proyectos. Anuncio de ganadores en vivo." }
+ { "date": "2026-08-04", "day": 4, "type": "apertura", "title": "Community Call — Apertura", "description": "Se abre el formulario de inscripción. Podés anotarte solo o en grupo (máx 4 personas)." },
+ { "date": "2026-08-11", "day": 11, "type": "pitch", "title": "Community Call — Pitches", "description": "Presentá tu proyecto en 3 minutos. Recibí feedback de la comunidad y los mentores." },
+ { "date": "2026-08-18", "day": 18, "type": "cierre", "title": "Community Call — Cierre inscripciones", "description": "Último día para inscribirte con un proyecto completo." },
+ { "date": "2026-08-25", "day": 25, "type": "premios", "title": "Community Call — Entrega de premios", "description": "El jurado AI evalúa los proyectos. Anuncio de ganadores en vivo." }
]
},
{
"id": "ai-agents",
- "number": 6,
+ "number": 5,
"name": "AI AGENTS",
"focus": "Bots & Automation",
"description": "Agentes autónomos, workflows, AI + Bitcoin.",
"difficulty": "Advanced",
"stars": 3,
- "month": "Agosto",
- "monthShort": "AGO",
+ "month": "Julio",
+ "monthShort": "JUL",
"year": 2026,
"icon": "🤖",
"tags": ["AI", "Automation", "Bitcoin"],
"topics": ["Agentes Autónomos", "LLM + Lightning", "Workflows", "NWC"],
"dates": [
- { "date": "2026-08-04", "day": 4, "type": "apertura", "title": "Community Call — Apertura", "description": "Se abre el formulario de inscripción. Podés anotarte solo o en grupo (máx 4 personas)." },
- { "date": "2026-08-11", "day": 11, "type": "pitch", "title": "Community Call — Pitches", "description": "Presentá tu proyecto en 3 minutos. Recibí feedback de la comunidad y los mentores." },
- { "date": "2026-08-18", "day": 18, "type": "cierre", "title": "Community Call — Cierre inscripciones", "description": "Último día para inscribirte con un proyecto completo." },
- { "date": "2026-08-25", "day": 25, "type": "premios", "title": "Community Call — Entrega de premios", "description": "El jurado AI evalúa los proyectos. Anuncio de ganadores en vivo." }
+ { "date": "2026-07-07", "day": 7, "type": "apertura", "title": "Community Call — Apertura", "description": "Se abre el formulario de inscripción. Podés anotarte solo o en grupo (máx 4 personas)." },
+ { "date": "2026-07-14", "day": 14, "type": "pitch", "title": "Community Call — Pitches", "description": "Presentá tu proyecto en 3 minutos. Recibí feedback de la comunidad y los mentores." },
+ { "date": "2026-07-21", "day": 21, "type": "cierre", "title": "Community Call — Cierre inscripciones", "description": "Último día para inscribirte con un proyecto completo." },
+ { "date": "2026-07-28", "day": 28, "type": "premios", "title": "Community Call — Entrega de premios", "description": "El jurado AI evalúa los proyectos. Anuncio de ganadores en vivo." }
]
},
{
diff --git a/lib/hackathonTabsContext.tsx b/lib/hackathonTabsContext.tsx
new file mode 100644
index 0000000..8c180da
--- /dev/null
+++ b/lib/hackathonTabsContext.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+/** The three sections the hackathon detail page is split into. */
+export type HackathonTabId = "proyectos" | "resultados" | "datos";
+
+type HackathonTabContextValue = {
+ tab: HackathonTabId;
+ setTab: (tab: HackathonTabId) => void;
+};
+
+/**
+ * Lets components deep inside a tab (e.g. the voting CTA, or the
+ * project-published toast) switch the active tab before scrolling to an
+ * element that lives in a different tab's hidden panel. Null outside
+ * `HackathonTabs` (e.g. `VotingHero` on the home page), so callers must treat
+ * it as optional.
+ */
+export const HackathonTabContext = createContext
(
+ null,
+);
+
+export function useHackathonTab() {
+ return useContext(HackathonTabContext);
+}