diff --git a/app/hackathons/PrizeRulesNote.tsx b/app/hackathons/PrizeRulesNote.tsx new file mode 100644 index 0000000..7644308 --- /dev/null +++ b/app/hackathons/PrizeRulesNote.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Info, X } from "lucide-react"; +import { useScrollLock } from "@/lib/useScrollLock"; +import { PROGRAM, formatSats } from "@/lib/hackathons"; + +const TITLE_ID = "prize-rules-modal-title"; + +/** + * Fine print + modal covering the two prize conditions: one prize per person + * (a participant with several projects only wins with their best-ranked one) + * and the scarcity rule (fewer participating projects than prize slots → the + * smallest prizes go out first, with a worked example). Reused under the + * `/hackathons` prize grid and each hackathon's "Premios" results card. Kept + * as its own client component so the server pages stay server components. + */ +export default function PrizeRulesNote() { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const dialogRef = useRef(null); + useScrollLock(open); + + useEffect(() => { + if (!open) return; + // Move focus into the dialog on open, and restore it to the trigger on + // close so keyboard users don't lose their place behind the overlay. + const trigger = triggerRef.current; + dialogRef.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + trigger?.focus(); + }; + }, [open]); + + const slots = [...PROGRAM.prizeDistribution].sort( + (a, b) => a.position - b.position, + ); + const example = slots.slice(-3); + + return ( + <> +

+ * Un premio por persona · si participan menos de {slots.length}{" "} + proyectos, se entregan los premios más chicos primero.{" "} + +

+ + + {open && ( + + setOpen(false)} + /> + + +
+
+ + + +
+
+
+ +
+

+ Condiciones de los premios +

+
+ +
+

+ Cada hackatón reparte{" "} + {formatSats(PROGRAM.prizePerHackathon)} sats entre los + mejores {slots.length} proyectos, del 1° al {slots.length} + °. +

+ +
+

+ Un premio por persona +

+

+ Cada persona puede ganar un solo premio. Si participás con + varios proyectos, solo se premia el mejor ranqueado; los + demás quedan fuera del reparto. +

+
+ +
+

+ Menos proyectos que premios +

+

+ Si participan menos de {slots.length} proyectos, los + premios más chicos se entregan primero — así nadie se + lleva de más y no queda parte del pozo sin asignar. +

+
+

+ Ejemplo con 3 proyectos participando +

+
    + {example.map((slot, i) => ( +
  • + + {i + 1}° lugar + + + {formatSats(slot.sats)} sats + +
  • + ))} +
+
+
+
+
+ + + )} + + + ); +} diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index 50bec0f..5fbdcc5 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -30,7 +30,6 @@ import { getHackathon, hackathonSlug, hackathonStatus, - isHackathonInscriptionOpen, primaryProjectPubkey, prizedProjects, programRules, @@ -61,10 +60,10 @@ import HackathonTabs from "./HackathonTabs"; import { VotingProvider, HackathonVotingActions } from "./VotingSection"; import VotingHero from "@/components/voting/VotingHero"; import HackathonResultsClient from "./HackathonResultsClient"; +import PrizeRulesNote from "@/app/hackathons/PrizeRulesNote"; import AdminBadgesLink from "./AdminBadgesLink"; import PrizeBadgeButton, { type PrizeBadgeTask } from "./PrizeBadgeButton"; import PrizeZapButton from "@/components/voting/PrizeZapButton"; -import HackathonInscripcionButton from "@/components/HackathonInscripcionButton"; export function generateStaticParams() { // The dynamic segment is the public slug (falls back to id). @@ -589,11 +588,6 @@ export default async function HackathonPage({ ))}
- {isHackathonInscriptionOpen(hackathon) && ( -
- -
- )}
@@ -761,6 +755,9 @@ export default async function HackathonPage({ /> )} +
+ +
)} diff --git a/app/hackathons/page.tsx b/app/hackathons/page.tsx index cd7c51e..3dce1d9 100644 --- a/app/hackathons/page.tsx +++ b/app/hackathons/page.tsx @@ -20,6 +20,7 @@ import HackathonTimeline, { type TimelineHackathon, } from "@/app/hackathons/HackathonTimeline"; import PilaresDisclosure from "@/app/hackathons/PilaresDisclosure"; +import PrizeRulesNote from "@/app/hackathons/PrizeRulesNote"; const DESCRIPTION = "Lightning Hackathons 2026 — 8 hackatones mensuales, 8M sats en premios. Bitcoin, Lightning, Nostr."; @@ -119,8 +120,13 @@ function buildTimeline(now: Date): { const initialIndex = activeIdx >= 0 ? activeIdx : firstUpcomingIdx >= 0 ? firstUpcomingIdx : n - 1; - // Elapsed fraction: closed nodes count as fully done; the active one is - // interpolated across its own date span. Each node owns a 1/n slice. + // Elapsed fraction: node dots sit at the CENTER of each 1/n slice + // (`(i + 0.5)/n`), so the marker must too. While a hackathon is active the + // marker travels from its own dot toward the next one as its date span + // elapses (f: 0→1 maps activeIdx's center → the next node's center), keeping + // the "today" pip on the live hackathon instead of lagging a half-slice + // behind it. Between hackathons it rests at the midpoint of the two dots, + // which `closedCount/n` already yields (a cell boundary == a dot midpoint). let todayPct: number; if (activeIdx >= 0) { const h = ordered[activeIdx]; @@ -130,7 +136,7 @@ function buildTimeline(now: Date): { const span = new Date(last).getTime() - new Date(first).getTime(); const into = new Date(today).getTime() - new Date(first).getTime(); const f = span > 0 ? Math.min(1, Math.max(0, into / span)) : 0.5; - todayPct = ((activeIdx + f) / n) * 100; + todayPct = ((activeIdx + 0.5 + f) / n) * 100; } else { const closedCount = items.filter((x) => x.status === "closed").length; todayPct = (closedCount / n) * 100; @@ -253,6 +259,8 @@ export default async function HackathonsPage() { Mirá las Community Calls en {PROGRAM.organization} YouTube )} + + diff --git a/data/hackathons/hackathons.json b/data/hackathons/hackathons.json index f3b4e0f..f63619c 100644 --- a/data/hackathons/hackathons.json +++ b/data/hackathons/hackathons.json @@ -21,6 +21,7 @@ ], "rules": [ "Hasta {maxTeamSize} personas por equipo.", + "Podés sumar varios proyectos, pero solo uno por persona puede ganar premio.", "Inscripción abierta al primer sábado, cierre al tercero.", "Open source recomendado — sumá puntos por licencia.", "Evaluación por jurado AI: {judges}." diff --git a/lib/hackathons.ts b/lib/hackathons.ts index de8c569..b1aca71 100644 --- a/lib/hackathons.ts +++ b/lib/hackathons.ts @@ -427,6 +427,11 @@ export type PrizedProject = { * Walks the ranking and distributes the program's prize slots in order. * Ties share the combined value of the consecutive slots they occupy * (e.g. a tie at 1st consumes slots 1 and 2 and splits 400k+250k = 325k each). + * + * When fewer projects participate than there are prize slots, the smallest + * prizes go out first: with only 3 ranked projects out of 6 slots, 1st takes + * slot 4, 2nd takes slot 5, 3rd takes slot 6 (100k/60k/40k) rather than + * claiming the top slots and leaving the rest of the pool unawarded. */ export function prizedProjects(hackathonId: string): PrizedProject[] { const ranked = rankedProjects(hackathonId).filter( @@ -444,7 +449,7 @@ export function prizedProjects(hackathonId: string): PrizedProject[] { const positions = [...byPosition.keys()].sort((a, b) => a - b); const slots = PROGRAM.prizeDistribution; const result: PrizedProject[] = []; - let slotIdx = 0; + let slotIdx = Math.max(0, slots.length - ranked.length); for (const pos of positions) { const group = byPosition.get(pos)!;