From 274fdbedb53cd9d02d5c56dd00a1084ac36c8396 Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Thu, 28 May 2026 14:17:35 -0300 Subject: [PATCH] Add commerce results and prize zaps --- app/api/lnurl-invoice/route.ts | 140 ++ app/dashboard/DashboardClient.tsx | 7 +- app/dashboard/projects/UserProjectsClient.tsx | 284 ++- app/hackathons/[id]/HackathonProjectsList.tsx | 6 +- app/hackathons/[id]/PrizeZapButton.tsx | 1519 +++++++++++++++++ .../[projectId]/NostrProjectPageClient.tsx | 16 +- .../[id]/[projectId]/NostrProjectServer.tsx | 8 +- app/hackathons/[id]/[projectId]/page.tsx | 10 +- app/hackathons/[id]/page.tsx | 108 +- app/projects/ProjectsGrid.tsx | 8 +- app/projects/[pubkey]/CuratedProjectPage.tsx | 7 +- app/projects/[pubkey]/UserProjectsPage.tsx | 6 +- .../[pubkey]/[id]/StandaloneProjectPage.tsx | 7 +- components/NewProjectModal.tsx | 5 +- components/sections/Hero.tsx | 5 +- data/hackathons/projects-commerce.json | 148 ++ data/hackathons/reports.json | 90 +- lib/hackathons.ts | 48 +- lib/jsonld.tsx | 3 +- lib/nostrProfile.ts | 4 + lib/nostrRelayConfig.ts | 24 + lib/prizeZaps.ts | 442 +++++ lib/soldierProfileLinks.ts | 129 +- lib/soldiers.ts | 137 ++ lib/userProjects.ts | 10 +- 25 files changed, 3076 insertions(+), 95 deletions(-) create mode 100644 app/api/lnurl-invoice/route.ts create mode 100644 app/hackathons/[id]/PrizeZapButton.tsx create mode 100644 data/hackathons/projects-commerce.json create mode 100644 lib/prizeZaps.ts diff --git a/app/api/lnurl-invoice/route.ts b/app/api/lnurl-invoice/route.ts new file mode 100644 index 0000000..32f9b5c --- /dev/null +++ b/app/api/lnurl-invoice/route.ts @@ -0,0 +1,140 @@ +import { NextResponse } from "next/server"; + +type InvoiceRequestBody = { + endpoint?: unknown; + amount?: unknown; + nostr?: unknown; +}; + +function isAllowedEndpoint(endpoint: string): boolean { + try { + const url = new URL(endpoint); + if (url.protocol !== "https:") return false; + const hostname = url.hostname.toLowerCase(); + if ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname.endsWith(".local") + ) { + return false; + } + return true; + } catch { + return false; + } +} + +async function resolveInvoiceCallback(endpoint: string): Promise { + const url = new URL(endpoint); + if (!url.pathname.toLowerCase().includes("/.well-known/lnurlp/")) { + return endpoint; + } + + const metadataRes = await fetch(endpoint, { + cache: "no-store", + headers: { accept: "application/json" }, + }); + const metadata = (await metadataRes.json().catch(() => ({}))) as { + callback?: unknown; + status?: unknown; + reason?: unknown; + }; + if (!metadataRes.ok || metadata.status === "ERROR") { + throw new Error( + typeof metadata.reason === "string" + ? metadata.reason + : "No se pudo resolver la lightning address.", + ); + } + if (typeof metadata.callback !== "string" || !isAllowedEndpoint(metadata.callback)) { + throw new Error("La lightning address no devolvió un callback válido."); + } + return metadata.callback; +} + +export async function POST(req: Request) { + let body: InvoiceRequestBody; + try { + body = (await req.json()) as InvoiceRequestBody; + } catch { + return NextResponse.json( + { ok: false, reason: "Body JSON inválido." }, + { status: 400 }, + ); + } + + const endpoint = typeof body.endpoint === "string" ? body.endpoint : ""; + const amount = typeof body.amount === "string" ? body.amount : ""; + const nostr = typeof body.nostr === "string" ? body.nostr : ""; + + if (!isAllowedEndpoint(endpoint)) { + return NextResponse.json( + { ok: false, reason: "Endpoint LNURL inválido." }, + { status: 400 }, + ); + } + if (!/^\d+$/.test(amount) || !nostr) { + return NextResponse.json( + { ok: false, reason: "Faltan amount o nostr." }, + { status: 400 }, + ); + } + + let callback: string; + try { + callback = await resolveInvoiceCallback(endpoint); + } catch (error) { + return NextResponse.json( + { + ok: false, + reason: error instanceof Error ? error.message : String(error), + }, + { status: 502 }, + ); + } + + const url = new URL(callback); + url.searchParams.set("amount", amount); + url.searchParams.set("nostr", nostr); + + try { + const invoiceRes = await fetch(url.toString(), { + cache: "no-store", + headers: { + accept: "application/json", + }, + }); + const text = await invoiceRes.text(); + let payload: unknown = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { reason: text }; + } + + if (!invoiceRes.ok) { + const reason = + typeof payload === "object" && + payload !== null && + "reason" in payload && + typeof payload.reason === "string" + ? payload.reason + : `LNURL respondió HTTP ${invoiceRes.status}`; + return NextResponse.json( + { ok: false, reason }, + { status: invoiceRes.status }, + ); + } + + return NextResponse.json(payload); + } catch (error) { + return NextResponse.json( + { + ok: false, + reason: error instanceof Error ? error.message : String(error), + }, + { status: 502 }, + ); + } +} diff --git a/app/dashboard/DashboardClient.tsx b/app/dashboard/DashboardClient.tsx index 47f1936..6328eaf 100644 --- a/app/dashboard/DashboardClient.tsx +++ b/app/dashboard/DashboardClient.tsx @@ -36,6 +36,7 @@ import { DEFAULT_PROFILE_RELAYS, type NostrProfile as BaseNostrProfile, } from "@/lib/nostrProfile"; +import { mergeNonAuthRelays } from "@/lib/nostrRelayConfig"; import { useRelayList, publishRelayList, @@ -80,11 +81,7 @@ export default function DashboardClient() { const [error, setError] = useState(null); const relays = useMemo(() => { - const out = new Set(DEFAULT_PROFILE_RELAYS); - if (auth?.bunker?.relays) { - auth.bunker.relays.forEach((r) => out.add(r)); - } - return [...out]; + return mergeNonAuthRelays(DEFAULT_PROFILE_RELAYS, auth?.bunker?.relays); }, [auth]); const { diff --git a/app/dashboard/projects/UserProjectsClient.tsx b/app/dashboard/projects/UserProjectsClient.tsx index b55489a..fa3c157 100644 --- a/app/dashboard/projects/UserProjectsClient.tsx +++ b/app/dashboard/projects/UserProjectsClient.tsx @@ -43,6 +43,7 @@ import { } from "@/lib/userProjects"; import { HACKATHONS } from "@/lib/hackathons"; import { useNostrProfile } from "@/lib/nostrProfile"; +import { mergeNonAuthRelays } from "@/lib/nostrRelayConfig"; type RelayResult = { relay: string; ok: boolean; error?: string }; type Phase = "signing" | "publishing" | "done"; @@ -79,12 +80,43 @@ type FormState = { submittedAt?: string; }; +type GitHubRepoSuggestion = { + id: number; + name: string; + fullName: string; + htmlUrl: string; + description: string | null; + language: string | null; + topics: string[]; + homepage: string | null; + pushedAt: string | null; + archived: boolean; + fork: boolean; +}; + function newRowKey() { return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2); } +function githubUsernameFromProfile( + profile: ReturnType["profile"], +): string | null { + const direct = profile?.github?.trim().replace(/^@+/, ""); + if (direct && /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/i.test(direct)) { + return direct; + } + + const website = profile?.website?.trim(); + if (!website) return null; + + const match = website.match( + /^(?:https?:\/\/)?(?:www\.)?github\.com\/([a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?)(?:\/)?(?:[?#].*)?$/i, + ); + return match?.[1] ?? null; +} + function emptyForm(): FormState { return { id: null, @@ -214,6 +246,10 @@ export default function UserProjectsClient() { // Profile for the currently-authenticated user — used to pre-fill the // owner row in the team editor (avatar + nip05 + display name). const { profile: ownerProfile } = useNostrProfile(auth?.pubkey); + const ownerGithub = useMemo( + () => githubUsernameFromProfile(ownerProfile), + [ownerProfile], + ); const ownerRow = useCallback((): TeamRow => { return { @@ -248,9 +284,7 @@ export default function UserProjectsClient() { }, [ownerRow, formOpen, form.id]); const relays = useMemo(() => { - const out = new Set(DEFAULT_USER_RELAYS); - auth?.bunker?.relays?.forEach((r) => out.add(r)); - return [...out]; + return mergeNonAuthRelays(DEFAULT_USER_RELAYS, auth?.bunker?.relays); }, [auth]); useEffect(() => { @@ -519,8 +553,8 @@ export default function UserProjectsClient() { if (!doc || !auth) return; const now = Math.floor(Date.now() / 1000); const today = new Date().toISOString().slice(0, 10); - const clean = (s: string) => s.trim(); - const tech = form.tech.map((t) => t.trim()).filter(Boolean); + const clean = (s?: string | null) => s?.trim() ?? ""; + const tech = form.tech.map((t) => clean(t)).filter(Boolean); const team: TeamMember[] = form.team .map((row) => { @@ -744,6 +778,7 @@ export default function UserProjectsClient() { open={formOpen} form={form} setForm={setForm} + githubUsername={ownerGithub} onSave={handleSave} onClose={() => { setFormOpen(false); @@ -1084,6 +1119,52 @@ async function fetchGitHubMeta(repoUrl: string): Promise<{ }; } +async function fetchGitHubUserRepos( + username: string, +): Promise { + const res = await fetch( + `https://api.github.com/users/${encodeURIComponent(username)}/repos?sort=pushed&direction=desc&per_page=100&type=owner`, + { headers: { Accept: "application/vnd.github+json" } }, + ); + + if (!res.ok) { + if (res.status === 404) throw new Error("Usuario de GitHub no encontrado"); + if (res.status === 403) throw new Error("GitHub limitó la consulta"); + throw new Error("No se pudieron cargar repos de GitHub"); + } + + const repos = (await res.json()) as Array<{ + id: number; + name: string; + full_name: string; + html_url: string; + description: string | null; + language: string | null; + topics?: string[]; + homepage: string | null; + pushed_at: string | null; + archived?: boolean; + fork?: boolean; + private?: boolean; + }>; + + return repos + .filter((repo) => !repo.private) + .map((repo) => ({ + id: repo.id, + name: repo.name, + fullName: repo.full_name, + htmlUrl: repo.html_url, + description: repo.description, + language: repo.language, + topics: repo.topics ?? [], + homepage: repo.homepage, + pushedAt: repo.pushed_at, + archived: !!repo.archived, + fork: !!repo.fork, + })); +} + function RelayPublishProgress({ relays, results, @@ -1222,6 +1303,7 @@ function ProjectFormModal({ open, form, setForm, + githubUsername, onSave, onClose, publishing, @@ -1231,6 +1313,7 @@ function ProjectFormModal({ open: boolean; form: FormState; setForm: (f: FormState) => void; + githubUsername: string | null; onSave: () => void; onClose: () => void; publishing: boolean; @@ -1239,15 +1322,106 @@ function ProjectFormModal({ }) { const [step, setStep] = useState<"repo" | "fetching" | "form">("repo"); const [fetchError, setFetchError] = useState(null); + const [githubRepos, setGithubRepos] = useState([]); + const [githubReposLoading, setGithubReposLoading] = useState(false); + const [githubReposError, setGithubReposError] = useState(null); + const repoQuery = form.repo.trim(); + const repoQueryUrl = repoQuery.match( + /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/]+?)\/([^/?#]+?)(?:\.git)?\/?(?:[?#].*)?$/i, + ); + const normalizedRepoQueryUrl = repoQueryUrl + ? `https://github.com/${repoQueryUrl[1]}/${repoQueryUrl[2].replace(/\.git$/i, "")}` + : null; + const filteredRepos = useMemo(() => { + const query = repoQuery.toLowerCase(); + const base = normalizedRepoQueryUrl + ? githubRepos.filter( + (repo) => + repo.htmlUrl.toLowerCase() === normalizedRepoQueryUrl.toLowerCase(), + ) + : !query + ? githubRepos + : githubRepos.filter((repo) => + [ + repo.name, + repo.fullName, + repo.description ?? "", + repo.language ?? "", + ...repo.topics, + ] + .join(" ") + .toLowerCase() + .includes(query), + ); + + if ( + !normalizedRepoQueryUrl || + base.some( + (repo) => + repo.htmlUrl.toLowerCase() === normalizedRepoQueryUrl.toLowerCase(), + ) + ) { + return base; + } + + if (!repoQueryUrl) return base; + + const [, owner, rawRepo] = repoQueryUrl; + const repoName = rawRepo.replace(/\.git$/i, ""); + return [ + { + id: -1, + name: repoName, + fullName: `${owner}/${repoName}`, + htmlUrl: normalizedRepoQueryUrl, + description: "Usar esta URL de GitHub", + language: null, + topics: [], + homepage: null, + pushedAt: null, + archived: false, + fork: false, + }, + ...base, + ]; + }, [githubRepos, normalizedRepoQueryUrl, repoQuery, repoQueryUrl]); useEffect(() => { if (open) { setStep(form.id ? "form" : "repo"); setFetchError(null); + setGithubReposError(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); + useEffect(() => { + if (!open || form.id || step !== "repo" || !githubUsername) return; + let cancelled = false; + + setGithubReposLoading(true); + setGithubReposError(null); + fetchGitHubUserRepos(githubUsername) + .then((repos) => { + if (cancelled) return; + setGithubRepos(repos); + }) + .catch((e) => { + if (cancelled) return; + setGithubRepos([]); + setGithubReposError( + e instanceof Error ? e.message : "No se pudieron cargar repos", + ); + }) + .finally(() => { + if (!cancelled) setGithubReposLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [open, form.id, step, githubUsername]); + const phaseLabel = phase === "signing" ? "Esperando firma…" @@ -1267,13 +1441,14 @@ function ProjectFormModal({ return () => window.removeEventListener("keydown", onKey); }, [open, onClose, publishing]); - async function handleFetch() { + async function handleFetch(repoUrl = form.repo.trim()) { setFetchError(null); setStep("fetching"); try { - const meta = await fetchGitHubMeta(form.repo.trim()); + const meta = await fetchGitHubMeta(repoUrl); setForm({ ...form, + repo: repoUrl, name: meta.name.replace(/-/g, " "), description: meta.readmeDescription || meta.description || "", demo: meta.homepage ?? "", @@ -1355,22 +1530,97 @@ function ProjectFormModal({

Ingresá la URL del repositorio en GitHub para autocompletar nombre, descripción y stack.

- {fetchError && ( -
- - {fetchError} -
- )} - + setForm({ ...form, repo: e.target.value })} - placeholder="https://github.com/owner/repo" + placeholder="Buscar por nombre o pegar https://github.com/owner/repo" className="w-full px-3 py-2.5 rounded-lg bg-white/[0.03] border border-border focus:border-bitcoin/50 focus:bg-white/[0.05] transition-colors text-sm font-mono placeholder:text-foreground-subtle" /> + {fetchError && ( +
+ + {fetchError} +
+ )} + {githubUsername && ( +
+
+
+
+ + @{githubUsername} +
+
+ {repoQuery + ? `${filteredRepos.length} resultado${filteredRepos.length === 1 ? "" : "s"}` + : githubRepos.length > 0 + ? `${githubRepos.length} repos públicos` + : "Repos públicos"} +
+
+ {githubReposLoading && ( + + )} +
+ {githubReposError ? ( +
+ {githubReposError} +
+ ) : filteredRepos.length > 0 ? ( +
+ {filteredRepos.map((repo) => ( + + ))} +
+ ) : githubReposLoading ? null : ( +
+ No encontré repos para esa búsqueda. +
+ )} +
+ )} ) : (
@@ -1404,7 +1654,7 @@ function ProjectFormModal({ action={
- {project.team.length > 0 && ( + {team.length > 0 && ( - {project.team.map((t) => t.name).join(" · ")} + {team.map((t) => t.name).join(" · ")} )} {project.repo && ( diff --git a/app/hackathons/[id]/PrizeZapButton.tsx b/app/hackathons/[id]/PrizeZapButton.tsx new file mode 100644 index 0000000..8b24bec --- /dev/null +++ b/app/hackathons/[id]/PrizeZapButton.tsx @@ -0,0 +1,1519 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + CheckCircle2, + ClipboardList, + Copy, + ExternalLink, + FileJson, + Loader2, + ReceiptText, + ScrollText, + Trophy, + X, + Zap, +} from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { getSigner } from "@/lib/nostrSigner"; +import type { SignedEvent } from "@/lib/nostrSigner"; +import { + findPrizeZapReceipt, + getLocalPrizeZapReceipt, + getLocalPrizeZap, + payPrizeZap, + type PrizeZapRecipientPaymentInfo, + type PrizeZapProgress, + type PrizeZapProgressStep, + type PrizeZapRecord, + type PrizeZapTarget, +} from "@/lib/prizeZaps"; +import { formatSats } from "@/lib/hackathons"; +import { resolveLacryptaPubkey } from "@/lib/nostrReports"; +import { useToast } from "@/components/Toast"; +import { cn } from "@/lib/cn"; + +export default function PrizeZapButton({ target }: { target: PrizeZapTarget }) { + const { auth, signerReady } = useAuth(); + const { push } = useToast(); + const [adminPubkey, setAdminPubkey] = useState(null); + const [checking, setChecking] = useState(false); + const [paying, setPaying] = useState(false); + const [paid, setPaid] = useState(false); + const [receipt, setReceipt] = useState(null); + const [localRecord, setLocalRecord] = useState(null); + const [proofOpen, setProofOpen] = useState(false); + const [progressOpen, setProgressOpen] = useState(false); + const [progressLog, setProgressLog] = useState([]); + const [progressError, setProgressError] = useState(null); + const [recipientPaymentInfo, setRecipientPaymentInfo] = + useState( + target.recipientLightningAddress + ? { + lightningAddress: target.recipientLightningAddress, + zapEndpoint: target.recipientZapEndpoint ?? null, + source: "lud16", + } + : target.recipientZapEndpoint + ? { + lightningAddress: null, + zapEndpoint: target.recipientZapEndpoint, + source: "zap-endpoint", + } + : null, + ); + + const isAdmin = !!auth && !!adminPubkey && auth.pubkey === adminPubkey; + const disabled = checking || paying || (!paid && !signerReady); + + const label = useMemo(() => { + if (paid) return "Pagado"; + if (paying) return "Pagando"; + if (checking) return "Chequeando"; + return `Pagar ${formatSats(target.sats)}`; + }, [checking, paid, paying, target.sats]); + + useEffect(() => { + let cancelled = false; + resolveLacryptaPubkey().then((pk) => { + if (!cancelled) setAdminPubkey(pk || null); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!adminPubkey) return; + const cachedReceipt = getLocalPrizeZapReceipt(target, adminPubkey); + if (cachedReceipt) { + setReceipt(cachedReceipt); + setPaid(true); + return; + } + const local = isAdmin && auth ? getLocalPrizeZap(target, auth.pubkey) : null; + if (local) { + setLocalRecord(local); + setPaid(true); + return; + } + let cancelled = false; + setChecking(true); + findPrizeZapReceipt(target, adminPubkey) + .then((receipt) => { + if (!cancelled && receipt) { + setReceipt(receipt); + setPaid(true); + } + }) + .catch(() => { + /* keep payable if receipt check fails */ + }) + .finally(() => { + if (!cancelled) setChecking(false); + }); + return () => { + cancelled = true; + }; + }, [adminPubkey, auth, isAdmin, target]); + + useEffect(() => { + setRecipientPaymentInfo( + target.recipientLightningAddress + ? { + lightningAddress: target.recipientLightningAddress, + zapEndpoint: target.recipientZapEndpoint ?? null, + source: "lud16", + } + : target.recipientZapEndpoint + ? { + lightningAddress: null, + zapEndpoint: target.recipientZapEndpoint, + source: "zap-endpoint", + } + : null, + ); + }, [target.recipientLightningAddress, target.recipientZapEndpoint]); + + async function handlePay(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (paid) { + setProofOpen(true); + return; + } + if (!auth || disabled) return; + setPaying(true); + setProgressOpen(true); + setProgressError(null); + setProgressLog([]); + try { + const signer = await getSigner(auth); + const record = await payPrizeZap(target, signer, (progress) => { + setProgressLog((prev) => [...prev, progress]); + }); + setLocalRecord(record); + setProgressLog((prev) => [ + ...prev, + { + step: "recording", + status: "active", + detail: "Buscando recibo NIP-57", + }, + ]); + const newReceipt = await findPrizeZapReceipt(target, signer.pubkey); + if (newReceipt) { + setReceipt(newReceipt); + setProgressLog((prev) => [ + ...prev, + { + step: "recording", + status: "done", + detail: "Recibo NIP-57 encontrado", + }, + ]); + } else { + setProgressLog((prev) => [ + ...prev, + { + step: "recording", + status: "done", + detail: "Pago guardado; recibo pendiente de indexar", + }, + ]); + } + setPaid(true); + push({ + kind: "success", + title: "Premio pagado", + description: `${target.projectName} recibió ${formatSats(target.sats)} sats.`, + }); + } catch (err) { + push({ + kind: "error", + title: "No se pudo pagar", + description: err instanceof Error ? err.message : String(err), + duration: 12000, + }); + setProgressError(err instanceof Error ? err.message : String(err)); + setProgressLog((prev) => [ + ...prev, + { + step: "done", + status: "error", + detail: err instanceof Error ? err.message : String(err), + }, + ]); + } finally { + setPaying(false); + } + } + + if (!isAdmin && !paid) return null; + + return ( + <> + + {proofOpen && ( + setProofOpen(false)} + /> + )} + {progressOpen && ( + setProgressOpen(false)} + onOpenProof={() => { + setProgressOpen(false); + setProofOpen(true); + }} + /> + )} + + ); +} + +const PRIZE_ZAP_STEPS: Array<{ + id: PrizeZapProgressStep; + label: string; + description: string; +}> = [ + { + id: "checking", + label: "Pago previo", + description: "Busca registros locales y recibos NIP-57 existentes.", + }, + { + id: "endpoint", + label: "Endpoint", + description: "Resuelve el callback LNURL/NIP-57 del ganador.", + }, + { + id: "signing", + label: "Firma", + description: "Firma el zap request 9734 con el admin.", + }, + { + id: "invoice", + label: "Invoice", + description: "Pide la invoice al servicio de zaps.", + }, + { + id: "paying", + label: "Pago", + description: "Detecta WebLN o muestra la invoice para pago externo.", + }, + { + id: "recording", + label: "Registro", + description: "Guarda el pago y busca el recibo 9735.", + }, +]; + +const PRIZE_ZAP_VISIBLE_STEPS = PRIZE_ZAP_STEPS.filter( + (step, index, steps) => + steps.findIndex((candidate) => candidate.id === step.id) === index, +); + +function latestProgress( + log: PrizeZapProgress[], + step: PrizeZapProgressStep, +): PrizeZapProgress | null { + for (let i = log.length - 1; i >= 0; i--) { + if (log[i].step === step) return log[i]; + } + return null; +} + +function latestProgressInvoice(log: PrizeZapProgress[]): string { + for (let i = log.length - 1; i >= 0; i--) { + const invoice = log[i].invoice; + if (invoice) return invoice; + } + return ""; +} + +function latestProgressEndpoint(log: PrizeZapProgress[]): string { + for (let i = log.length - 1; i >= 0; i--) { + const endpoint = log[i].zapEndpoint; + if (endpoint) return endpoint; + } + return ""; +} + +function lightningAddressFromEndpoint(endpoint: string): string | null { + try { + const url = new URL(endpoint); + const parts = url.pathname.split("/").filter(Boolean); + const lnurlpIndex = parts.findIndex((part) => part.toLowerCase() === "lnurlp"); + const username = lnurlpIndex >= 0 ? parts[lnurlpIndex + 1] : null; + if (!username) return null; + return `${decodeURIComponent(username)}@${url.hostname}`; + } catch { + return null; + } +} + +function ManualInvoicePanel({ invoice }: { invoice: string }) { + const [qrDataUrl, setQrDataUrl] = useState(""); + + useEffect(() => { + let cancelled = false; + import("qrcode") + .then((qr) => + qr.toDataURL(invoice, { + margin: 1, + width: 220, + color: { + dark: "#0b0f1a", + light: "#ffffff", + }, + }), + ) + .then((dataUrl) => { + if (!cancelled) setQrDataUrl(dataUrl); + }) + .catch(() => { + if (!cancelled) setQrDataUrl(""); + }); + return () => { + cancelled = true; + }; + }, [invoice]); + + return ( +
+
+ + Invoice para pagar +
+
+
+ {qrDataUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + QR de invoice Lightning + ) : ( + + )} +
+
+
+ {invoice} +
+
+ + + + Abrir wallet + +
+
+
+
+ ); +} + +function PrizeZapProgressModal({ + target, + recipientPaymentInfo, + progressLog, + error, + done, + onClose, + onOpenProof, +}: { + target: PrizeZapTarget; + recipientPaymentInfo: PrizeZapRecipientPaymentInfo | null; + progressLog: PrizeZapProgress[]; + error: string | null; + done: boolean; + onClose: () => void; + onOpenProof: () => void; +}) { + const stepState = (step: PrizeZapProgressStep) => { + if (step === "paying") { + return ( + latestProgress(progressLog, "paying") ?? + latestProgress(progressLog, "webln") + ); + } + return latestProgress(progressLog, step); + }; + const completed = PRIZE_ZAP_VISIBLE_STEPS.filter( + (step) => stepState(step.id)?.status === "done", + ).length; + const activeStep = + PRIZE_ZAP_VISIBLE_STEPS.find( + (step) => stepState(step.id)?.status === "active", + ) ?? + PRIZE_ZAP_VISIBLE_STEPS[ + Math.min(completed, PRIZE_ZAP_VISIBLE_STEPS.length - 1) + ]; + const progress = error + ? Math.max(8, (completed / PRIZE_ZAP_VISIBLE_STEPS.length) * 100) + : done + ? 100 + : Math.max(8, (completed / PRIZE_ZAP_VISIBLE_STEPS.length) * 100); + const destination = recipientPaymentInfo + ? recipientPaymentInfo.lightningAddress || "Sin lightning address pública" + : "Resolviendo lightning address…"; + const manualInvoice = latestProgressInvoice(progressLog); + const progressEndpoint = latestProgressEndpoint(progressLog); + const progressLightningAddress = progressEndpoint + ? lightningAddressFromEndpoint(progressEndpoint) + : null; + const displayedDestination = + recipientPaymentInfo?.lightningAddress ?? + progressLightningAddress ?? + progressEndpoint ?? + destination; + const destinationLabel = + recipientPaymentInfo?.lightningAddress || progressLightningAddress + ? "Lightning address" + : progressEndpoint + ? "Endpoint" + : "Destino"; + + return ( +
+ +
+ +
+
+
+ + {error + ? "Proceso detenido" + : done + ? "Pago completado" + : activeStep?.label} + + + {Math.round(progress)}% + +
+
+
+
+
+ +
+ {PRIZE_ZAP_VISIBLE_STEPS.map((step, index) => { + const state = stepState(step.id); + const isActive = state?.status === "active"; + const isDone = state?.status === "done"; + const isError = state?.status === "error"; + return ( +
+
+
+ {isDone ? ( + + ) : isActive ? ( + + ) : isError ? ( + + ) : ( + + {index + 1} + + )} +
+
+
+ {step.label} +
+
+ {state?.detail || step.description} +
+
+
+
+ ); + })} +
+ + {manualInvoice && (error || latestProgress(progressLog, "webln")?.status === "error") && ( + + )} + + {error && ( +
+ {error} +
+ )} + +
+ {(done || error) && ( + + )} + {done && ( + + )} +
+
+
+
+ ); +} + +function eventTag(event: SignedEvent | null, name: string): string | null { + return event?.tags.find((tag) => tag[0] === name)?.[1] ?? null; +} + +function parseZapRequest(receipt: SignedEvent | null): SignedEvent | null { + const description = eventTag(receipt, "description"); + if (!description) return null; + try { + return JSON.parse(description) as SignedEvent; + } catch { + return null; + } +} + +type RelayPublicationStatus = + | { + relay: string; + status: "checking" | "published" | "missing" | "publishing"; + } + | { + relay: string; + status: "error"; + error: string; + }; + +function zapRequestRelays(zapRequest: SignedEvent | null): string[] { + const relays = zapRequest?.tags.find((tag) => tag[0] === "relays")?.slice(1) ?? []; + return [...new Set(relays.map((relay) => relay.trim()).filter(Boolean))]; +} + +async function checkRelayForEvent( + relay: string, + eventId: string, +): Promise { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + + return new Promise((resolve) => { + let settled = false; + let closer: { close: () => void } | null = null; + const finish = (status: RelayPublicationStatus) => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + try { + closer?.close(); + pool.close([relay]); + } catch { + /* noop */ + } + resolve(status); + }; + const timeout = window.setTimeout(() => { + finish({ relay, status: "missing" }); + }, 3500); + + try { + closer = pool.subscribe( + [relay], + { ids: [eventId], limit: 1 }, + { + onevent(ev) { + if ((ev as SignedEvent).id === eventId) { + finish({ relay, status: "published" }); + } + }, + oneose() { + finish({ relay, status: "missing" }); + }, + onclose(reason) { + finish({ + relay, + status: "error", + error: Array.isArray(reason) + ? reason.filter(Boolean).join(", ") || "Conexión cerrada" + : reason || "Conexión cerrada", + }); + }, + }, + ); + } catch (error) { + finish({ + relay, + status: "error", + error: error instanceof Error ? error.message : String(error), + }); + } + }); +} + +async function checkRelayPublication( + relays: string[], + eventId: string, + onStatus: (status: RelayPublicationStatus) => void, +) { + await Promise.all( + relays.map(async (relay) => { + const status = await checkRelayForEvent(relay, eventId); + onStatus(status); + }), + ); +} + +async function publishEventToRelay( + relay: string, + event: SignedEvent, +): Promise { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + try { + const [published] = pool.publish([relay], event); + await Promise.race([ + published, + new Promise((_, reject) => { + window.setTimeout(() => reject(new Error("Timeout publicando")), 5000); + }), + ]); + return { relay, status: "published" }; + } catch (error) { + return { + relay, + status: "error", + error: error instanceof Error ? error.message : String(error), + }; + } finally { + try { + pool.close([relay]); + } catch { + /* noop */ + } + } +} + +function short(value?: string | null, head = 12, tail = 8): string { + if (!value) return "—"; + if (value.length <= head + tail + 1) return value; + return `${value.slice(0, head)}…${value.slice(-tail)}`; +} + +function CopyButton({ value }: { value?: string | null }) { + if (!value) return null; + return ( + + ); +} + +function ProofRow({ + label, + value, + copyValue, + href, +}: { + label: string; + value?: string | number | null; + copyValue?: string | null; + href?: string | null; +}) { + const text = value === undefined || value === null || value === "" ? "—" : String(value); + return ( +
+
+ {label} +
+ {href ? ( + + {text} + + + ) : ( +
+ {text} +
+ )} + +
+ ); +} + +type ProofTab = "summary" | "invoice" | "request" | "receipt"; + +function SectionTitle({ + icon, + title, +}: { + icon: React.ReactNode; + title: string; +}) { + return ( +
+ {icon} + {title} +
+ ); +} + +function JsonValue({ value, depth = 0 }: { value: unknown; depth?: number }) { + if (value === null) { + return null; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return []; + } + return ( +
+ {value.map((item, index) => ( +
+
+ [{index}] +
+ +
+ ))} +
+ ); + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record); + if (entries.length === 0) { + return {"{}"}; + } + return ( +
+ {entries.map(([key, nested]) => ( +
+
+ {key} +
+
+ +
+ +
+ ))} +
+ ); + } + + if (typeof value === "string") { + return {value}; + } + + if (typeof value === "number") { + return {value}; + } + + if (typeof value === "boolean") { + return {String(value)}; + } + + return {String(value)}; +} + +function JsonViewer({ value }: { value: unknown }) { + return ( +
+ +
+ ); +} + +function RelayPublicationList({ + relays, + statuses, + receipt, + onStatus, +}: { + relays: string[]; + statuses: RelayPublicationStatus[]; + receipt: SignedEvent | null; + onStatus: (status: RelayPublicationStatus) => void; +}) { + const statusByRelay = new Map(statuses.map((status) => [status.relay, status])); + + async function handlePublish(relay: string) { + if (!receipt) return; + onStatus({ relay, status: "publishing" }); + const status = await publishEventToRelay(relay, receipt); + onStatus(status); + } + + if (!relays.length) { + return ( +
+ El zap request no declara relays. +
+ ); + } + + return ( +
+ {relays.map((relay) => { + const status = statusByRelay.get(relay) ?? { relay, status: "checking" }; + const isPublished = status.status === "published"; + const isMissing = status.status === "missing"; + const isError = status.status === "error"; + const isPublishing = status.status === "publishing"; + const canPublish = !!receipt && (isMissing || isError); + const label = isPublished + ? "Publicado" + : isMissing + ? "No encontrado" + : isError + ? "Error" + : isPublishing + ? "Publicando" + : "Chequeando"; + const stateClass = cn( + "inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[9px] font-mono font-bold uppercase tracking-widest transition-colors", + isPublished && "border-success/30 bg-success/10 text-success", + isMissing && "border-foreground-subtle/30 bg-white/5 text-foreground-subtle", + isError && "border-danger/30 bg-danger/10 text-danger", + (status.status === "checking" || isPublishing) && + "border-bitcoin/30 bg-bitcoin/10 text-bitcoin", + ); + + return ( +
+
+
+ {relay} +
+ {isError && ( +
+ {status.error} +
+ )} +
+ {canPublish ? ( + + ) : ( +
+ {isPublished ? ( + + ) : isError || isMissing ? ( + + ) : ( + + )} + {label} +
+ )} +
+ ); + })} +
+ ); +} + +type DecodedBolt11 = { + network: string; + amountMsats: string | null; + amountSats: string | null; + timestamp: number; + paymentHash?: string; + description?: string; + descriptionHash?: string; + paymentSecret?: string; + payeePubkey?: string; + expirySeconds?: number; + minFinalCltvExpiry?: number; +}; + +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +function wordsToInt(words: number[]): number { + return words.reduce((acc, word) => acc * 32 + word, 0); +} + +function wordsToBytes(words: number[]): Uint8Array { + const bytes: number[] = []; + let value = 0; + let bits = 0; + for (const word of words) { + value = (value << 5) | word; + bits += 5; + while (bits >= 8) { + bits -= 8; + bytes.push((value >> bits) & 0xff); + } + } + return new Uint8Array(bytes); +} + +function bytesToHex(bytes: Uint8Array): string { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function amountToMsats(amount: string): string | null { + if (!amount) return null; + const unit = amount.at(-1); + const numberPart = /[munp]/.test(unit ?? "") ? amount.slice(0, -1) : amount; + if (!/^\d+$/.test(numberPart)) return null; + const n = BigInt(numberPart); + const msats = + unit === "m" + ? n * BigInt("100000000") + : unit === "u" + ? n * BigInt("100000") + : unit === "n" + ? n * BigInt("100") + : unit === "p" + ? n / BigInt("10") + : n * BigInt("100000000000"); + return msats.toString(); +} + +function decodeBolt11(invoice: string): DecodedBolt11 | null { + const lower = invoice.trim().toLowerCase(); + const separator = lower.lastIndexOf("1"); + if (separator <= 0) return null; + + const hrp = lower.slice(0, separator); + if (!hrp.startsWith("ln")) return null; + + const rawData = lower.slice(separator + 1); + const allWords = [...rawData].map((char) => BECH32_CHARSET.indexOf(char)); + if (allWords.some((word) => word < 0) || allWords.length < 7 + 104 + 6) { + return null; + } + + const words = allWords.slice(0, -6); + const timestamp = wordsToInt(words.slice(0, 7)); + const signatureWords = 104; + const tagWords = words.slice(7, Math.max(7, words.length - signatureWords)); + const amount = hrp.slice(4); + const amountMsats = amountToMsats(amount); + const decoded: DecodedBolt11 = { + network: hrp.slice(2, 4) || "bc", + amountMsats, + amountSats: amountMsats + ? (BigInt(amountMsats) / BigInt("1000")).toString() + : null, + timestamp, + }; + + for (let i = 0; i + 3 <= tagWords.length; ) { + const tag = BECH32_CHARSET[tagWords[i]]; + const length = wordsToInt(tagWords.slice(i + 1, i + 3)); + const data = tagWords.slice(i + 3, i + 3 + length); + if (data.length !== length) break; + const bytes = wordsToBytes(data); + + if (tag === "p") decoded.paymentHash = bytesToHex(bytes); + if (tag === "d") decoded.description = new TextDecoder().decode(bytes); + if (tag === "h") decoded.descriptionHash = bytesToHex(bytes); + if (tag === "s") decoded.paymentSecret = bytesToHex(bytes); + if (tag === "n") decoded.payeePubkey = bytesToHex(bytes); + if (tag === "x") decoded.expirySeconds = wordsToInt(data); + if (tag === "c") decoded.minFinalCltvExpiry = wordsToInt(data); + + i += 3 + length; + } + + return decoded; +} + +function Bolt11Decode({ invoice }: { invoice: string }) { + const decoded = invoice ? decodeBolt11(invoice) : null; + if (!decoded) { + return ( +
+ No pude decodificar esta invoice. +
+ ); + } + + const expiresAt = + decoded.expirySeconds !== undefined + ? decoded.timestamp + decoded.expirySeconds + : null; + + return ( +
+ + + + + + + + + + +
+ ); +} + +function PrizeZapProofModal({ + target, + receipt, + localRecord, + adminPubkey, + onClose, +}: { + target: PrizeZapTarget; + receipt: SignedEvent | null; + localRecord: PrizeZapRecord | null; + adminPubkey: string | null; + onClose: () => void; +}) { + const zapRequest = useMemo(() => parseZapRequest(receipt), [receipt]); + const bolt11 = eventTag(receipt, "bolt11") ?? localRecord?.invoice ?? ""; + const preimage = eventTag(receipt, "preimage") ?? localRecord?.preimage ?? ""; + const paidAt = receipt?.created_at ?? localRecord?.paidAt ?? null; + const amountMsats = + zapRequest?.tags.find((tag) => tag[0] === "amount")?.[1] ?? + String(target.sats * 1000); + const [tab, setTab] = useState("summary"); + const relayList = useMemo(() => zapRequestRelays(zapRequest), [zapRequest]); + const [relayStatuses, setRelayStatuses] = useState([]); + const updateRelayStatus = (status: RelayPublicationStatus) => { + setRelayStatuses((prev) => { + if (prev.some((item) => item.relay === status.relay)) { + return prev.map((item) => (item.relay === status.relay ? status : item)); + } + return [...prev, status]; + }); + }; + + useEffect(() => { + if (!receipt?.id || !relayList.length) { + setRelayStatuses([]); + return; + } + + let cancelled = false; + setRelayStatuses( + relayList.map((relay) => ({ + relay, + status: "checking", + })), + ); + checkRelayPublication(relayList, receipt.id, (status) => { + if (cancelled) return; + updateRelayStatus(status); + }).catch(() => { + /* per-relay status handles visible errors */ + }); + + return () => { + cancelled = true; + }; + }, [receipt?.id, relayList]); + + const tabs: Array<{ + id: ProofTab; + label: string; + icon: React.ReactNode; + disabled?: boolean; + }> = [ + { + id: "summary", + label: "Resumen", + icon: , + }, + { + id: "invoice", + label: "Invoice", + icon: , + disabled: !bolt11, + }, + { + id: "request", + label: "Request", + icon: , + disabled: !zapRequest, + }, + { + id: "receipt", + label: "Receipt", + icon: , + disabled: !receipt, + }, + ]; + + return ( +
+ +
+ +
+
+ {tabs.map((item) => ( + + ))} +
+
+ +
+ {tab === "summary" && ( +
+
+
+
+ + Puesto +
+
+ #{target.position} +
+
+
+
+ + Premio +
+
+ {formatSats(target.sats)} +
+
+
+
+ + Estado +
+
+ Pagado +
+
+
+ +
+ } + title="Datos del pago" + /> +
+ + + + + + + + + + + + +
+
+
+ )} + + {tab === "invoice" && ( +
+ } + title="Invoice bolt11" + /> +
+ {bolt11 || "—"} +
+
+ } + title="Decode BOLT11" + /> + +
+
+ )} + + {tab === "request" && zapRequest && ( +
+ } + title="Zap request 9734" + /> + +
+ )} + + {tab === "receipt" && receipt && ( +
+
+ } + title="Publicación en relays" + /> + +
+
+ } + title="Zap receipt 9735" + /> + +
+
+ )} +
+ + + ); +} diff --git a/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx b/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx index ff25f71..21e753c 100644 --- a/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx +++ b/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx @@ -36,10 +36,14 @@ import { getHackathon, type Hackathon } from "@/lib/hackathons"; import { useProjectReport } from "@/lib/nostrReports"; import { useAuth } from "@/lib/auth"; import { getSigner } from "@/lib/nostrSigner"; +import { mergeNonAuthRelays } from "@/lib/nostrRelayConfig"; import { useToast } from "@/components/Toast"; import { GithubIcon } from "@/components/BrandIcons"; import { cn } from "@/lib/cn"; -import { soldierProfileHref } from "@/lib/soldierProfileLinks"; +import { + dedupeSoldierProfileMembers, + soldierProfileHref, +} from "@/lib/soldierProfileLinks"; import { Trophy, Lightbulb, AlertTriangle } from "lucide-react"; import NewProjectModal from "@/components/NewProjectModal"; @@ -315,9 +319,7 @@ export default function NostrProjectPage({ const [searchProgress, setSearchProgress] = useState(null); const relays = useMemo(() => { - const out = new Set(DEFAULT_USER_RELAYS); - auth?.bunker?.relays?.forEach((r) => out.add(r)); - return [...out]; + return mergeNonAuthRelays(DEFAULT_USER_RELAYS, auth?.bunker?.relays); }, [auth]); useEffect(() => { @@ -452,6 +454,8 @@ export default function NostrProjectPage({ ); } + const team = dedupeSoldierProfileMembers(project.team); + return ( - {project.team.length === 0 ? ( + {team.length === 0 ? (

Sin equipo cargado.

) : (
    - {project.team.map((m, i) => { + {team.map((m, i) => { const pic = i === 0 ? (authorPicture ?? m.picture) : m.picture; const displayName = m.name || m.nip05 || "Anónimo"; diff --git a/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx b/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx index 914217a..fa521f7 100644 --- a/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx +++ b/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx @@ -1,6 +1,7 @@ import { getNostrProject } from "@/lib/nostrCache"; import { getHackathon } from "@/lib/hackathons"; import { breadcrumbLd, jsonLdScript } from "@/lib/jsonld"; +import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks"; import NostrProjectPageClient from "./NostrProjectPageClient"; /** @@ -36,6 +37,7 @@ export default async function NostrProjectServer({ const hackathon = getHackathon(hackathonId); const url = `https://lacrypta.dev/hackathons/${hackathonId}/${projectId}`; + const team = dedupeSoldierProfileMembers(project.team); const projectLd = { "@context": "https://schema.org", @@ -55,7 +57,7 @@ export default async function NostrProjectServer({ url: `https://lacrypta.dev/hackathons/${hackathon.id}`, } : undefined, - author: project.team.map((m) => ({ + author: team.map((m) => ({ "@type": "Person", name: m.name || m.nip05 || "Anonymous", url: m.github ? `https://github.com/${m.github}` : undefined, @@ -94,11 +96,11 @@ export default async function NostrProjectServer({

    )}

    Estado: {project.status}.

    - {project.team.length > 0 && ( + {team.length > 0 && ( <>

    Equipo

      - {project.team.map((m) => ( + {team.map((m) => (
    • {m.name} — {m.role} {m.github ? ` (github.com/${m.github})` : ""} diff --git a/app/hackathons/[id]/[projectId]/page.tsx b/app/hackathons/[id]/[projectId]/page.tsx index 24a8c2e..f005873 100644 --- a/app/hackathons/[id]/[projectId]/page.tsx +++ b/app/hackathons/[id]/[projectId]/page.tsx @@ -28,7 +28,10 @@ import { getNostrProject, getNostrSubmissionsSnapshot, } from "@/lib/nostrCache"; -import { soldierProfileHref } from "@/lib/soldierProfileLinks"; +import { + dedupeSoldierProfileMembers, + soldierProfileHref, +} from "@/lib/soldierProfileLinks"; import NostrProjectServer from "./NostrProjectServer"; export async function generateStaticParams() { @@ -204,6 +207,7 @@ function ProjectPageContent({ id, projectId }: ProjectPageParams) { const report = project.report; const award = prizeForProject(id, projectId); const prize = award?.prize ?? null; + const team = dedupeSoldierProfileMembers(project.team); return (
      @@ -475,11 +479,11 @@ function ProjectPageContent({ id, projectId }: ProjectPageParams) { Equipo
      - {project.team.length === 0 ? ( + {team.length === 0 ? (

      Sin equipo cargado.

      ) : (
        - {project.team.map((m) => { + {team.map((m) => { const profileHref = soldierProfileHref(m); const memberContent = ( <> diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index be3478f..527127d 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -21,6 +21,7 @@ import { formatSats, getHackathon, hackathonStatus, + primaryProjectPubkey, prizedProjects, programRules, rankedProjects, @@ -33,8 +34,10 @@ import { getNostrHackathonSubmissions, NOSTR_SUBMISSIONS_TAG, } from "@/lib/nostrCache"; +import { getCachedNostrProfile } from "@/lib/nostrProfileCache"; import HackathonProjectsList from "./HackathonProjectsList"; import HackathonResultsClient from "./HackathonResultsClient"; +import PrizeZapButton from "./PrizeZapButton"; import HackathonInscripcionButton from "@/components/HackathonInscripcionButton"; export function generateStaticParams() { @@ -102,6 +105,13 @@ const STATUS_BADGE: Record = { idea: "bg-white/5 border-border text-foreground-subtle", }; +function lightningAddressToLnurlpEndpoint(address?: string | null): string | null { + if (!address) return null; + const [name, domain] = address.split("@"); + if (!name || !domain) return null; + return `https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`; +} + function medal(position: number | null): string { if (position === 1) return "🥇"; if (position === 2) return "🥈"; @@ -314,6 +324,16 @@ export default async function HackathonPage({ const total = projects.length; const hasReports = projects.some((p) => p.report); const awards = prizedProjects(id); + const prizePubkeys = [ + ...new Set(awards.map((a) => primaryProjectPubkey(a.project)).filter((pubkey): pubkey is string => !!pubkey)), + ]; + const prizeProfileEntries = await Promise.all( + prizePubkeys.map(async (pubkey) => [ + pubkey, + await getCachedNostrProfile(pubkey), + ] as const), + ); + const prizeProfiles = new Map(prizeProfileEntries); const prizeByProjectId = new Map( awards.map((a) => [a.project.id, a] as const), ); @@ -409,42 +429,68 @@ export default async function HackathonPage({ {awards.length > 0 ? ( <>
          - {awards.map((a) => ( -
        1. - { + const recipientPubkey = primaryProjectPubkey(a.project); + const recipientLightningAddress = recipientPubkey + ? prizeProfiles.get(recipientPubkey)?.lud16 ?? null + : null; + return ( +
        2. - - {medal(a.position) || ( - - #{a.position} - + -
          -
          - {a.project.name} -
          -
          - - {formatSats(a.prize)} sats - - {a.tied && ( - - empate + > + + {medal(a.position) || ( + + #{a.position} )} + +
          +
          + {a.project.name} +
          +
          + + {formatSats(a.prize)} sats + + {a.tied && ( + + empate + + )} +
          -
          - -
        3. - ))} + + {recipientPubkey && ( + + )} + + ); + })}
        {awards.some((a) => a.tied) && (

        diff --git a/app/projects/ProjectsGrid.tsx b/app/projects/ProjectsGrid.tsx index 6ba8025..455f0c4 100644 --- a/app/projects/ProjectsGrid.tsx +++ b/app/projects/ProjectsGrid.tsx @@ -35,6 +35,7 @@ import { } from "@/lib/userProjects"; import { GithubIcon } from "@/components/BrandIcons"; import { cn } from "@/lib/cn"; +import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks"; type ProjectSource = "builtin" | "nostr"; @@ -145,7 +146,7 @@ function toDisplay(p: Project): DisplayProject { function nostrToDisplay(np: CommunityProject): DisplayProject { const team = np.team && np.team.length > 0 - ? np.team.map((m) => ({ + ? dedupeSoldierProfileMembers(np.team).map((m) => ({ name: m.name, github: m.github ?? "", role: m.role, @@ -589,6 +590,7 @@ function ProjectCard({ authorPicture?: string; }) { const status = getBadge(project); + const team = dedupeSoldierProfileMembers(project.team); const nostrProjectId = project.source === "nostr" && project.author @@ -675,11 +677,11 @@ function ProjectCard({ {project.description}

        - {project.team.length > 0 && ( + {team.length > 0 && (
        - {project.team.map((m) => m.name).join(" · ")} + {team.map((m) => m.name).join(" · ")}
        )} diff --git a/app/projects/[pubkey]/CuratedProjectPage.tsx b/app/projects/[pubkey]/CuratedProjectPage.tsx index 9d9b2f8..606e1fb 100644 --- a/app/projects/[pubkey]/CuratedProjectPage.tsx +++ b/app/projects/[pubkey]/CuratedProjectPage.tsx @@ -9,6 +9,7 @@ import { import { GithubIcon } from "@/components/BrandIcons"; import { cn } from "@/lib/cn"; import type { Project } from "@/lib/projects"; +import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks"; const STATUS_BADGE: Record = { official: "bg-bitcoin/10 border-bitcoin/40 text-bitcoin", @@ -22,6 +23,8 @@ const STATUS_BADGE: Record = { }; export default function CuratedProjectPage({ project }: { project: Project }) { + const team = dedupeSoldierProfileMembers(project.team); + return (
        @@ -100,13 +103,13 @@ export default function CuratedProjectPage({ project }: { project: Project }) { Equipo
        - {project.team.length === 0 ? ( + {team.length === 0 ? (

        Sin equipo cargado.

        ) : (
          - {project.team.map((m) => ( + {team.map((m) => (
        • = { official: "bg-bitcoin/10 border-bitcoin/40 text-bitcoin", @@ -86,6 +87,7 @@ export default function UserProjectsPage({ pubkey }: { pubkey: string }) { const href = p.hackathon ? `/hackathons/${p.hackathon}/${p.id}` : `/projects/${pubkey}/${p.id}`; + const team = dedupeSoldierProfileMembers(p.team); return ( )}
          - {p.team.length > 0 && ( + {team.length > 0 && ( - {p.team.map((t) => t.name).join(" · ")} + {team.map((t) => t.name).join(" · ")} )} {p.repo && ( diff --git a/app/projects/[pubkey]/[id]/StandaloneProjectPage.tsx b/app/projects/[pubkey]/[id]/StandaloneProjectPage.tsx index fb55003..cc2a401 100644 --- a/app/projects/[pubkey]/[id]/StandaloneProjectPage.tsx +++ b/app/projects/[pubkey]/[id]/StandaloneProjectPage.tsx @@ -11,6 +11,7 @@ import { } from "@/lib/userProjects"; import { GithubIcon } from "@/components/BrandIcons"; import { cn } from "@/lib/cn"; +import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks"; const STATUS_BADGE: Record = { official: "bg-bitcoin/10 border-bitcoin/40 text-bitcoin", @@ -85,6 +86,8 @@ export default function StandaloneProjectPage({ ); } + const team = dedupeSoldierProfileMembers(project.team); + return (
          @@ -195,11 +198,11 @@ export default function StandaloneProjectPage({

          Equipo

          - {project.team.length === 0 ? ( + {team.length === 0 ? (

          Sin equipo cargado.

          ) : (
            - {project.team.map((m, i) => { + {team.map((m, i) => { const pic = i === 0 ? (authorPicture ?? m.picture) : m.picture; return (
          • diff --git a/components/NewProjectModal.tsx b/components/NewProjectModal.tsx index 2b5f79b..6a10a85 100644 --- a/components/NewProjectModal.tsx +++ b/components/NewProjectModal.tsx @@ -28,6 +28,7 @@ import { } from "@/lib/userProjects"; import { HACKATHONS } from "@/lib/hackathons"; import { useNostrProfile } from "@/lib/nostrProfile"; +import { mergeNonAuthRelays } from "@/lib/nostrRelayConfig"; import { cn } from "@/lib/cn"; type Phase = "signing" | "publishing" | "done"; @@ -244,9 +245,7 @@ export default function NewProjectModal({ const [relayResults, setRelayResults] = useState<{ relay: string; ok: boolean; error?: string }[]>([]); const relays = useMemo(() => { - const out = new Set(DEFAULT_USER_RELAYS); - auth?.bunker?.relays?.forEach((r) => out.add(r)); - return [...out]; + return mergeNonAuthRelays(DEFAULT_USER_RELAYS, auth?.bunker?.relays); }, [auth]); const ownerRow = useCallback((): TeamRow => ({ diff --git a/components/sections/Hero.tsx b/components/sections/Hero.tsx index cb9426b..b0925c7 100644 --- a/components/sections/Hero.tsx +++ b/components/sections/Hero.tsx @@ -11,6 +11,7 @@ import { formatSats, } from "@/lib/hackathons"; import { PROJECTS } from "@/lib/projects"; +import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks"; export default function Hero() { return ( @@ -143,7 +144,9 @@ function HeroStats() { const hackathonProjects = allProjects().length; const totalProjects = curatedCount + hackathonProjects; const buildersCount = new Set( - allProjects().flatMap((p) => p.team.map((m) => m.name.toLowerCase())), + allProjects().flatMap((p) => + dedupeSoldierProfileMembers(p.team).map((m) => m.name.toLowerCase()), + ), ).size; const stats = [ diff --git a/data/hackathons/projects-commerce.json b/data/hackathons/projects-commerce.json new file mode 100644 index 0000000..2b3f4ee --- /dev/null +++ b/data/hackathons/projects-commerce.json @@ -0,0 +1,148 @@ +{ + "hackathon": "commerce", + "month": "Mayo 2026", + "projects": [ + { + "id": "cursats", + "name": "Cursats", + "description": "Proyecto ganador de Commerce con score final 8.24 y pitch en el minuto 77:47 de la CC #161.", + "team": [ + { + "name": "Anix", + "github": "analiaacostaok", + "role": "Dev", + "nip05": "anix@hodl.ar", + "pubkey": "507fc20a0a9c6c661b4b3a600ef4f2545359fa0425689991f8edebae74c0dd02" + }, + { + "name": "Fred", + "role": "Dev", + "nip05": "fred@hodl.ar", + "pubkey": "4330e5b786c7899c19f55316eaa579496f3907c09d6d048cb8c0ef3d3bd5b257" + }, + { + "name": "wder", + "github": "Pizza-Wder", + "role": "Dev" + } + ], + "repo": "https://github.com/bitbybit-ar/bitbybit-cursats", + "demo": "https://cursats.bitbybit.com.ar/", + "tech": [ + "Lightning", + "E-commerce" + ], + "status": "winner", + "submittedAt": "2026-05-26" + }, + { + "id": "qrwapu", + "name": "QRWapu", + "description": "Segundo puesto de Commerce con score final 7.80 y demo pública localizada.", + "team": [ + { + "name": "CapScabio", + "github": "CapScabio", + "role": "Lead Dev", + "nip05": "Capscabio@hodl.ar", + "pubkey": "1e0746dbd6c2a06aa0399505e0830d2ee6f10f26c0831361156511709a183cba" + } + ], + "repo": "https://github.com/CapScabio/QRWapu", + "demo": "https://qr-wapu.vercel.app", + "tech": [ + "Lightning", + "QR", + "Checkout" + ], + "status": "winner", + "submittedAt": "2026-05-26" + }, + { + "id": "zaploop", + "name": "Zaploop", + "description": "Tercer puesto de Commerce con score final 7.52 y pitch en el minuto 86:38 de la CC #161.", + "team": [ + { + "name": "Burgos", + "github": "Burgos247", + "role": "Lead Dev", + "nip05": "burgos@primal.net", + "pubkey": "afc8a6df0841909c981c7c5a5536e562cbd5ff5bb22beb8357f3f2798465e4dc" + } + ], + "repo": "https://github.com/Burgos247/Zaploop", + "demo": "https://zaploop.vercel.app", + "tech": [ + "Lightning", + "Commerce" + ], + "status": "winner", + "submittedAt": "2026-05-26" + }, + { + "id": "wapufy", + "name": "wapufy", + "description": "Cuarto puesto de Commerce con score final 7.42 y mención fuerte en los resultados finales.", + "team": [ + { + "name": "Looker", + "github": "Lo0ker-Noma", + "role": "Lead Dev", + "pubkey": "51d31de55347780bb2aa36887142b46cb3e98eed2e5352adab5926168c307e90" + } + ], + "repo": "https://github.com/Lo0ker-Noma/wapify", + "demo": "https://wapify-seven.vercel.app", + "tech": [ + "Lightning", + "Wapupay" + ], + "status": "winner", + "submittedAt": "2026-05-26" + }, + { + "id": "tiendita", + "name": "tiendita", + "description": "Quinto puesto de Commerce con score final 5.36 luego de penalización por no pitch.", + "team": [ + { + "name": "negr0", + "github": "Negr087", + "role": "Lead Dev", + "nip05": "negr0@hodl.ar", + "pubkey": "20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f" + } + ], + "repo": "https://github.com/Negr087/tiendita", + "demo": "https://tienditawapu.vercel.app", + "tech": [ + "Lightning", + "Storefront" + ], + "status": "winner", + "submittedAt": "2026-05-26" + }, + { + "id": "hash-21", + "name": "hash 21", + "description": "Sexto puesto de Commerce con score final 4.95 luego de penalización por no pitch.", + "team": [ + { + "name": "Lai", + "github": "warrior-lai", + "role": "Lead Dev", + "pubkey": "a78a391888c6a7a2e114ad66dc0e473b9f561734c7f098c9552b2e5bb840d26c" + } + ], + "repo": "https://github.com/warrior-lai/hash-21", + "demo": "https://hash21.studio/", + "tech": [ + "Lightning", + "Commerce" + ], + "status": "winner", + "submittedAt": "2026-05-26" + } + ] +} diff --git a/data/hackathons/reports.json b/data/hackathons/reports.json index d24beaa..0adbcc1 100644 --- a/data/hackathons/reports.json +++ b/data/hackathons/reports.json @@ -1708,5 +1708,93 @@ "improvements": [] } } + }, + "commerce": { + "cursats": { + "title": "Cursats", + "position": 1, + "finalScore": 8.24, + "team": [ + "Anix", + "Fred", + "wder" + ], + "stack": [], + "judges": [], + "feedback": { + "strengths": [], + "improvements": [] + } + }, + "qrwapu": { + "title": "QRWapu", + "position": 2, + "finalScore": 7.8, + "team": [ + "CapScabio" + ], + "stack": [], + "judges": [], + "feedback": { + "strengths": [], + "improvements": [] + } + }, + "zaploop": { + "title": "Zaploop", + "position": 3, + "finalScore": 7.52, + "team": [ + "Burgos" + ], + "stack": [], + "judges": [], + "feedback": { + "strengths": [], + "improvements": [] + } + }, + "wapufy": { + "title": "wapufy", + "position": 4, + "finalScore": 7.42, + "team": [ + "Looker" + ], + "stack": [], + "judges": [], + "feedback": { + "strengths": [], + "improvements": [] + } + }, + "tiendita": { + "title": "tiendita", + "position": 5, + "finalScore": 5.36, + "team": [ + "negr0" + ], + "stack": [], + "judges": [], + "feedback": { + "strengths": [], + "improvements": [] + } + }, + "hash-21": { + "title": "hash 21", + "position": 6, + "finalScore": 4.95, + "team": [ + "Lai" + ], + "stack": [], + "judges": [], + "feedback": { + "strengths": [], + "improvements": [] + } + } } -} \ No newline at end of file +} diff --git a/lib/hackathons.ts b/lib/hackathons.ts index df7447b..ed8ce42 100644 --- a/lib/hackathons.ts +++ b/lib/hackathons.ts @@ -1,6 +1,7 @@ import hackathonsJson from "@/data/hackathons/hackathons.json"; import foundationsProjectsJson from "@/data/hackathons/projects-foundations.json"; import identityProjectsJson from "@/data/hackathons/projects-identity.json"; +import commerceProjectsJson from "@/data/hackathons/projects-commerce.json"; import reportsJson from "@/data/hackathons/reports.json"; export type HackathonDifficulty = @@ -118,6 +119,10 @@ export type HackathonProject = { report?: ProjectReport; }; +export function primaryProjectPubkey(project: HackathonProject): string | null { + return project.team.find((m) => m.pubkey)?.pubkey ?? null; +} + export type JudgeCategory = { name: string; score: number; @@ -178,6 +183,9 @@ const PROJECTS_BY_HACKATHON: Record = { identity: (identityProjectsJson as unknown as RawProjectsFile).projects.map( (p) => withReport(p as HackathonProject, "identity"), ), + commerce: (commerceProjectsJson as unknown as RawProjectsFile).projects.map( + (p) => withReport(p as HackathonProject, "commerce"), + ), }; export function getHackathon(id: string): Hackathon | null { @@ -210,6 +218,35 @@ export type HackathonSubmission = HackathonProject & { nostrCreatedAt?: number; }; +function comparableProjectName(name: string): string { + return name + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, ""); +} + +function comparableRepo(repo?: string): string | null { + if (!repo) return null; + try { + const url = new URL(repo); + if (url.hostname.toLowerCase() !== "github.com") return repo.trim().toLowerCase(); + const [owner, name] = url.pathname + .replace(/\.git$/i, "") + .split("/") + .filter(Boolean); + return owner && name ? `${owner.toLowerCase()}/${name.toLowerCase()}` : null; + } catch { + return repo + .trim() + .toLowerCase() + .replace(/^https?:\/\/github\.com\//, "") + .replace(/\.git$/i, "") + .replace(/\/+$/, ""); + } +} + /** * Merges curated (JSON) projects with community-submitted (Nostr) projects * for a given hackathon. Curated wins on id collisions. Curated with reports @@ -222,9 +259,18 @@ export function mergeWithSubmissions( ): HackathonSubmission[] { const curated = rankedProjects(hackathonId); const curatedIds = new Set(curated.map((p) => p.id)); + const curatedRepos = new Set( + curated.map((p) => comparableRepo(p.repo)).filter((repo): repo is string => !!repo), + ); + const curatedNames = new Set(curated.map((p) => comparableProjectName(p.name))); const uniqueNostr = nostrSubmissions .filter((s) => s.hackathon === hackathonId) - .filter((s) => !curatedIds.has(s.id)); + .filter((s) => { + if (curatedIds.has(s.id)) return false; + const repo = comparableRepo(s.repo); + if (repo && curatedRepos.has(repo)) return false; + return !curatedNames.has(comparableProjectName(s.name)); + }); uniqueNostr.sort((a, b) => (b.nostrCreatedAt ?? 0) - (a.nostrCreatedAt ?? 0)); return [...curated, ...uniqueNostr]; diff --git a/lib/jsonld.tsx b/lib/jsonld.tsx index 7c294fc..d435fac 100644 --- a/lib/jsonld.tsx +++ b/lib/jsonld.tsx @@ -1,4 +1,5 @@ import type { Hackathon, HackathonProject } from "./hackathons"; +import { dedupeSoldierProfileMembers } from "./soldierProfileLinks"; const BASE_URL = "https://lacrypta.dev"; const ORG_ID = `${BASE_URL}/#organization`; @@ -94,7 +95,7 @@ export function creativeWorkLd( name: `${hackathon.name} — Hackatón #${hackathon.number}`, url: `${BASE_URL}/hackathons/${hackathon.id}`, }, - author: project.team.map((m) => ({ + author: dedupeSoldierProfileMembers(project.team).map((m) => ({ "@type": "Person", name: m.name || m.nip05 || "Anonymous", url: m.github ? `https://github.com/${m.github}` : undefined, diff --git a/lib/nostrProfile.ts b/lib/nostrProfile.ts index 69f0607..49a71b0 100644 --- a/lib/nostrProfile.ts +++ b/lib/nostrProfile.ts @@ -11,8 +11,10 @@ export type NostrProfile = { banner?: string; about?: string; nip05?: string; + lud06?: string; lud16?: string; website?: string; + github?: string; }; export type CachedProfile = { @@ -100,8 +102,10 @@ export async function fetchNostrProfile( banner: parsed.banner, about: parsed.about, nip05: parsed.nip05, + lud06: parsed.lud06, lud16: parsed.lud16, website: parsed.website, + github: parsed.github, }, fetchedAt: Date.now(), eventCreatedAt: latest.created_at, diff --git a/lib/nostrRelayConfig.ts b/lib/nostrRelayConfig.ts index 958dd4b..2aec63c 100644 --- a/lib/nostrRelayConfig.ts +++ b/lib/nostrRelayConfig.ts @@ -28,6 +28,30 @@ export const LACRYPTA_NIP46_LOGIN_RELAYS = [ "wss://nos.lol", ] as const; +export const AUTH_ONLY_RELAYS = ["wss://relay.nsec.app"] as const; + +export function isAuthOnlyRelay(relay: string): boolean { + const normalized = relay.trim().replace(/\/+$/, "").toLowerCase(); + return AUTH_ONLY_RELAYS.some((authRelay) => authRelay === normalized); +} + +export function withoutAuthOnlyRelays(relays: Iterable): string[] { + const out = new Set(); + for (const relay of relays) { + const normalized = relay.trim().replace(/\/+$/, ""); + if (!normalized || isAuthOnlyRelay(normalized)) continue; + out.add(normalized); + } + return [...out]; +} + +export function mergeNonAuthRelays( + baseRelays: Iterable, + extraRelays?: Iterable, +): string[] { + return withoutAuthOnlyRelays([...(baseRelays ?? []), ...(extraRelays ?? [])]); +} + export const DEFAULT_RELAYS = [...LACRYPTA_DEFAULT_RELAYS]; export const FAST_USER_RELAYS = [...LACRYPTA_FAST_USER_RELAYS]; export const NIP46_LOGIN_RELAYS = [...LACRYPTA_NIP46_LOGIN_RELAYS]; diff --git a/lib/prizeZaps.ts b/lib/prizeZaps.ts new file mode 100644 index 0000000..aab5744 --- /dev/null +++ b/lib/prizeZaps.ts @@ -0,0 +1,442 @@ +"use client"; + +import { DEFAULT_RELAYS } from "./nostrRelayConfig"; +import type { SignedEvent, UnsignedEvent, UserSigner } from "./nostrSigner"; + +export type PrizeZapTarget = { + hackathonId: string; + projectId: string; + projectName: string; + position: number; + recipientPubkey: string; + recipientLightningAddress?: string | null; + recipientZapEndpoint?: string | null; + sats: number; +}; + +export type PrizeZapRecord = PrizeZapTarget & { + adminPubkey: string; + paidAt: number; + invoice: string; + preimage?: string; + zapRequestId: string; +}; + +export type PrizeZapReceiptRecord = PrizeZapTarget & { + adminPubkey: string; + checkedAt: number; + receipt: SignedEvent; +}; + +export type PrizeZapProgressStep = + | "checking" + | "webln" + | "endpoint" + | "signing" + | "invoice" + | "paying" + | "recording" + | "done"; + +export type PrizeZapProgressStatus = "active" | "done" | "error"; + +export type PrizeZapProgress = { + step: PrizeZapProgressStep; + status: PrizeZapProgressStatus; + detail?: string; + invoice?: string; + zapEndpoint?: string; +}; + +export type PrizeZapProgressHandler = ( + progress: PrizeZapProgress, +) => void; + +function isUserCancelledPayment(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /cancel|reject|denied|declin|abort|user/i.test(message); +} + +export type PrizeZapRecipientPaymentInfo = { + lightningAddress: string | null; + zapEndpoint: string | null; + source?: "lud16" | "zap-endpoint" | "none"; +}; + +const STORAGE_PREFIX = "labs:prize-zap:"; +const RECEIPT_STORAGE_PREFIX = "labs:prize-zap-receipt:"; +const ZAP_KIND = 9735; +const ZAP_REQUEST_KIND = 9734; + +declare global { + interface Window { + webln?: { + enable?: () => Promise; + sendPayment?: (invoice: string) => Promise<{ preimage?: string }>; + }; + } +} + +function storageKey(target: PrizeZapTarget, adminPubkey: string): string { + return `${STORAGE_PREFIX}${target.hackathonId}:${target.projectId}:${target.recipientPubkey}:${target.sats}:${adminPubkey}`; +} + +function receiptStorageKey(target: PrizeZapTarget, adminPubkey: string): string { + return `${RECEIPT_STORAGE_PREFIX}${target.hackathonId}:${target.projectId}:${target.recipientPubkey}:${target.sats}:${adminPubkey}`; +} + +export function getLocalPrizeZap( + target: PrizeZapTarget, + adminPubkey: string, +): PrizeZapRecord | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(storageKey(target, adminPubkey)); + return raw ? (JSON.parse(raw) as PrizeZapRecord) : null; + } catch { + return null; + } +} + +function setLocalPrizeZap(record: PrizeZapRecord) { + if (typeof window === "undefined") return; + window.localStorage.setItem( + storageKey(record, record.adminPubkey), + JSON.stringify(record), + ); +} + +export function getLocalPrizeZapReceipt( + target: PrizeZapTarget, + adminPubkey: string, +): SignedEvent | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(receiptStorageKey(target, adminPubkey)); + if (!raw) return null; + return (JSON.parse(raw) as PrizeZapReceiptRecord).receipt; + } catch { + return null; + } +} + +export function setLocalPrizeZapReceipt( + target: PrizeZapTarget, + adminPubkey: string, + receipt: SignedEvent, +) { + if (typeof window === "undefined") return; + const record: PrizeZapReceiptRecord = { + ...target, + adminPubkey, + checkedAt: Math.floor(Date.now() / 1000), + receipt, + }; + window.localStorage.setItem( + receiptStorageKey(target, adminPubkey), + JSON.stringify(record), + ); +} + +function trackingTag(target: PrizeZapTarget): string { + return `lacrypta-prize:${target.hackathonId}:${target.projectId}`; +} + +function lightningAddressFromZapEndpoint(endpoint: string | null): string | null { + if (!endpoint) return null; + try { + const url = new URL(endpoint); + const parts = url.pathname.split("/").filter(Boolean); + const lnurlpIndex = parts.findIndex((part) => part.toLowerCase() === "lnurlp"); + const username = lnurlpIndex >= 0 ? parts[lnurlpIndex + 1] : null; + if (!username) return null; + return `${decodeURIComponent(username)}@${url.hostname}`; + } catch { + return null; + } +} + +async function fetchLatestProfileEvent(pubkey: string) { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const events: SignedEvent[] = []; + const closer = pool.subscribe( + DEFAULT_RELAYS, + { kinds: [0], authors: [pubkey], limit: 1 }, + { + onevent(ev) { + events.push(ev as SignedEvent); + }, + oneose() {}, + }, + ); + await new Promise((r) => setTimeout(r, 3500)); + closer.close(); + try { + pool.close(DEFAULT_RELAYS); + } catch { + /* noop */ + } + events.sort((a, b) => b.created_at - a.created_at); + return events[0] ?? null; +} + +async function findZapEndpoint(recipientPubkey: string): Promise { + const profileEvent = await fetchLatestProfileEvent(recipientPubkey); + if (!profileEvent) { + throw new Error("No encontré el perfil Nostr del ganador."); + } + const { getZapEndpoint } = await import("nostr-tools/nip57"); + const endpoint = await getZapEndpoint(profileEvent); + if (!endpoint) { + throw new Error("El perfil del ganador no tiene zaps NIP-57 habilitados."); + } + return endpoint; +} + +export async function getPrizeZapRecipientPaymentInfo( + recipientPubkey: string, +): Promise { + const profileEvent = await fetchLatestProfileEvent(recipientPubkey); + if (!profileEvent) { + return { lightningAddress: null, zapEndpoint: null, source: "none" }; + } + + let lightningAddress: string | null = null; + let source: PrizeZapRecipientPaymentInfo["source"] = "none"; + try { + const profile = JSON.parse(profileEvent.content) as { + lud16?: string; + lud06?: string; + }; + const lud16 = profile.lud16?.trim(); + lightningAddress = lud16 && lud16.includes("@") ? lud16 : null; + if (lightningAddress) source = "lud16"; + } catch { + /* ignore malformed profile */ + } + + const { getZapEndpoint } = await import("nostr-tools/nip57"); + const zapEndpoint = await getZapEndpoint(profileEvent); + if (!lightningAddress) { + lightningAddress = lightningAddressFromZapEndpoint(zapEndpoint); + if (lightningAddress) source = "zap-endpoint"; + } + return { lightningAddress, zapEndpoint, source }; +} + +function buildPrizeZapRequest( + target: PrizeZapTarget, + adminPubkey: string, +): UnsignedEvent { + return { + kind: ZAP_REQUEST_KIND, + pubkey: adminPubkey, + created_at: Math.floor(Date.now() / 1000), + content: `Premio La Crypta Commerce: #${target.position} ${target.projectName}`, + tags: [ + ["p", target.recipientPubkey], + ["amount", String(target.sats * 1000)], + ["relays", ...DEFAULT_RELAYS], + ["t", "lacrypta-prize"], + ["h", target.hackathonId], + ["project", target.projectId], + ["position", String(target.position)], + ["tracking", trackingTag(target)], + ], + }; +} + +function isPrizeReceipt( + ev: SignedEvent, + target: PrizeZapTarget, + adminPubkey: string, +): boolean { + const bolt11 = ev.tags.find((t) => t[0] === "bolt11")?.[1]; + const description = ev.tags.find((t) => t[0] === "description")?.[1]; + if (!bolt11 || !description) return false; + try { + const zapRequest = JSON.parse(description) as SignedEvent; + if (zapRequest.pubkey !== adminPubkey) return false; + const amount = zapRequest.tags.find((t) => t[0] === "amount")?.[1]; + const p = zapRequest.tags.find((t) => t[0] === "p")?.[1]; + const position = zapRequest.tags.find((t) => t[0] === "position")?.[1]; + const tracking = zapRequest.tags.find((t) => t[0] === "tracking")?.[1]; + return ( + amount === String(target.sats * 1000) && + p === target.recipientPubkey && + (!position || position === String(target.position)) && + tracking === trackingTag(target) + ); + } catch { + return false; + } +} + +export async function findPrizeZapReceipt( + target: PrizeZapTarget, + adminPubkey: string, +): Promise { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const events: SignedEvent[] = []; + const closer = pool.subscribe( + DEFAULT_RELAYS, + { + kinds: [ZAP_KIND], + "#p": [target.recipientPubkey], + "#P": [adminPubkey], + since: Math.floor(new Date("2026-05-26T00:00:00Z").getTime() / 1000), + limit: 25, + }, + { + onevent(ev) { + events.push(ev as SignedEvent); + }, + oneose() { + closer.close(); + }, + }, + ); + await new Promise((r) => setTimeout(r, 3500)); + closer.close(); + try { + pool.close(DEFAULT_RELAYS); + } catch { + /* noop */ + } + events.sort((a, b) => b.created_at - a.created_at); + const receipt = events.find((ev) => isPrizeReceipt(ev, target, adminPubkey)) ?? null; + if (receipt) setLocalPrizeZapReceipt(target, adminPubkey, receipt); + return receipt; +} + +export async function payPrizeZap( + target: PrizeZapTarget, + signer: UserSigner, + onProgress?: PrizeZapProgressHandler, +): Promise { + const progress = ( + step: PrizeZapProgressStep, + status: PrizeZapProgressStatus, + detail?: string, + invoice?: string, + zapEndpoint?: string, + ) => onProgress?.({ step, status, detail, invoice, zapEndpoint }); + + progress("checking", "active", "Buscando pago existente"); + const local = getLocalPrizeZap(target, signer.pubkey); + if (local) { + progress("checking", "done", "Registro local encontrado"); + progress("done", "done", "Pago registrado"); + return local; + } + + const receipt = await findPrizeZapReceipt(target, signer.pubkey); + if (receipt) { + progress("checking", "done", "Recibo NIP-57 encontrado"); + const record: PrizeZapRecord = { + ...target, + adminPubkey: signer.pubkey, + paidAt: receipt.created_at, + invoice: receipt.tags.find((t) => t[0] === "bolt11")?.[1] ?? "", + zapRequestId: JSON.parse( + receipt.tags.find((t) => t[0] === "description")?.[1] ?? "{}", + ).id, + }; + setLocalPrizeZap(record); + progress("done", "done", "Pago confirmado"); + return record; + } + progress("checking", "done", "No hay pago previo"); + + progress("endpoint", "active", "Buscando endpoint NIP-57"); + const endpoint = + target.recipientZapEndpoint || (await findZapEndpoint(target.recipientPubkey)); + progress("endpoint", "done", "Endpoint encontrado", undefined, endpoint); + + progress("signing", "active", "Firmando zap request"); + const zapRequest = await signer.signEvent( + buildPrizeZapRequest(target, signer.pubkey), + ); + progress("signing", "done", `Zap request ${zapRequest.id.slice(0, 12)}…`); + + progress( + "invoice", + "active", + `Solicitando invoice a ${target.recipientLightningAddress || "la lightning address"}`, + ); + const invoiceRes = await fetch("/api/lnurl-invoice", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + endpoint, + amount: String(target.sats * 1000), + nostr: JSON.stringify(zapRequest), + }), + }); + const invoiceBody = (await invoiceRes.json().catch(() => ({}))) as { + pr?: string; + reason?: string; + }; + if (!invoiceRes.ok) { + progress("invoice", "error", invoiceBody.reason || "Invoice rechazada"); + throw new Error( + invoiceBody.reason || "El servicio de zaps rechazó la invoice.", + ); + } + if (!invoiceBody.pr) { + progress("invoice", "error", invoiceBody.reason || "Sin invoice"); + throw new Error( + invoiceBody.reason || "El servicio de zaps no devolvió invoice.", + ); + } + progress("invoice", "done", "Invoice recibida", invoiceBody.pr, endpoint); + + if (!window.webln?.sendPayment) { + progress("webln", "error", "No se detectó WebLN", invoiceBody.pr, endpoint); + throw new Error( + "No detecté WebLN. Pagá la invoice con una wallet compatible.", + ); + } + progress("webln", "active", "Habilitando wallet WebLN", invoiceBody.pr, endpoint); + try { + await window.webln.enable?.(); + } catch (error) { + const detail = isUserCancelledPayment(error) + ? "Cancelado" + : "No se pudo habilitar WebLN"; + progress("webln", "error", detail, invoiceBody.pr, endpoint); + throw error; + } + progress("webln", "done", "Wallet lista", invoiceBody.pr, endpoint); + + progress("paying", "active", "Esperando pago en la wallet", invoiceBody.pr, endpoint); + let payment: { preimage?: string }; + try { + payment = await window.webln.sendPayment(invoiceBody.pr); + } catch (error) { + const detail = isUserCancelledPayment(error) + ? "Cancelado" + : "La wallet no completó el pago"; + progress("paying", "error", detail, invoiceBody.pr, endpoint); + throw error; + } + progress("paying", "done", "Invoice pagada", invoiceBody.pr, endpoint); + + progress("recording", "active", "Guardando registro local"); + const record: PrizeZapRecord = { + ...target, + adminPubkey: signer.pubkey, + paidAt: Math.floor(Date.now() / 1000), + invoice: invoiceBody.pr, + preimage: payment.preimage, + zapRequestId: zapRequest.id, + }; + setLocalPrizeZap(record); + progress("recording", "done", "Registro guardado"); + progress("done", "done", "Pago completado"); + return record; +} diff --git a/lib/soldierProfileLinks.ts b/lib/soldierProfileLinks.ts index ed83226..9695a17 100644 --- a/lib/soldierProfileLinks.ts +++ b/lib/soldierProfileLinks.ts @@ -14,6 +14,82 @@ function cleanGithub(value?: string): string | undefined { return clean(value)?.replace(/^@+/, "").toLowerCase(); } +const CANONICAL_SOLDIER_SLUGS: Record = { + "gh-analiaacostaok": + "pk-507fc20a0a9c6c661b4b3a600ef4f2545359fa0425689991f8edebae74c0dd02", + "nip05-anix@hodl.ar": + "pk-507fc20a0a9c6c661b4b3a600ef4f2545359fa0425689991f8edebae74c0dd02", + "name-anix": + "pk-507fc20a0a9c6c661b4b3a600ef4f2545359fa0425689991f8edebae74c0dd02", + + "gh-burgos247": + "pk-afc8a6df0841909c981c7c5a5536e562cbd5ff5bb22beb8357f3f2798465e4dc", + "nip05-burgos@primal.net": + "pk-afc8a6df0841909c981c7c5a5536e562cbd5ff5bb22beb8357f3f2798465e4dc", + "name-burgos": + "pk-afc8a6df0841909c981c7c5a5536e562cbd5ff5bb22beb8357f3f2798465e4dc", + + "gh-fchurca": + "pk-4330e5b786c7899c19f55316eaa579496f3907c09d6d048cb8c0ef3d3bd5b257", + "nip05-fred@hodl.ar": + "pk-4330e5b786c7899c19f55316eaa579496f3907c09d6d048cb8c0ef3d3bd5b257", + "name-fred": + "pk-4330e5b786c7899c19f55316eaa579496f3907c09d6d048cb8c0ef3d3bd5b257", + + "gh-warrior-lai": + "pk-a78a391888c6a7a2e114ad66dc0e473b9f561734c7f098c9552b2e5bb840d26c", + "name-abstract-lai": + "pk-a78a391888c6a7a2e114ad66dc0e473b9f561734c7f098c9552b2e5bb840d26c", + "name-lai": + "pk-a78a391888c6a7a2e114ad66dc0e473b9f561734c7f098c9552b2e5bb840d26c", + + "gh-lalo1821": + "pk-96709186f836c901525cac72773ece5e646efe72a26fbca2e73c49f9f5f91b7e", + "name-lalo": + "pk-96709186f836c901525cac72773ece5e646efe72a26fbca2e73c49f9f5f91b7e", + "name-lalo1821": + "pk-96709186f836c901525cac72773ece5e646efe72a26fbca2e73c49f9f5f91b7e", + + "gh-pizza-wder": "nip05-wder@bitbybit.com.ar", + "name-wander": "nip05-wder@bitbybit.com.ar", + "name-wder": "nip05-wder@bitbybit.com.ar", + + "gh-lo0ker-noma": + "pk-51d31de55347780bb2aa36887142b46cb3e98eed2e5352adab5926168c307e90", + "name-lo0ker-noma": + "pk-51d31de55347780bb2aa36887142b46cb3e98eed2e5352adab5926168c307e90", + "name-looker": + "pk-51d31de55347780bb2aa36887142b46cb3e98eed2e5352adab5926168c307e90", + + "gh-negr087": + "pk-20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f", + "nip05-negr0@hodl.ar": + "pk-20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f", + "name-negr0": + "pk-20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f", + + "gh-landaverdend": + "pk-f7672a4cc1bf23ebe9519f88e74b41aa4f648981bb1dbe918a2b00a9b1bc78de", + "name-landaverdend": + "pk-f7672a4cc1bf23ebe9519f88e74b41aa4f648981bb1dbe918a2b00a9b1bc78de", + "name-nic-o": + "pk-f7672a4cc1bf23ebe9519f88e74b41aa4f648981bb1dbe918a2b00a9b1bc78de", + + "gh-soyezequiel": + "pk-7c45dcfb2e93594ce43bc2b16fd29b3c38ba0daf42ae8561fe6f0353892b7df4", + "nip05-naranja@coinos.io": + "pk-7c45dcfb2e93594ce43bc2b16fd29b3c38ba0daf42ae8561fe6f0353892b7df4", + "name-naranja": + "pk-7c45dcfb2e93594ce43bc2b16fd29b3c38ba0daf42ae8561fe6f0353892b7df4", + + "name-llopo": "gh-fabricio333", + "name-fabricio": "gh-fabricio333", +}; + +function canonicalSlug(slug: string): string { + return CANONICAL_SOLDIER_SLUGS[slug.toLowerCase()] ?? slug; +} + export function slugifySoldierName(name: string): string { return name .toLowerCase() @@ -25,20 +101,50 @@ export function slugifySoldierName(name: string): string { export function soldierProfileSlug(member: SoldierProfileMember): string | null { const github = cleanGithub(member.github); - if (github) return `gh-${github}`; + if (github) return canonicalSlug(`gh-${github}`); const pubkey = clean(member.pubkey)?.toLowerCase(); - if (pubkey) return `pk-${pubkey}`; + if (pubkey) return canonicalSlug(`pk-${pubkey}`); const nip05 = clean(member.nip05)?.toLowerCase(); - if (nip05) return `nip05-${nip05}`; + if (nip05) return canonicalSlug(`nip05-${nip05}`); const nameSlug = member.name ? slugifySoldierName(member.name) : ""; - if (nameSlug) return `name-${nameSlug}`; + if (nameSlug) return canonicalSlug(`name-${nameSlug}`); return null; } +export function dedupeSoldierProfileMembers( + members: T[], +): T[] { + const seen = new Set(); + const unique: T[] = []; + + for (const member of members) { + const slug = soldierProfileSlug(member); + const github = cleanGithub(member.github); + const pubkey = clean(member.pubkey)?.toLowerCase(); + const nip05 = clean(member.nip05)?.toLowerCase(); + const key = + slug ?? + [ + member.name ? `name:${slugifySoldierName(member.name)}` : "", + github ? `gh:${github}` : "", + pubkey ? `pk:${pubkey}` : "", + nip05 ? `nip05:${nip05}` : "", + ] + .filter(Boolean) + .join("|"); + + if (!key || seen.has(key)) continue; + seen.add(key); + unique.push(member); + } + + return unique; +} + export function soldierProfileSlugAliases( member: SoldierProfileMember, ): string[] { @@ -46,22 +152,27 @@ export function soldierProfileSlugAliases( const push = (slug: string | null | undefined) => { if (slug && !aliases.includes(slug)) aliases.push(slug); }; + const pushAlias = (slug: string | null | undefined) => { + if (!slug) return; + push(slug); + push(canonicalSlug(slug)); + }; push(soldierProfileSlug(member)); const github = cleanGithub(member.github); - if (github) push(`gh-${github}`); + if (github) pushAlias(`gh-${github}`); const pubkey = clean(member.pubkey)?.toLowerCase(); - if (pubkey) push(`pk-${pubkey}`); + if (pubkey) pushAlias(`pk-${pubkey}`); const nip05 = clean(member.nip05)?.toLowerCase(); - if (nip05) push(`nip05-${nip05}`); + if (nip05) pushAlias(`nip05-${nip05}`); const nameSlug = member.name ? slugifySoldierName(member.name) : ""; if (nameSlug) { - push(`name-${nameSlug}`); - push(`gh-${nameSlug}`); + pushAlias(`name-${nameSlug}`); + pushAlias(`gh-${nameSlug}`); } return aliases; diff --git a/lib/soldiers.ts b/lib/soldiers.ts index 4178e5f..75247ed 100644 --- a/lib/soldiers.ts +++ b/lib/soldiers.ts @@ -35,6 +35,74 @@ const POSITION_POINTS: Record = { const POINTS_PER_PROJECT = 2; const POINTS_PER_HACKATHON = 3; +const SOLDIER_ALIAS_GROUPS: string[][] = [ + [ + "gh:analiaacostaok", + "pk:507fc20a0a9c6c661b4b3a600ef4f2545359fa0425689991f8edebae74c0dd02", + "nip05:anix@hodl.ar", + "name:anix", + ], + [ + "gh:burgos247", + "pk:afc8a6df0841909c981c7c5a5536e562cbd5ff5bb22beb8357f3f2798465e4dc", + "nip05:burgos@primal.net", + "name:burgos", + ], + [ + "gh:fchurca", + "pk:4330e5b786c7899c19f55316eaa579496f3907c09d6d048cb8c0ef3d3bd5b257", + "nip05:fred@hodl.ar", + "name:fred", + ], + [ + "gh:warrior-lai", + "pk:a78a391888c6a7a2e114ad66dc0e473b9f561734c7f098c9552b2e5bb840d26c", + "name:abstract-lai", + "name:lai", + ], + [ + "gh:lalo1821", + "pk:96709186f836c901525cac72773ece5e646efe72a26fbca2e73c49f9f5f91b7e", + "name:lalo", + "name:lalo1821", + ], + [ + "gh:pizza-wder", + "nip05:wder@bitbybit.com.ar", + "name:wander", + "name:wder", + ], + [ + "gh:lo0ker-noma", + "pk:51d31de55347780bb2aa36887142b46cb3e98eed2e5352adab5926168c307e90", + "name:lo0ker-noma", + "name:looker", + ], + [ + "gh:negr087", + "pk:20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f", + "nip05:negr0@hodl.ar", + "name:negr0", + ], + [ + "gh:landaverdend", + "pk:f7672a4cc1bf23ebe9519f88e74b41aa4f648981bb1dbe918a2b00a9b1bc78de", + "name:landaverdend", + "name:nic-o", + ], + [ + "gh:soyezequiel", + "pk:7c45dcfb2e93594ce43bc2b16fd29b3c38ba0daf42ae8561fe6f0353892b7df4", + "nip05:naranja@coinos.io", + "name:naranja", + ], + [ + "gh:fabricio333", + "name:llopo", + "name:fabricio", + ], +]; + export type SoldierProjectRef = { hackathonId: string | null; projectId: string; @@ -242,6 +310,72 @@ function indexAliases(s: Soldier, map: Map) { if (s.github) map.set(`gh:${s.github.toLowerCase()}`, s); if (s.pubkey) map.set(`pk:${s.pubkey.toLowerCase()}`, s); if (s.nip05) map.set(`nip05:${s.nip05.toLowerCase()}`, s); + map.set(`name:${slugify(s.name)}`, s); +} + +function mergeSoldierIdentity(primary: Soldier, secondary: Soldier): Soldier { + const nostrFirst = secondary.hasNostr && !primary.hasNostr; + const target = nostrFirst ? secondary : primary; + const source = nostrFirst ? primary : secondary; + + target.projects.push(...source.projects); + for (const role of source.roles) pushUnique(target.roles, role); + + if (source.hasNostr) target.hasNostr = true; + if (!target.pubkey && source.pubkey) target.pubkey = source.pubkey; + if (!target.nip05 && source.nip05) target.nip05 = source.nip05; + if (!target.picture && source.picture) target.picture = source.picture; + if (!target.github && source.github) target.github = source.github; + + if (source.hasNostr && source.pubkey) { + target.id = `pk:${source.pubkey}`; + target.slug = toSlug(target.id); + target.name = source.name; + if (source.picture) target.picture = source.picture; + if (source.nip05) target.nip05 = source.nip05; + } else if (target.pubkey) { + target.id = `pk:${target.pubkey}`; + target.slug = toSlug(target.id); + } + + return target; +} + +function canonicalizeNostrSoldier(s: Soldier, name?: string) { + if (!s.pubkey) return; + s.id = `pk:${s.pubkey}`; + s.slug = toSlug(s.id); + if (name) s.name = name; +} + +function mergeKnownAliases(map: Map) { + for (const group of SOLDIER_ALIAS_GROUPS) { + const members = [ + ...new Set(group.map((key) => map.get(key)).filter(Boolean)), + ] as Soldier[]; + if (members.length <= 1) { + const only = members[0]; + if (only?.hasNostr) canonicalizeNostrSoldier(only); + if (only) { + for (const key of group) map.set(key, only); + indexAliases(only, map); + } + continue; + } + + let primary = + members.find((s) => s.hasNostr && s.pubkey) ?? + members.find((s) => s.pubkey) ?? + members[0]!; + + for (const member of members) { + if (member === primary) continue; + primary = mergeSoldierIdentity(primary, member); + } + + for (const key of group) map.set(key, primary); + indexAliases(primary, map); + } } function curatedHackathonId(p: (typeof PROJECTS)[number]): string | null { @@ -397,6 +531,7 @@ async function buildSoldiers(): Promise { const newGh = githubLc ?? inheritedGhLc; if (newGh) existing.github = newGh; } + if (pubkeyLc) canonicalizeNostrSoldier(existing, m.name); existing.projects.push(ref); pushUnique(existing.roles, m.role); // Re-index aliases: subsequent Nostr submissions for the same @@ -430,6 +565,8 @@ async function buildSoldiers(): Promise { } } + mergeKnownAliases(map); + // Deduplicate — alias indexing means the same Soldier instance can be // reachable from multiple keys (gh / pk / nip05). const unique = [...new Set(map.values())]; diff --git a/lib/userProjects.ts b/lib/userProjects.ts index 10b0763..cfa2e7b 100644 --- a/lib/userProjects.ts +++ b/lib/userProjects.ts @@ -1,7 +1,11 @@ "use client"; import type { SignedEvent, UnsignedEvent, UserSigner } from "./nostrSigner"; -import { DEFAULT_RELAYS, FAST_USER_RELAYS } from "./nostrRelayConfig"; +import { + DEFAULT_RELAYS, + FAST_USER_RELAYS, + withoutAuthOnlyRelays, +} from "./nostrRelayConfig"; import type { HackathonProject, ProjectStatus, @@ -67,10 +71,10 @@ export type CommunityScanProgress = { const USER_CACHE_PREFIX = "labs:user-projects-v2:"; const COMMUNITY_CACHE_KEY = "labs:community-projects:v1"; -export const DEFAULT_USER_RELAYS = FAST_USER_RELAYS; +export const DEFAULT_USER_RELAYS = withoutAuthOnlyRelays(FAST_USER_RELAYS); /** Public relays used to index community projects. */ -export const TOP10_RELAYS = DEFAULT_RELAYS; +export const TOP10_RELAYS = withoutAuthOnlyRelays(DEFAULT_RELAYS); /* ─────────────────────────── cache (localStorage) ──────────────────────── */