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 ( -
  1. - - - {medal(a.position) || ( - - #{a.position} - - )} - -
    -
    - {a.project.name} -
    -
    - - {formatSats(a.prize)} sats - - {a.tied && ( - - empate - - )} -
    -
    - - {recipientPubkey && ( - - )} - {badgeRecipient && prizeBadgeTasks.length > 0 && ( - - )} -
  2. - ); - })} -
- {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 ( +
  1. + + + {medal(a.position) || ( + + #{a.position} + + )} + +
    +
    + {a.project.name} +
    +
    + + {formatSats(a.prize)} sats + + {a.tied && ( + + empate + + )} +
    +
    + + {recipientPubkey && ( + + )} + {badgeRecipient && + prizeBadgeTasks.length > 0 && ( + + )} +
  2. + ); + })} +
+ {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 ( +
  1. + +
    + + {d.date.slice(8, 10)} {hackathon.monthShort} + + {style && ( + + {style.label} + + )} + {d.youtube && ( + + + VIDEO + + )} +
    +
    + {d.title} +
    +

    + {d.description} +

    +
  2. + ); + })} +
+
+ + {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 ( -
  1. - -
    - - {d.date.slice(8, 10)} {hackathon.monthShort} - - {style && ( - - {style.label} - - )} - {d.youtube && ( - - - VIDEO - - )} -
    -
    {d.title}
    -

    - {d.description} -

    -
  2. - ); - })} -
-
- - {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); +}