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
169 changes: 169 additions & 0 deletions app/hackathons/PrizeRulesNote.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(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 (
<>
<p className="mt-4 text-xs text-foreground-subtle leading-relaxed">
* Un premio por persona · si participan menos de {slots.length}{" "}
proyectos, se entregan los premios más chicos primero.{" "}
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
className="underline underline-offset-2 hover:text-foreground-muted transition-colors"
>
Ver condiciones
</button>
</p>

<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/80 backdrop-blur-md"
onClick={() => setOpen(false)}
/>

<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={TITLE_ID}
tabIndex={-1}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="relative w-full max-w-md glass-strong rounded-2xl border border-border-strong overflow-hidden outline-none"
>
<div className="absolute inset-0 bg-gradient-to-br from-bitcoin/10 via-transparent to-nostr/10 pointer-events-none" />
<div className="absolute -top-px left-1/2 -translate-x-1/2 w-[40%] h-px bg-gradient-to-r from-transparent via-bitcoin to-transparent" />

<button
onClick={() => setOpen(false)}
className="absolute top-4 right-4 p-2 rounded-lg text-foreground-muted hover:text-foreground hover:bg-white/5 transition-colors z-10"
aria-label="Cerrar"
>
<X className="h-5 w-5" />
</button>

<div className="relative px-6 pt-8 pb-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-bitcoin/10 text-bitcoin">
<Info className="h-5 w-5" />
</div>
<h2 id={TITLE_ID} className="font-display font-bold text-lg">
Condiciones de los premios
</h2>
</div>

<div className="space-y-4 text-sm text-foreground-muted leading-relaxed">
<p>
Cada hackatón reparte{" "}
{formatSats(PROGRAM.prizePerHackathon)} sats entre los
mejores {slots.length} proyectos, del 1° al {slots.length}
°.
</p>

<div className="space-y-1.5">
<h3 className="text-xs font-mono font-semibold uppercase tracking-widest text-bitcoin">
Un premio por persona
</h3>
<p>
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.
</p>
</div>

<div className="space-y-1.5">
<h3 className="text-xs font-mono font-semibold uppercase tracking-widest text-bitcoin">
Menos proyectos que premios
</h3>
<p>
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.
</p>
<div className="mt-2 rounded-xl border border-border bg-white/[0.02] p-3">
<p className="mb-2 text-[10px] font-mono uppercase tracking-widest text-foreground-subtle">
Ejemplo con 3 proyectos participando
</p>
<ul className="space-y-1 text-xs font-mono">
{example.map((slot, i) => (
<li
key={slot.position}
className="flex items-center justify-between"
>
<span className="text-foreground-muted">
{i + 1}° lugar
</span>
<span className="font-bold tabular-nums">
{formatSats(slot.sats)} sats
</span>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
11 changes: 4 additions & 7 deletions app/hackathons/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
getHackathon,
hackathonSlug,
hackathonStatus,
isHackathonInscriptionOpen,
primaryProjectPubkey,
prizedProjects,
programRules,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -589,11 +588,6 @@ export default async function HackathonPage({
</span>
))}
</div>
{isHackathonInscriptionOpen(hackathon) && (
<div className="mt-6">
<HackathonInscripcionButton hackathonId={hackathon.id} />
</div>
)}
<AdminBadgesLink />
</div>
</div>
Expand Down Expand Up @@ -761,6 +755,9 @@ export default async function HackathonPage({
/>
</div>
)}
<div className="max-w-2xl mx-auto">
<PrizeRulesNote />
</div>
</Card>
</div>
)}
Expand Down
14 changes: 11 additions & 3 deletions app/hackathons/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -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];
Expand All @@ -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;
Expand Down Expand Up @@ -253,6 +259,8 @@ export default async function HackathonsPage() {
Mirá las Community Calls en {PROGRAM.organization} YouTube
</a>
)}

<PrizeRulesNote />
</div>
</div>
</section>
Expand Down
1 change: 1 addition & 0 deletions data/hackathons/hackathons.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}."
Expand Down
7 changes: 6 additions & 1 deletion lib/hackathons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)!;
Expand Down