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
+

+ ) : (
+
+ )}
+
+
+
+ {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 ? (
+
+ ) : done ? (
+
+ ) : (
+
+ )}
+ Zap de premio
+
+
+ {target.projectName}
+
+
+ #{target.position} · {formatSats(target.sats)} sats
+
+
+
+
+ {destinationLabel}
+
+
+ {displayedDestination}
+
+
+
+
+
+
+
+
+
+
+ {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) => (
+
+ ))}
+
+ );
+ }
+
+ if (typeof value === "object") {
+ const entries = Object.entries(value as Record);
+ if (entries.length === 0) {
+ return {"{}"};
+ }
+ return (
+
+ {entries.map(([key, nested]) => (
+
+ ))}
+
+ );
+ }
+
+ 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 (
+
+
+
+
+
+
+
+ Premio pagado
+
+
+ {target.projectName}
+
+
+ #{target.position} · {formatSats(target.sats)} sats · {receipt ? "Recibo NIP-57" : "Registro local"}
+
+
+
+
+
+
+
+ {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