diff --git a/app/hackathons/[id]/VotingSection.tsx b/app/hackathons/[id]/VotingSection.tsx index d825fd8..0285d71 100644 --- a/app/hackathons/[id]/VotingSection.tsx +++ b/app/hackathons/[id]/VotingSection.tsx @@ -53,6 +53,7 @@ import { fetchAllBallotEvents, publishBallot, subscribeToBallots, + subscribeToJudgesEvent, subscribeToVotingPeriod, } from "@/lib/votingClient"; import LiveTally from "@/components/voting/LiveTally"; @@ -1393,11 +1394,24 @@ function JudgesUpload({ busy: boolean; onUpload: (csv: JudgesCsv) => Promise; }) { + const { pubkeys } = useVotingContext(); const [text, setText] = useState(""); const [parsed, setParsed] = useState(null); const [match, setMatch] = useState(null); const [error, setError] = useState(null); const [saved, setSaved] = useState(null); + // Detect an already-published judges event for this hackathon (e.g. on a + // reload after a previous upload) so we can flag it even without `saved`. + const [judgesPublished, setJudgesPublished] = useState(false); + + useEffect(() => { + if (!pubkeys.publisherPubkey) return; + return subscribeToJudgesEvent( + period.hackathonId, + pubkeys.publisherPubkey, + () => setJudgesPublished(true), + ); + }, [period.hackathonId, pubkeys.publisherPubkey]); const projects = useMemo( () => period.projects.map((p) => ({ id: p.id, name: p.name })), @@ -1450,6 +1464,12 @@ function JudgesUpload({ Votos de jueces (CSV) + {(judgesPublished || saved?.ok) && ( + + + Puntaje de Jueces publicado + + )}

Formato: project,juez1,juez2,…. El promedio de los jueces se @@ -1479,7 +1499,7 @@ function JudgesUpload({ onChange={(e) => apply(e.target.value)} rows={4} spellCheck={false} - placeholder={"project,gorilaltor,gorilatron,claudio\nMi Proyecto,8,7,9"} + placeholder={"project,gorilator,gorilatron,claudio\nMi Proyecto,8,7,9"} className="mt-3 w-full rounded-lg border border-border bg-background/60 px-3 py-2 font-mono text-[11px] text-foreground placeholder:text-foreground-subtle focus:border-nostr/50 focus:outline-none" /> diff --git a/components/voting/VotingHero.tsx b/components/voting/VotingHero.tsx index 6f92f00..5287a31 100644 --- a/components/voting/VotingHero.tsx +++ b/components/voting/VotingHero.tsx @@ -27,6 +27,7 @@ import { } from "@/lib/voting"; import { cn } from "@/lib/cn"; import LiveTally from "@/components/voting/LiveTally"; +import FinalResultsTable from "@/components/voting/FinalResultsTable"; /** * Gamified, live "community voting" hero. Reused on the home page and the @@ -662,6 +663,17 @@ function ClosedHero({ )} )} + + {/* Official combined ranking (70% popular · 30% judges). Present only when + * the admin uploaded judges' scores before closing — the podium above is + * the community vote; this table is the prize-deciding blended result. */} + {period.results?.final && period.results.final.length > 0 && ( + + )} ); } diff --git a/lib/devSeed.ts b/lib/devSeed.ts index b43df84..2699241 100644 --- a/lib/devSeed.ts +++ b/lib/devSeed.ts @@ -71,6 +71,12 @@ const PROJECTS: ProjectSpec[] = [ { id: "dummy-soberano", name: "Soberano", description: "Wallet de identidad + pagos.", repo: "https://github.com/dummy/soberano", hackathon: "identity", author: 7, team: [0] }, { id: "dummy-tienda-zap", name: "TiendaZap", description: "Catálogo y cobros Lightning.", repo: "https://github.com/dummy/tienda-zap", hackathon: "commerce", author: 7 }, { id: "dummy-arcade", name: "SatArcade", description: "Arcade retro pay-per-play.", repo: "https://github.com/dummy/sat-arcade", hackathon: "zaps", author: 7 }, + { id: "dummy-lightning-runner", name: "Lightning Runner", description: "Endless runner que mina sats al correr.", repo: "https://github.com/dummy/lightning-runner", hackathon: "zaps", author: 3, team: [1] }, + { id: "dummy-zap-chess", name: "ZapChess", description: "Ajedrez por dinero: cada jaque mate cobra el bote.", repo: "https://github.com/dummy/zap-chess", hackathon: "zaps", author: 2 }, + { id: "dummy-block-blast", name: "BlockBlast", description: "Battle royale on-chain con leaderboards Nostr.", repo: "https://github.com/dummy/block-blast", hackathon: "zaps", author: 4, team: [5, 3] }, + { id: "dummy-sat-snake", name: "SatSnake", description: "Snake multijugador donde la manzana es un zap.", repo: "https://github.com/dummy/sat-snake", hackathon: "zaps", author: 0 }, + { id: "dummy-pixel-pump", name: "PixelPump", description: "Plataforma de mini-juegos con login Nostr y premios.", repo: "https://github.com/dummy/pixel-pump", hackathon: "zaps", author: 5, team: [6] }, + { id: "dummy-bolt-quest", name: "BoltQuest", description: "RPG por turnos con recompensas Lightning.", repo: "https://github.com/dummy/bolt-quest", hackathon: "zaps", author: 6, team: [7] }, ]; function avatarFor(name: string): string { diff --git a/lib/voting.ts b/lib/voting.ts index ea1e9ba..e55454f 100644 --- a/lib/voting.ts +++ b/lib/voting.ts @@ -118,7 +118,7 @@ export type VotingResults = { export type JudgesDoc = { version: number; hackathonId: string; - /** Judge names in column order, e.g. ["gorilaltor","gorilatron","claudio"]. */ + /** Judge names in column order, e.g. ["gorilator","gorilatron","claudio"]. */ judges: string[]; /** projectId → raw score per judge, aligned to `judges[]`. */ scores: Record; diff --git a/lib/votingClient.ts b/lib/votingClient.ts index 37dd629..92a80ea 100644 --- a/lib/votingClient.ts +++ b/lib/votingClient.ts @@ -9,11 +9,13 @@ import { FAST_USER_RELAYS, DEFAULT_RELAYS } from "./nostrRelayConfig"; import type { SignedEvent, UserSigner } from "./nostrSigner"; import { + JUDGES_KIND, VOTE_ENC, VOTE_T_TAG, VOTING_KIND, VOTING_SCHEMA_VERSION, dedupeBallots, + judgesDTag, parseBallotContent, parseVotingPeriod, voteDTag, @@ -245,6 +247,66 @@ export function subscribeToBallots( }; } +/** + * Live-subscribe to the judges' scores event published by La Crypta. Fires + * `onFound` with the event's created_at whenever a (fresher) one exists — used + * to surface the "judges published" badge. The content stays NIP-44 encrypted; + * only its existence is observable here. + */ +export function subscribeToJudgesEvent( + hackathonId: string, + publisherPubkey: string, + onFound: (eventCreatedAt: number) => void, +): () => void { + let closed = false; + let teardown: (() => void) | null = null; + let freshest = 0; + const dTag = judgesDTag(hackathonId); + const relays = [...FAST_USER_RELAYS]; + + void (async () => { + const { SimplePool } = await import("nostr-tools/pool"); + if (closed) return; + const pool = new SimplePool(); + const closer = pool.subscribe( + relays, + { + kinds: [JUDGES_KIND], + authors: [publisherPubkey], + "#d": [dTag], + }, + { + onevent(ev) { + const event = ev as SignedEvent; + const d = event.tags.find((t) => t[0] === "d")?.[1]; + if (d !== dTag) return; + if (event.pubkey !== publisherPubkey) return; + if (event.created_at <= freshest) return; + freshest = event.created_at; + onFound(event.created_at); + }, + oneose() { + // Keep open so a fresh judges upload flips the badge live. + }, + }, + ); + teardown = () => { + closer.close(); + try { + pool.close(relays); + } catch { + /* noop */ + } + }; + if (closed) teardown(); + })(); + + return () => { + closed = true; + teardown?.(); + }; +} + /** * Live-subscribe to the voting period event published by La Crypta. Calls * `onPeriod` with the freshest valid period whenever one arrives, so open and