Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions app/hackathons/HackathonTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 */}
<span className="relative flex h-6 w-6 items-center justify-center">
{/* Dot — nudged up to sit centered on the rail line above. */}
<span className="relative -mt-[7px] flex h-6 w-6 items-center justify-center">
{selected && (
<motion.span
layoutId="rail-active"
Expand Down Expand Up @@ -458,6 +459,7 @@ function InfoChip({
}

function StageCard({ h }: { h: TimelineHackathon }) {
const router = useRouter();
const isActive = h.status === "active";
const isClosed = h.status === "closed";

Expand Down Expand Up @@ -496,8 +498,14 @@ function StageCard({ h }: { h: TimelineHackathon }) {

return (
<div
role="link"
tabIndex={0}
onClick={() => 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",
Comment on lines +501 to +508

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keydown bubbles past the stopPropagation guards, causing unintended card navigation from nested controls.

stopPropagation is only applied to onClick on the chip (Line 557) and CTA (Line 623) wrappers, not to keyboard events. A native keydown event fired on any nested focusable element — the InfoChip buttons, the inscription button, or the "Ver detalle" Link — still bubbles up to the outer <div>'s onKeyDown handler at Lines 504-506, which unconditionally calls router.push on Enter. So keyboard users pressing Enter on any nested control (tags tooltip trigger, difficulty chip, "Ver detalle") also trigger the card's own navigation, on top of (or instead of) the nested control's intended action.

🔧 Proposed fix: guard against nested-target key events
     <div
       role="link"
       tabIndex={0}
-      onClick={() => router.push(`/hackathons/${h.slug}`)}
-      onKeyDown={(e) => {
-        if (e.key === "Enter") router.push(`/hackathons/${h.slug}`);
-      }}
+      onClick={(e) => {
+        if (e.target === e.currentTarget) router.push(`/hackathons/${h.slug}`);
+      }}
+      onKeyDown={(e) => {
+        if (e.key === "Enter" && e.target === e.currentTarget) {
+          router.push(`/hackathons/${h.slug}`);
+        }
+      }}

And add onKeyDown={(e) => e.stopPropagation()} alongside the existing onClick={(e) => e.stopPropagation()} on the chip (Line 557) and CTA (Line 623) wrapper divs.

Also applies to: 554-559, 620-625

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/hackathons/HackathonTimeline.tsx` around lines 501 - 508, The outer
card’s keyboard navigation in HackathonTimeline is still receiving Enter key
events from nested interactive controls, causing unintended router.push calls.
Update the chip and CTA wrapper divs that already stop click bubbling to also
stop keyboard bubbling in their onKeyDown handlers, and keep the card-level
onKeyDown in the main card container limited to genuine card focus rather than
nested targets. Use the existing InfoChip wrappers and the “Ver detalle”/CTA
wrapper as the places to add the propagation guard.

accent.ring,
accent.glow,
)}
Expand Down Expand Up @@ -543,8 +551,12 @@ function StageCard({ h }: { h: TimelineHackathon }) {
</p>
</div>

{/* Compact icon meta — hover any chip to read the detail. */}
<div className="flex flex-wrap items-center gap-2">
{/* Compact icon meta — hover any chip to read the detail. Stop
* propagation so tapping a chip doesn't trigger the card's nav. */}
<div
onClick={(e) => e.stopPropagation()}
className="flex flex-wrap items-center gap-2"
>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-[11px] font-mono font-semibold uppercase tracking-wider",
Expand Down Expand Up @@ -605,8 +617,12 @@ function StageCard({ h }: { h: TimelineHackathon }) {
)}
</div>

{/* CTA */}
<div className="flex flex-col items-start gap-3 border-t border-border pt-4 sm:flex-row sm:items-center">
{/* CTA — the whole card already navigates on click; stop propagation
* here so the inscription button doesn't also trigger that nav. */}
<div
onClick={(e) => e.stopPropagation()}
className="flex flex-col items-start gap-3 border-t border-border pt-4 sm:flex-row sm:items-center"
>
{h.inscriptionOpen && (
<HackathonInscripcionButton hackathonId={h.id} />
)}
Expand Down
19 changes: 15 additions & 4 deletions app/hackathons/[id]/HackathonProjectsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand All @@ -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;
Expand Down
105 changes: 105 additions & 0 deletions app/hackathons/[id]/HackathonTabs.tsx
Original file line number Diff line number Diff line change
@@ -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<HackathonTabId>(defaultTab);

return (
<HackathonTabContext.Provider value={{ tab, setTab }}>
<div
className={cn(
"sticky z-20 border-y border-border bg-background/90 backdrop-blur-sm",
// Stick right below the fixed navbar — which itself sits lower when
// the DEV MODE bar (32px) is present (see Navbar.tsx).
isDevMode() ? "top-24" : "top-16",
)}
>
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div
role="tablist"
aria-label="Secciones del hackatón"
className="no-scrollbar flex items-center gap-1 overflow-x-auto py-2.5"
>
{TABS.map(({ id, label, icon: Icon }) => {
const active = tab === id;
return (
<button
key={id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setTab(id)}
className={cn(
"relative inline-flex shrink-0 items-center gap-1.5 rounded-xl px-4 py-2 text-xs font-mono font-bold uppercase tracking-widest transition-colors",
active
? "bg-white/[0.07] text-foreground"
: "text-foreground-muted hover:bg-white/[0.03] hover:text-foreground",
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
{id === "proyectos" && projectsCount > 0 && (
<span className="text-foreground-subtle">
{projectsCount}
</span>
)}
{id === "resultados" && votingOpen && (
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success/70" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-success" />
</span>
)}
</button>
);
})}
</div>
</div>
</div>

<div className={cn(tab === "proyectos" ? "block" : "hidden")}>
{proyectos}
</div>
<div className={cn(tab === "resultados" ? "block" : "hidden")}>
{resultados}
</div>
<div className={cn(tab === "datos" ? "block" : "hidden")}>{datos}</div>
</HackathonTabContext.Provider>
);
}
Loading