From e99a8fb2b07d5556e6b16146c6ff05226d91a559 Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Thu, 25 Jun 2026 17:01:27 -0300 Subject: [PATCH] community voting notifications and controls --- app/api/hackathons/[id]/voting/route.ts | 289 +++++++- app/hackathons/[id]/HackathonProjectsList.tsx | 250 +++---- app/hackathons/[id]/VotingSection.tsx | 617 +++++++++++------- app/hackathons/[id]/page.tsx | 28 +- app/hackathons/page.tsx | 5 +- lib/hackathons.ts | 33 + 6 files changed, 859 insertions(+), 363 deletions(-) diff --git a/app/api/hackathons/[id]/voting/route.ts b/app/api/hackathons/[id]/voting/route.ts index 090e572..93e74c8 100644 --- a/app/api/hackathons/[id]/voting/route.ts +++ b/app/api/hackathons/[id]/voting/route.ts @@ -4,8 +4,10 @@ import type { SignedEvent } from "@/lib/nostrSigner"; import { getSoldiers } from "@/lib/soldiers"; import { getHackathon, + hackathonSlug, mergeWithSubmissions, primaryProjectPubkey, + type Hackathon, type HackathonSubmission, } from "@/lib/hackathons"; import { getNostrHackathonSubmissions } from "@/lib/nostrCache"; @@ -50,6 +52,47 @@ const CLOSE_ACTIONS = new Set([ CLOSE_PREVIEW_ACTION, CLOSE_CONFIRM_ACTION, ]); +const PROFILE_KIND = 0; +const PROFILE_SOURCE_NPUB = + "npub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnljdghs2q8qrqtt9u68"; +const PROFILE_DISPLAY_NAME = "La Crypta Dev"; +const PROFILE_SOURCE_FALLBACK_RELAYS = [ + "wss://purplepag.es", + "wss://relay.nostr.band", + "wss://relay.snort.social", + "wss://nostr.wine", +] as const; +const VOTING_DM_KIND = 4; +const VOTING_DM_TAG = "lacrypta-dev-voting-notice"; + +type RelayPublishResult = { relay: string; ok: boolean; error?: string }; + +type VotingStartNotificationResult = { + recipientPubkey: string; + recipientName: string; + eventId: string | null; + ok: boolean; + relays: RelayPublishResult[]; + error?: string; +}; + +type VotingStartNotificationSummary = { + attempted: number; + delivered: number; + failed: number; + senderProfile: SenderProfileResult; + results: VotingStartNotificationResult[]; +}; + +type SenderProfileResult = { + pubkey: string; + existed: boolean; + sourcePubkey: string; + sourceFound: boolean; + eventId: string | null; + relays: RelayPublishResult[]; + error?: string; +}; function jsonError(message: string, status = 400) { return NextResponse.json({ error: message }, { status }); @@ -85,7 +128,7 @@ async function publishToRelays( signed: SignedEvent, relays: string[], perRelayTimeoutMs = 8000, -): Promise<{ relay: string; ok: boolean; error?: string }[]> { +): Promise { const { SimplePool } = await import("nostr-tools/pool"); const pool = new SimplePool(); const promises = pool.publish(relays, signed); @@ -116,6 +159,228 @@ async function publishToRelays( return results; } +function parseProfileContent(content: string): Record | null { + try { + const parsed = JSON.parse(content); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + +async function fetchLatestProfileEvent( + pubkey: string, + relays: string[] = DEFAULT_RELAYS, + timeoutMs = 3500, +): Promise { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + let latest: SignedEvent | null = null; + + const closer = pool.subscribe( + relays, + { kinds: [PROFILE_KIND], authors: [pubkey], limit: 1 }, + { + onevent(ev) { + const event = ev as SignedEvent; + if (!parseProfileContent(event.content)) return; + if (!latest || event.created_at > latest.created_at) latest = event; + }, + oneose() { + /* timeout-driven */ + }, + }, + ); + + await new Promise((r) => setTimeout(r, timeoutMs)); + try { + closer.close(); + } catch { + /* noop */ + } + try { + pool.close(relays); + } catch { + /* noop */ + } + return latest; +} + +async function profileSourcePubkey(): Promise { + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(PROFILE_SOURCE_NPUB); + if (decoded.type !== "npub") { + throw new Error("PROFILE_SOURCE_NPUB invalido."); + } + return decoded.data as string; +} + +function sourceProfileRelays(): string[] { + return [...new Set([...DEFAULT_RELAYS, ...PROFILE_SOURCE_FALLBACK_RELAYS])]; +} + +async function ensureSenderProfile( + secret: Uint8Array, + createdAt: number, +): Promise { + const { finalizeEvent, getPublicKey } = await import("nostr-tools/pure"); + const pubkey = getPublicKey(secret); + const sourcePubkey = await profileSourcePubkey(); + const existing = await fetchLatestProfileEvent(pubkey); + if (existing) { + return { + pubkey, + existed: true, + sourcePubkey, + sourceFound: false, + eventId: existing.id, + relays: [], + }; + } + + const source = await fetchLatestProfileEvent( + sourcePubkey, + sourceProfileRelays(), + 6000, + ); + const cloned = parseProfileContent(source?.content ?? "") ?? {}; + const signed = finalizeEvent( + { + kind: PROFILE_KIND, + created_at: createdAt, + tags: [["client", "La Crypta Dev"]], + content: JSON.stringify({ + ...cloned, + name: PROFILE_DISPLAY_NAME, + display_name: PROFILE_DISPLAY_NAME, + displayName: PROFILE_DISPLAY_NAME, + }), + }, + secret, + ) as SignedEvent; + const relays = await publishToRelays(signed, DEFAULT_RELAYS); + return { + pubkey, + existed: false, + sourcePubkey, + sourceFound: !!source, + eventId: signed.id, + relays, + }; +} + +function siteBaseUrl(req: Request): string { + try { + return new URL(req.url).origin.replace(/\/+$/u, ""); + } catch { + /* fallback below */ + } + return ( + process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/u, "") || + "https://lacrypta.dev" + ); +} + +function votingUrlForHackathon(hackathon: Hackathon, req: Request): string { + return `${siteBaseUrl(req)}/hackathons/${hackathonSlug(hackathon)}#votar`; +} + +function votingStartMessage( + hackathonName: string, + voter: VotingEligibleVoter, + votingUrl: string, +): string { + const voteWord = voter.maxVotes === 1 ? "voto" : "votos"; + return [ + voter.name ? `Hola ${voter.name}.` : "Hola.", + `Ya está abierta la votación comunitaria de ${hackathonName}.`, + `Tenés ${voter.maxVotes} ${voteWord} disponibles para repartir entre los proyectos habilitados.`, + `Entrá a votar acá: ${votingUrl}`, + "La Crypta Dev", + ].join("\n\n"); +} + +async function publishVotingStartNotifications({ + secret, + period, + hackathonName, + votingUrl, + votingEventId, + createdAt, +}: { + secret: Uint8Array; + period: VotingPeriod; + hackathonName: string; + votingUrl: string; + votingEventId: string; + createdAt: number; +}): Promise { + const { finalizeEvent } = await import("nostr-tools/pure"); + const nip04 = await import("nostr-tools/nip04"); + const senderProfile = await ensureSenderProfile(secret, createdAt); + + const results: VotingStartNotificationResult[] = []; + const batchSize = 5; + for (let i = 0; i < period.eligible.length; i += batchSize) { + const batch = period.eligible.slice(i, i + batchSize); + const batchResults = await Promise.all( + batch.map(async (voter): Promise => { + try { + const content = await nip04.encrypt( + secret, + voter.pubkey, + votingStartMessage(hackathonName, voter, votingUrl), + ); + const signed = finalizeEvent( + { + kind: VOTING_DM_KIND, + created_at: createdAt, + content, + tags: [ + ["p", voter.pubkey], + ["e", votingEventId], + ["t", VOTING_DM_TAG], + ["client", "La Crypta Dev"], + ], + }, + secret, + ) as SignedEvent; + const relays = await publishToRelays(signed, DEFAULT_RELAYS, 5000); + return { + recipientPubkey: voter.pubkey, + recipientName: voter.name, + eventId: signed.id, + ok: relays.some((r) => r.ok), + relays, + }; + } catch (error) { + return { + recipientPubkey: voter.pubkey, + recipientName: voter.name, + eventId: null, + ok: false, + relays: [], + error: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + results.push(...batchResults); + } + + const delivered = results.filter((r) => r.ok).length; + return { + attempted: results.length, + delivered, + failed: results.length - delivered, + senderProfile, + results, + }; +} + /** Collects all ballot events for the hackathon (no `authors` filter — * eligibility is enforced by `tallyBallots` against the frozen snapshot). */ async function fetchBallotEvents( @@ -426,11 +691,14 @@ export async function POST( const existing = await fetchVotingPeriodFromRelays(id); const now = Math.floor(Date.now() / 1000); + const shouldNotifyVotingStart = + action === OPEN_ACTION && existing?.period.status !== "open"; // NIP-01 replaceable tie-break keeps the LOWEST id on equal created_at — // always publish strictly after the event we're replacing. const eventCreatedAt = Math.max(now, (existing?.eventCreatedAt ?? 0) + 1); let period: VotingPeriod; + let notifications: VotingStartNotificationSummary | null = null; if (action === OPEN_ACTION) { if ( @@ -535,6 +803,24 @@ export async function POST( // Bust the server cache so SSR reads the freshly-published period. revalidateTag(nostrVotingTag(id), { expire: 0 }); + if (shouldNotifyVotingStart) { + notifications = await publishVotingStartNotifications({ + secret, + period, + hackathonName: hackathon.name, + votingUrl: votingUrlForHackathon(hackathon, req), + votingEventId: signed.id, + createdAt: eventCreatedAt, + }); + if (notifications.failed > 0) { + console.warn("[api/hackathons/voting] NIP-04 notification misses", { + hackathonId: id, + delivered: notifications.delivered, + attempted: notifications.attempted, + }); + } + } + return NextResponse.json({ ok: true, eventId: signed.id, @@ -542,6 +828,7 @@ export async function POST( eligibleCount: period.eligible.length, projectCount: period.projects.length, results: period.results, + notifications, relays: relayResults, }); } catch (error) { diff --git a/app/hackathons/[id]/HackathonProjectsList.tsx b/app/hackathons/[id]/HackathonProjectsList.tsx index 390bd5a..b5c4805 100644 --- a/app/hackathons/[id]/HackathonProjectsList.tsx +++ b/app/hackathons/[id]/HackathonProjectsList.tsx @@ -20,7 +20,7 @@ import { Zap, } from "lucide-react"; import { - hackathonStatus, + isHackathonInscriptionOpen, mergeWithSubmissions, prizedProjects, type Hackathon, @@ -51,6 +51,10 @@ import { rememberScrollPosition, restoreScrollPosition, } from "@/lib/scrollMemory"; +import { + ProjectVotingControls, + ProjectVotingToolbar, +} from "./VotingSection"; function medal(position: number | null | undefined) { if (position === 1) return "🥇"; @@ -359,7 +363,11 @@ export default function HackathonProjectsList({ : "PROYECTOS INSCRIPTOS"; return ( -
+
@@ -385,7 +393,7 @@ export default function HackathonProjectsList({
- {hackathonStatus(hackathon) !== "closed" && ( + {isHackathonInscriptionOpen(hackathon) && ( )}
@@ -471,8 +482,7 @@ function ProjectRow({ : null; const team = dedupeSoldierProfileMembers(project.team); - const Wrapper: React.ElementType = Link; - const wrapperProps = { + const linkProps = { href, onClick(event: MouseEvent) { if ( @@ -490,114 +500,122 @@ function ProjectRow({ }; return ( - -
- {pos ? ( - <> -
{medal(pos) || `#${pos}`}
- {!(pos >= 1 && pos <= 3) && ( -
- #{pos} -
- )} - {score != null && ( -
- {score.toFixed(2)} +
+ +
+ {pos ? ( + <> +
+ {medal(pos) || `#${pos}`}
- )} - - ) : isNostr ? ( - <> - {authorPicture ? ( - { - e.currentTarget.style.display = "none"; - e.currentTarget.nextElementSibling?.classList.remove( - "hidden", - ); - }} + {!(pos >= 1 && pos <= 3) && ( +
+ #{pos} +
+ )} + {score != null && ( +
+ {score.toFixed(2)} +
+ )} + + ) : isNostr ? ( + <> + {authorPicture ? ( + { + e.currentTarget.style.display = "none"; + e.currentTarget.nextElementSibling?.classList.remove( + "hidden", + ); + }} + /> + ) : null} + - ) : null} - + {authorDisplayName} +
+ + ) : ( +
-
- {authorDisplayName} + {project.status}
- - ) : ( -
+ +
+
+
+

+ {project.name} +

+

+ {project.description} +

+
+ {prize && ( + + + {formatSats(prize)} sats + )} - > - {project.status}
- )} -
- -
-
-
-

- {project.name} -

-

- {project.description} -

+
+ {team.length > 0 && ( + + {team.map((t) => t.name).join(" · ")} + + )} + {project.repo && ( + + + repo + + )} + {project.demo && ( + + + demo + + )} + {isNostr && ( + + + submission firmada + + )}
- {prize && ( - - - {formatSats(prize)} sats - - )}
-
- {team.length > 0 && ( - - {team.map((t) => t.name).join(" · ")} - - )} - {project.repo && ( - - - repo - - )} - {project.demo && ( - - - demo - - )} - {isNostr && ( - - - submission firmada - - )} -
-
+ -
+
+
- +
); } diff --git a/app/hackathons/[id]/VotingSection.tsx b/app/hackathons/[id]/VotingSection.tsx index f8c0bef..4821322 100644 --- a/app/hackathons/[id]/VotingSection.tsx +++ b/app/hackathons/[id]/VotingSection.tsx @@ -1,7 +1,16 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { AnimatePresence, motion } from "framer-motion"; import { CheckCircle2, @@ -20,6 +29,7 @@ import { ListChecks, } from "lucide-react"; import { useAuth } from "@/lib/auth"; +import type { Auth } from "@/lib/auth"; import { hackathonSlugForId } from "@/lib/hackathons"; import { getSigner, type SignedEvent } from "@/lib/nostrSigner"; import { useToast } from "@/components/Toast"; @@ -67,6 +77,55 @@ type VoterRow = { voted: boolean; }; +type VotingTotals = { + budget: number; + used: number; + remaining: number; + votedCount: number; + eligibleCount: number; +}; + +type VotingContextValue = { + hackathonId: string; + hackathonName: string; + auth: Auth | null; + ready: boolean; + pubkeys: Pubkeys; + period: VotingPeriod | null; + setPeriod: (period: VotingPeriod) => void; + ballots: Map; + isAdmin: boolean; + admin: AdminVoting; + voterRows: VoterRow[]; + totals: VotingTotals; + results: VotingResults | null; + voter: VotingPeriod["eligible"][number] | null; + allocations: Record; + maxVotes: number; + used: number; + remaining: number; + blocked: string[]; + publishing: boolean; + celebrate: boolean; + hasPrev: boolean; + adjustProjectVote: (projectId: string, delta: number) => void; + publishVotes: () => Promise; +}; + +const VotingContext = createContext(null); + +function useVotingContext() { + const context = useContext(VotingContext); + if (!context) { + throw new Error("Voting components must be rendered inside VotingProvider."); + } + return context; +} + +function useOptionalVotingContext() { + return useContext(VotingContext); +} + /** * Community voting for the hackathon's projects. Eligibility, vote budgets and * the votable project list come frozen inside the period event La Crypta @@ -75,16 +134,19 @@ type VoterRow = { * relay ballots; once closed the embedded official results are rendered * verbatim (the freeze rule — late ballots can't change a signed result). */ -export default function VotingSection({ +export function VotingProvider({ hackathonId, hackathonName, initialPeriod, + children, }: { hackathonId: string; hackathonName: string; initialPeriod: VotingPeriod | null; + children: ReactNode; }) { const { auth, ready } = useAuth(); + const { push } = useToast(); const [pubkeys, setPubkeys] = useState({ adminPubkey: null, @@ -197,7 +259,6 @@ export default function VotingSection({ auth.pubkey === pubkeys.adminPubkey; const admin = useAdminVoting(hackathonId, setPeriod); - const [detailOpen, setDetailOpen] = useState(false); // Ballots are NIP-44 encrypted, so the client can't tally them. While open we // only surface WHO voted + their DECLARED count (the plaintext ["votes"] tag); @@ -267,9 +328,6 @@ export default function VotingSection({ }; }, [voterRows]); - // Nothing to show before the first opening (admins see the open button). - if (!period && !isAdmin) return null; - // Results stay hidden while open (ballots are encrypted); only the closed, // signed period carries the canonical tally + winners. const results: VotingResults | null = @@ -286,8 +344,190 @@ export default function VotingSection({ ? (ballots.get(auth.pubkey.toLowerCase()) ?? null) : null; + const [allocations, setAllocations] = useState>({}); + const [publishing, setPublishing] = useState(false); + const [celebrate, setCelebrate] = useState(false); + const dirty = useRef(false); + const maxVotes = voter?.maxVotes ?? 0; + const blocked = useMemo(() => voter?.blocked ?? [], [voter]); + const used = Object.values(allocations).reduce((sum, n) => sum + n, 0); + const remaining = Math.max(0, maxVotes - used); + const hasPrev = (ownBallotEvent?.created_at ?? 0) > 0 || !!ownAllocations; + const resetKey = `${period?.openedAt ?? 0}:${auth?.pubkey ?? ""}`; + const ownAllocationsKey = JSON.stringify(ownAllocations ?? {}); + + useEffect(() => { + dirty.current = false; + setAllocations(ownAllocations ?? {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetKey]); + + useEffect(() => { + if (!dirty.current) { + setAllocations(ownAllocations ?? {}); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ownAllocationsKey]); + + const adjustProjectVote = useCallback( + (projectId: string, delta: number) => { + if (!voter || publishing || blocked.includes(projectId)) return; + dirty.current = true; + setAllocations((prev) => { + const current = prev[projectId] ?? 0; + const next = current + delta; + if (next < 0) return prev; + const prevUsed = Object.values(prev).reduce((sum, n) => sum + n, 0); + if (delta > 0 && prevUsed >= maxVotes) return prev; + const out = { ...prev }; + if (next === 0) delete out[projectId]; + else out[projectId] = next; + return out; + }); + }, + [blocked, maxVotes, publishing, voter], + ); + + const publishVotes = useCallback(async () => { + if ( + !auth || + publishing || + used === 0 || + used > maxVotes || + !pubkeys.publisherPubkey + ) { + return; + } + setPublishing(true); + try { + const signer = await getSigner(auth); + const ev = await publishBallot( + signer, + hackathonId, + allocations, + pubkeys.publisherPubkey, + ownBallotEvent?.created_at ?? 0, + ); + dirty.current = false; + setBallots((prev) => { + const next = new Map(prev); + next.set(ev.pubkey.toLowerCase(), ev); + return next; + }); + setCelebrate(true); + window.setTimeout(() => setCelebrate(false), 2400); + push({ + kind: "success", + title: hasPrev ? "Votos actualizados" : "Votos publicados", + description: `Repartiste ${used} ${used === 1 ? "voto" : "votos"} firmados con tu clave Nostr.`, + }); + } catch (error) { + push({ + kind: "error", + title: "No se pudo publicar tu voto", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + } finally { + setPublishing(false); + } + }, [ + allocations, + auth, + hackathonId, + hasPrev, + maxVotes, + ownBallotEvent?.created_at, + pubkeys.publisherPubkey, + publishing, + push, + used, + ]); + return ( -
+ + {children} + + ); +} + +type VotingSectionProps = { + hackathonId: string; + hackathonName: string; + initialPeriod: VotingPeriod | null; +}; + +export default function VotingSection(props: Partial = {}) { + const context = useOptionalVotingContext(); + if (!context) { + if ( + !props.hackathonId || + !props.hackathonName || + !("initialPeriod" in props) + ) { + throw new Error("VotingSection requires VotingProvider or voting props."); + } + return ( + + + + ); + } + return ; +} + +function VotingSectionInner() { + const { + hackathonId, + hackathonName, + auth, + ready, + period, + isAdmin, + admin, + voterRows, + totals, + results, + voter, + } = useVotingContext(); + const [detailOpen, setDetailOpen] = useState(false); + + // Nothing to show before the first opening (admins see the open button). + if (!period && !isAdmin) return null; + + return ( +
@@ -372,31 +612,12 @@ export default function VotingSection({

Iniciá sesión con Nostr para votar.

- ) : voter ? ( - { - setBallots((prev) => { - const next = new Map(prev); - next.set(ev.pubkey.toLowerCase(), ev); - return next; - }); - }} - /> - ) : ( + ) : !voter ? (

Solo pueden votar quienes participaron de algún hackatón y tienen su identidad Nostr vinculada.

- )} + ) : null}
)} @@ -671,6 +892,11 @@ function useAdminVoting( ok?: boolean; status?: "open" | "closed"; eligibleCount?: number; + notifications?: { + attempted: number; + delivered: number; + failed: number; + } | null; error?: string; }; if (!res.ok || !data.ok) { @@ -680,10 +906,13 @@ function useAdminVoting( `/api/hackathons/${hackathonId}/voting`, ).then((r) => (r.ok ? r.json() : null)); if (fresh?.period) onPeriod(fresh.period as VotingPeriod); + const notices = data.notifications + ? ` NIP-04: ${data.notifications.delivered}/${data.notifications.attempted} enviados.` + : ""; push({ kind: "success", title: "Votación abierta", - description: `${data.eligibleCount ?? 0} votantes habilitados.`, + description: `${data.eligibleCount ?? 0} votantes habilitados.${notices}`, }); } catch (error) { push({ @@ -1040,243 +1269,169 @@ function CloseReviewModal({ ); } -/* ───────────────────────── Ballot editor ───────────────────────── */ - -function BallotEditor({ - hackathonId, - period, - voterPubkey, - maxVotes, - blocked, - lacryptaPubkey, - initialAllocations, - prevBallotCreatedAt, - onPublished, -}: { - hackathonId: string; - period: VotingPeriod; - voterPubkey: string; - maxVotes: number; - blocked: string[]; - lacryptaPubkey: string; - initialAllocations: Record | null; - prevBallotCreatedAt: number; - onPublished: (ev: SignedEvent) => void; -}) { - const { auth } = useAuth(); - const { push } = useToast(); - const [allocations, setAllocations] = useState>( - initialAllocations ?? {}, - ); - const [publishing, setPublishing] = useState(false); - const [celebrate, setCelebrate] = useState(false); - // Refresh steppers when our relay ballot arrives, but never clobber edits. - const dirty = useRef(false); - useEffect(() => { - if (!dirty.current && initialAllocations) { - setAllocations(initialAllocations); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(initialAllocations)]); - - const used = Object.values(allocations).reduce((sum, n) => sum + n, 0); - const remaining = maxVotes - used; - const hasPrev = prevBallotCreatedAt > 0 || !!initialAllocations; - - function adjust(projectId: string, delta: number) { - dirty.current = true; - setAllocations((prev) => { - const current = prev[projectId] ?? 0; - const next = current + delta; - if (next < 0) return prev; - // Compute against `prev`, not the rendered `remaining` — rapid clicks - // batched into one render would otherwise overshoot the budget. - const prevUsed = Object.values(prev).reduce((sum, n) => sum + n, 0); - if (delta > 0 && prevUsed >= maxVotes) return prev; - const out = { ...prev }; - if (next === 0) delete out[projectId]; - else out[projectId] = next; - return out; - }); - } - - async function handlePublish() { - if (!auth || publishing || used === 0 || used > maxVotes) return; - setPublishing(true); - try { - const signer = await getSigner(auth); - const ev = await publishBallot( - signer, - hackathonId, - allocations, - lacryptaPubkey, - prevBallotCreatedAt, - ); - dirty.current = false; - onPublished(ev); - setCelebrate(true); - window.setTimeout(() => setCelebrate(false), 2400); - push({ - kind: "success", - title: hasPrev ? "Votos actualizados" : "Votos publicados", - description: `Repartiste ${used} ${used === 1 ? "voto" : "votos"} firmados con tu clave Nostr.`, - }); - } catch (error) { - push({ - kind: "error", - title: "No se pudo publicar tu voto", - description: - error instanceof Error ? error.message : "Error desconocido.", - }); - } finally { - setPublishing(false); - } +/* ───────────────────────── Project-list voting controls ───────────────────────── */ + +export function ProjectVotingToolbar() { + const voting = useOptionalVotingContext(); + if ( + !voting?.period || + voting.period.status !== "open" || + !voting.ready || + !voting.auth || + !voting.voter + ) { + return null; } return ( -
-
-
- - - - {remaining} - /{maxVotes} - - - {remaining === 0 - ? "todo repartido" - : `${remaining === 1 ? "voto" : "votos"} por repartir`} - +
+
+ + + + {voting.remaining} + /{voting.maxVotes} - - {Array.from({ length: Math.min(maxVotes, 10) }).map((_, i) => { - const spent = i >= remaining; - return ( - - ); - })} - {maxVotes > 10 && ( - - +{maxVotes - 10} - - )} + + {voting.remaining === 0 + ? "todo repartido" + : `${voting.remaining === 1 ? "voto" : "votos"} por repartir`} -
- {voterPubkey && hasPrev && ( + + + {Array.from({ length: Math.min(voting.maxVotes, 10) }).map((_, i) => { + const spent = i >= voting.remaining; + return ( + + ); + })} + {voting.maxVotes > 10 && ( + + +{voting.maxVotes - 10} + + )} + + {voting.hasPrev && ( - Ya votaste — podés cambiar tu voto + Ya votaste )}
-
    - {period.projects.map((p) => { - const isBlocked = blocked.includes(p.id); - const count = allocations[p.id] ?? 0; - return ( -
  • 0 - ? "border-nostr/40 bg-nostr/5" - : "border-border bg-white/[0.02]", - )} - > - - {p.name} - - {isBlocked ? ( - - Tu proyecto - - ) : ( - - - 0 ? "text-nostr" : "text-foreground-subtle", - )} - > - {count} - - - - )} -
  • - ); - })} -
- -
+
- {celebrate && ( + {voting.celebrate && ( - ¡Voto firmado y publicado! + ¡Voto publicado! )}
); } +export function ProjectVotingControls({ + projectId, + projectName, +}: { + projectId: string; + projectName: string; +}) { + const voting = useOptionalVotingContext(); + if ( + !voting?.period || + voting.period.status !== "open" || + !voting.ready || + !voting.auth || + !voting.voter || + !voting.period.projects.some((project) => project.id === projectId) + ) { + return null; + } + + const isBlocked = voting.blocked.includes(projectId); + const count = voting.allocations[projectId] ?? 0; + if (isBlocked) { + return ( + + Tu proyecto + + ); + } + + return ( + + + 0 ? "text-nostr" : "text-foreground-subtle", + )} + > + {count} + + + + ); +} + /* ───────────────────────── Tally board ───────────────────────── */ function TallyBoard({ diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index 75e3a12..73fdef9 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -30,6 +30,7 @@ import { getHackathon, hackathonSlug, hackathonStatus, + isHackathonInscriptionOpen, primaryProjectPubkey, prizedProjects, programRules, @@ -49,7 +50,7 @@ import { getCachedNostrProfile } from "@/lib/nostrProfileCache"; import { getCachedVotingPeriod } from "@/lib/votingCache"; import { nostrVotingTag } from "@/lib/nostrCacheTags"; import HackathonProjectsList from "./HackathonProjectsList"; -import VotingSection from "./VotingSection"; +import VotingSection, { VotingProvider } from "./VotingSection"; import VotingHero from "@/components/voting/VotingHero"; import HackathonResultsClient from "./HackathonResultsClient"; import AdminBadgesLink from "./AdminBadgesLink"; @@ -560,7 +561,7 @@ export default async function HackathonPage({
))}
- {status !== "closed" && ( + {isHackathonInscriptionOpen(hackathon) && (
@@ -697,8 +698,8 @@ export default async function HackathonPage({ * the SSR skeleton; client takes over on hydration. */} {/* Community voting hero — promotes the vote; "Votar ahora" scrolls to - * the ballot (#votar) rendered by VotingSection below. Renders nothing - * until voting has been opened at least once. */} + * the projects list (#votar), where the ballot controls live. Renders + * nothing until voting has been opened at least once. */}
- - - - {/* Community voting — same Suspense requirement as the projects list. */} - - + > + + + +
diff --git a/app/hackathons/page.tsx b/app/hackathons/page.tsx index d767cf7..eeeeaa5 100644 --- a/app/hackathons/page.tsx +++ b/app/hackathons/page.tsx @@ -18,6 +18,7 @@ import { formatSats, hackathonSlug, hackathonStatus, + isHackathonInscriptionOpen, } from "@/lib/hackathons"; import { cn } from "@/lib/cn"; import HackathonInscripcionButton from "@/components/HackathonInscripcionButton"; @@ -421,7 +422,9 @@ function FeaturedHackathon({
- + {isHackathonInscriptionOpen(h) && ( + + )} Ver detalle diff --git a/lib/hackathons.ts b/lib/hackathons.ts index fb46baf..a234c2d 100644 --- a/lib/hackathons.ts +++ b/lib/hackathons.ts @@ -351,6 +351,39 @@ export function hackathonStatus( return "active"; } +function firstDateOfType(h: Hackathon, type: HackathonEventType): string | null { + const dates = h.dates + .filter((event) => event.type === type) + .map((event) => event.date) + .sort(); + return dates[0] ?? null; +} + +export function hackathonInscriptionDeadline(h: Hackathon): string | null { + return ( + firstDateOfType(h, "cierre") ?? + firstDateOfType(h, "pitch-final") ?? + firstDateOfType(h, "pitch") ?? + firstDateOfType(h, "premios") ?? + null + ); +} + +export function isHackathonInscriptionOpen( + h: Hackathon, + now: Date = new Date(), +): boolean { + const dates = [...h.dates].sort((a, b) => a.date.localeCompare(b.date)); + if (dates.length === 0) return false; + + const first = firstDateOfType(h, "apertura") ?? dates[0].date; + const deadline = hackathonInscriptionDeadline(h); + if (!deadline) return false; + + const today = now.toISOString().slice(0, 10); + return today >= first && today <= deadline; +} + export function formatSats(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;