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
22 changes: 21 additions & 1 deletion app/hackathons/[id]/VotingSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
fetchAllBallotEvents,
publishBallot,
subscribeToBallots,
subscribeToJudgesEvent,
subscribeToVotingPeriod,
} from "@/lib/votingClient";
import LiveTally from "@/components/voting/LiveTally";
Expand Down Expand Up @@ -1393,11 +1394,24 @@ function JudgesUpload({
busy: boolean;
onUpload: (csv: JudgesCsv) => Promise<JudgesUploadResult | null>;
}) {
const { pubkeys } = useVotingContext();
const [text, setText] = useState("");
const [parsed, setParsed] = useState<JudgesCsv | null>(null);
const [match, setMatch] = useState<JudgesMatch | null>(null);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState<JudgesUploadResult | null>(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 })),
Expand Down Expand Up @@ -1450,6 +1464,12 @@ function JudgesUpload({
<span className="text-[10px] font-mono font-semibold tracking-widest text-nostr uppercase">
Votos de jueces (CSV)
</span>
{(judgesPublished || saved?.ok) && (
<span className="ml-auto inline-flex items-center gap-1 rounded-full border border-success/40 bg-success/10 px-2 py-0.5 text-[9px] font-mono font-bold uppercase tracking-widest text-success">
<CheckCircle2 className="h-3 w-3" />
Puntaje de Jueces publicado
</span>
)}
</div>
<p className="mt-1 text-[11px] font-mono text-foreground-subtle">
Formato: <code>project,juez1,juez2,…</code>. El promedio de los jueces se
Expand Down Expand Up @@ -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"
/>

Expand Down
12 changes: 12 additions & 0 deletions components/voting/VotingHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -662,6 +663,17 @@ function ClosedHero({
)}
</div>
)}

{/* 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 && (
<FinalResultsTable
judges={period.results.judges ?? []}
rows={period.results.final}
hackathonId={hackathonId}
/>
)}
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions lib/devSeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion lib/voting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number[]>;
Expand Down
62 changes: 62 additions & 0 deletions lib/votingClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down