From 0c56ccb9cd60f1573399f1d0d006425c95ea815f Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Tue, 9 Jun 2026 17:31:18 -0300 Subject: [PATCH 1/3] feat(newsletter): CRM subscribe proxy + redesigned NewsletterCTA Add POST /api/events-subscribe as a server-only proxy to La Crypta's CRM subscribe endpoint, configurable via EVENTS_SUBSCRIBE_URL / EVENTS_SUBSCRIBE_LISTS. Rework NewsletterCTA to use it. Co-Authored-By: Claude Opus 4.8 --- .env.example | 12 + app/api/events-subscribe/route.ts | 150 ++++++++ components/sections/NewsletterCTA.tsx | 499 ++++++++++++-------------- 3 files changed, 401 insertions(+), 260 deletions(-) create mode 100644 app/api/events-subscribe/route.ts diff --git a/.env.example b/.env.example index b08296d..8a8035a 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,18 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 RESEND_API_KEY=re_... RESEND_FROM_EMAIL="La Crypta Dev " +# ─── Events subscription (optional) ───────────────────────────────────────── + +# Upstream La Crypta CRM subscribe endpoint that backs POST /api/events-subscribe. +# Server-only proxy target. Defaults to https://events.lacrypta.ar/api/subscribe +# when unset. Read by: app/api/events-subscribe/route.ts. +# EVENTS_SUBSCRIBE_URL=https://events.lacrypta.ar/api/subscribe + +# CRM contact list UUID(s) to subscribe contacts into (comma-separated for +# several). Forwarded as the `lists` array to the subscribe endpoint. Copy IDs +# from the CRM at /dashboard/lists. Defaults to the main newsletter list. +EVENTS_SUBSCRIBE_LISTS=0135a251-8a46-4f88-b5bc-315d982eb7fa + # ─── Cache revalidation (required for /api/revalidate-nostr) ──────────────── # Shared secret that gates POST /api/revalidate-nostr. diff --git a/app/api/events-subscribe/route.ts b/app/api/events-subscribe/route.ts new file mode 100644 index 0000000..58b66e3 --- /dev/null +++ b/app/api/events-subscribe/route.ts @@ -0,0 +1,150 @@ +import { NextResponse } from "next/server"; +import { isValidEmail, normalizeEmail } from "@/lib/emailLogin"; + +const DEFAULT_ENDPOINT = "https://events.lacrypta.ar/api/subscribe"; + +/** Default CRM contact list to subscribe contacts into (see API_SUBSCRIBE docs). */ +const DEFAULT_LIST_IDS = "0135a251-8a46-4f88-b5bc-315d982eb7fa"; + +/** Parse the comma-separated list-id env var into a clean UUID array. */ +function getListIds(): string[] { + const raw = process.env.EVENTS_SUBSCRIBE_LISTS?.trim() || DEFAULT_LIST_IDS; + return raw + .split(",") + .map((id) => id.trim()) + .filter(Boolean); +} + +type SubscribeBody = { + email?: string; + npub?: string; + name?: string; + phone?: string; +}; + +type CrmResponse = { + ok?: boolean; + exists?: boolean; + message?: string; + error?: string; +}; + +function jsonError(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +function isHexPubkey(value: string): boolean { + return /^[0-9a-f]{64}$/iu.test(value); +} + +/** + * The events CRM expects a bech32 `npub1…` string (it decodes it to hex on + * its side). Accept either an `npub1…` or a raw 64-char hex pubkey and always + * forward the bech32 form. Returns "" when nothing usable was provided. + */ +async function normalizeNpub(raw: string): Promise { + const value = raw.trim().toLowerCase(); + if (!value) return ""; + const { decode, npubEncode } = await import("nostr-tools/nip19"); + if (isHexPubkey(value)) return npubEncode(value); + if (!value.startsWith("npub1")) throw new Error("npub invalida."); + try { + const decoded = decode(value); + if (decoded.type !== "npub") throw new Error("npub invalida."); + return npubEncode(decoded.data as string); + } catch { + throw new Error("npub invalida."); + } +} + +export async function POST(req: Request) { + let body: SubscribeBody; + try { + body = (await req.json()) as SubscribeBody; + } catch { + return jsonError("Body JSON invalido."); + } + + const email = normalizeEmail(body.email ?? ""); + if (body.email && !isValidEmail(email)) { + return jsonError("Correo electronico invalido."); + } + + let npub = ""; + try { + npub = await normalizeNpub(body.npub ?? ""); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "npub invalida."); + } + + if (!email && !npub) { + return jsonError("Envia email, npub o ambos."); + } + + const endpoint = process.env.EVENTS_SUBSCRIBE_URL?.trim() || DEFAULT_ENDPOINT; + const name = body.name?.trim(); + const phone = body.phone?.trim(); + const lists = getListIds(); + + // Fields shared across every attempt; only the identity (email/npub) varies. + const baseFields = { + ...(name ? { name } : {}), + ...(phone ? { phone } : {}), + ...(lists.length ? { lists } : {}), + }; + + async function subscribe(identity: { email?: string; npub?: string }) { + const res = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...identity, ...baseFields }), + }); + const data = (await res.json().catch(() => ({}))) as CrmResponse; + return { res, data }; + } + + try { + let { res, data } = await subscribe({ + ...(email ? { email } : {}), + ...(npub ? { npub } : {}), + }); + + // The CRM returns `identity_conflict` when email + npub are sent together + // but already belong to existing/different contacts (it never links them). + // Fall back to subscribing each identifier on its own — preferring the + // email — so the contact still gets added to the list. + if ( + res.status === 409 && + data.error === "identity_conflict" && + email && + npub + ) { + for (const identity of [{ email }, { npub }]) { + const retry = await subscribe(identity); + if (retry.res.ok) { + res = retry.res; + data = retry.data; + break; + } + } + } + + if (!res.ok) { + const message = + data.error || data.message || "No se pudo crear la suscripcion."; + // Surface the upstream status (e.g. 409 identity_conflict) to the client. + return jsonError(message, res.status >= 400 ? res.status : 502); + } + + return NextResponse.json({ + ok: true, + exists: Boolean(data.exists), + message: data.message ?? (data.exists ? "Already subscribed" : "Subscribed"), + }); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : "No se pudo crear la suscripcion.", + 502, + ); + } +} diff --git a/components/sections/NewsletterCTA.tsx b/components/sections/NewsletterCTA.tsx index 94684fd..badedb6 100644 --- a/components/sections/NewsletterCTA.tsx +++ b/components/sections/NewsletterCTA.tsx @@ -1,17 +1,15 @@ "use client"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { motion } from "framer-motion"; import { AlertCircle, ArrowRight, Check, - Code2, Loader2, Mail, Sparkles, UserRoundSearch, - X, } from "lucide-react"; import { queryProfile } from "nostr-tools/nip05"; import { fetchNostrProfile, type NostrProfile } from "@/lib/nostrProfile"; @@ -144,16 +142,39 @@ export default function NewsletterCTA() { const [resolved, setResolved] = useState(null); const [publishPhase, setPublishPhase] = useState("idle"); const [publishError, setPublishError] = useState(""); - const [signedEvent, setSignedEvent] = useState(null); - const [eventModalOpen, setEventModalOpen] = useState(false); const [progress, setProgress] = useState([]); - const okCount = progress.filter((item) => item.ok).length; - const totalCount = progress.length; const isBusy = subscribePhase === "sending" || ["signing", "publishing"].includes(publishPhase); - const canNotify = !!resolved && resolveStatus === "found" && !isBusy; + const resolvedOk = !!resolved && resolveStatus === "found"; + const resolveFailed = + resolveStatus === "error" || resolveStatus === "missing"; + const canNotify = resolvedOk && !isBusy; + // Email only needs a valid email-format input — it stays available even when + // the NIP-05 never resolves to a Nostr profile. + const emailEnabled = isLikelyNip05(value) && !isBusy; + // Nostr is shown (disabled) until the NIP-05 resolves, then hidden if it + // resolves badly. The combined action is hidden until a clean resolution. + const showNostr = !resolveFailed; + const showBoth = resolvedOk; + const visibleButtons = 1 + (showNostr ? 1 : 0) + (showBoth ? 1 : 0); + + const okCount = progress.filter((item) => item.ok).length; + const totalCount = progress.length; + const notifyEmail = resolved?.handle || value.trim().toLowerCase(); + const relaysRef = useRef(null); + + // Scroll the relay list into view as soon as a Nostr publish starts so the + // user follows the per-relay progress animation. + useEffect(() => { + if ( + progress.length > 0 && + (publishPhase === "signing" || publishPhase === "publishing") + ) { + relaysRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, [progress.length, publishPhase]); useEffect(() => { const raw = value.trim(); @@ -162,8 +183,6 @@ export default function NewsletterCTA() { setSubscribePhase("idle"); setSubscribeError(""); setActiveMode(null); - setSignedEvent(null); - setEventModalOpen(false); setProgress([]); if (!raw) { setResolveStatus("idle"); @@ -202,18 +221,20 @@ export default function NewsletterCTA() { ); async function handleNotify(mode: NotifyMode) { - if (!resolved || !canNotify) return; - const wantsEmail = mode === "email" || mode === "both"; const wantsNostr = mode === "nostr" || mode === "both"; - const nip05 = resolved.handle; + // Nostr / combined need a resolved profile; email just needs a valid input. + if (wantsNostr && !resolvedOk) return; + const emailValue = value.trim().toLowerCase(); + if (wantsEmail && !isLikelyNip05(emailValue)) return; + if (isBusy) return; + + const nip05 = resolved?.handle ?? emailValue; setActiveMode(mode); setPublishError(""); - setSignedEvent(null); - setEventModalOpen(false); setSubscribeError(""); if (wantsNostr) { - const relays = mergeDataRelays(DEFAULT_RELAYS, resolved.relays); + const relays = mergeDataRelays(DEFAULT_RELAYS, resolved?.relays ?? []); setProgress(relays.map((relay) => ({ relay, state: "pending" }))); } else { setProgress([]); @@ -224,13 +245,37 @@ export default function NewsletterCTA() { if (wantsEmail) { setSubscribePhase("sending"); } + + // Register the subscriber in the La Crypta events CRM. The npub is + // derived from the resolved NIP-05 (no manual entry); the email is only + // included when the user opted into email notifications. + const eventsRes = await fetch("/api/events-subscribe", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: wantsEmail ? nip05 : undefined, + // Only send the npub when the user opted into Nostr. Sending both + // identifiers for an email-only subscription triggers the CRM's + // identity_conflict when the email already exists. + npub: wantsNostr ? resolved?.pubkey : undefined, + name: + resolved?.profile.display_name?.trim() || + resolved?.profile.name?.trim() || + undefined, + }), + }); + const eventsData = (await eventsRes.json()) as { error?: string }; + if (!eventsRes.ok) { + throw new Error(eventsData.error || "No se pudo crear la suscripcion."); + } + const subscribeRes = await fetch("/api/subscribe", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: wantsEmail ? nip05 : undefined, - handle: resolved.profile.nip05 || resolved.handle, - recipientPubkey: wantsNostr ? resolved.pubkey : undefined, + handle: resolved?.profile.nip05 || resolved?.handle || nip05, + recipientPubkey: wantsNostr ? resolved?.pubkey : undefined, }), }); const subscribeData = (await subscribeRes.json()) as { error?: string }; @@ -241,6 +286,7 @@ export default function NewsletterCTA() { setSubscribePhase(wantsEmail ? "sent" : "idle"); if (!wantsNostr) return; + if (!resolved) return; setPublishPhase("signing"); const relays = mergeDataRelays(DEFAULT_RELAYS, resolved.relays); const res = await fetch("/api/nostr-opportunity-notification", { @@ -255,8 +301,6 @@ export default function NewsletterCTA() { if (!res.ok || !data.event) { throw new Error(data.error || "No se pudo generar la notificacion."); } - setSignedEvent(data.event); - setPublishPhase("publishing"); const results = await publishNotification(data.event, relays, (relay, patch) => { setProgress((prev) => @@ -360,11 +404,20 @@ export default function NewsletterCTA() { className="w-full rounded-xl border border-border bg-background-card/60 py-3 pl-9 pr-3 text-sm transition-all placeholder:text-foreground-subtle focus:border-bitcoin/60 focus:bg-background-card focus:outline-none focus:ring-2 focus:ring-bitcoin/20" /> -
+
- - + {showNostr && ( + + )} + {showBoth && ( + + )}
- {subscribePhase === "sent" && ( + {subscribePhase === "sent" && publishPhase === "done" ? ( }> - Enviamos la notificación por email al NIP-05. + Te notificamos email y nostr a {notifyEmail}. - )} + ) : subscribePhase === "sent" ? ( + }> + Te notificamos por email a {notifyEmail}. + + ) : null} {subscribePhase === "error" && subscribeError && ( }> {subscribeError} @@ -454,11 +515,6 @@ export default function NewsletterCTA() { Resolviendo identidad y verificando perfil en relays… )} - {resolveStatus === "error" && ( - }> - {resolveError} - - )} {resolveStatus === "idle" && (

Ingresá un NIP-05. Lo resolvemos y verificamos el perfil antes de enviar. @@ -467,26 +523,109 @@ export default function NewsletterCTA() {

{resolved && ( - setEventModalOpen(true)} - phase={publishPhase} - progress={progress} - title={previewTitle} - totalCount={totalCount} - user={resolved} - error={publishError} - /> + + )} + + {progress.length > 0 && ( + +
+
+ {publishPhase === "signing" || publishPhase === "publishing" ? ( + + ) : publishPhase === "done" ? ( + + ) : publishPhase === "error" ? ( + + ) : ( + + )} +

+ {publishPhase === "signing" + ? "Firmando notificación…" + : publishPhase === "publishing" + ? "Publicando en relays…" + : publishPhase === "done" + ? "Notificación publicada en Nostr" + : publishPhase === "error" + ? "No se pudo publicar" + : "Relays"} +

+
+ + {okCount}/{totalCount} relays + +
+ +
+ {progress.map((item) => ( +
+
+ + {item.relay.replace(/^wss?:\/\//u, "")} + + + {item.state === "ok" + ? "ok" + : item.state === "error" + ? "error" + : item.state === "publishing" + ? "publicando" + : "pendiente"} + +
+
+ +
+
+ ))} +
+ + {publishPhase === "done" && ( +

+ Listo. La notificación fue aceptada por {okCount} relay + {okCount === 1 ? "" : "s"}. +

+ )} + {publishPhase === "error" && publishError && ( +

+ {publishError} +

+ )} +
)}
- {signedEvent && ( - setEventModalOpen(false)} - /> - )} ); } @@ -518,24 +657,10 @@ function StatusLine({ } function ProfilePreview({ - error, - event, - okCount, - onShowEvent, - phase, - progress, title, - totalCount, user, }: { - error: string; - event: SignedEvent | null; - okCount: number; - onShowEvent: () => void; - phase: PublishPhase; - progress: RelayProgress[]; title: string; - totalCount: number; user: ResolvedUser; }) { const banner = user.profile.banner; @@ -546,7 +671,7 @@ function ProfilePreview({
{banner && ( @@ -592,153 +717,7 @@ function ProfilePreview({ {user.profile.about}

)} - -
-
-
-

- Notificación NIP-17 -

-

- Firmada por La Crypta y publicada desde tu navegador. -

-
- {phase === "done" && ( - - {okCount}/{totalCount} relays - - )} -
- - {progress.length > 0 && ( -
- {progress.map((item) => ( -
-
- - {item.relay.replace(/^wss?:\/\//u, "")} - - - {item.state === "ok" - ? "ok" - : item.state === "error" - ? "error" - : item.state === "publishing" - ? "publicando" - : "pendiente"} - -
-
- -
-
- ))} -
- )} - - {phase === "error" && error && ( -

- {error} -

- )} - {phase === "done" && ( -

- Listo. La notificación fue aceptada por {okCount} relay - {okCount === 1 ? "" : "s"}. -

- )} - {event && ( - - )} -
); } - -function SignedEventModal({ - event, - onClose, - open, -}: { - event: SignedEvent; - onClose: () => void; - open: boolean; -}) { - if (!open) return null; - const json = JSON.stringify(event, null, 2); - return ( -
- -
-
-          {json}
-        
- - - ); -} From 25ce80546172776504e12004cb1221929184b313 Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Tue, 9 Jun 2026 17:31:31 -0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(soldados):=20Nostr=20social=20?= =?UTF-8?q?=E2=80=94=20zaps,=20follows,=20live=20zap=20wall,=20admin=20ran?= =?UTF-8?q?king?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add to the soldiers pages: - Zap button (NIP-57) with amount/message, WebLN auto-pay, QR + LNURL-pay fallback to any valid lightning address (not only NIP-57-advertised ones). - Live "Zaps recibidos" wall: real-time kind-9735 subscription, sender + text. - Follow/unfollow (NIP-02) button with status (Siguiendo / Te sigue / Se siguen), hover "Dejar de seguir", and a styled confirm dialog with the user's avatar. Added to the profile, grid and table views via a batched follows provider. - Grid cards: social-style Nostr banner cover behind the avatar; incomplete profiles (no name/avatar) sort to the end. - Admin "Recrear ranking": recompute the ranking from all sources (curated + Nostr), publish it as a server-signed (LACRYPTA_NSEC) kind-30078 replaceable snapshot, server-cache it, and render snapshot + newer Nostr submissions. Co-Authored-By: Claude Opus 4.8 --- app/api/lnurl-invoice/route.ts | 46 +- app/api/soldiers/ranking/route.ts | 189 +++++++ app/soldados/AdminRepublishRanking.tsx | 120 +++++ app/soldados/ConfirmUnfollow.tsx | 124 +++++ app/soldados/SoldiersClient.tsx | 44 +- app/soldados/SoldiersFollows.tsx | 327 ++++++++++++ app/soldados/SoldiersGrid.tsx | 477 +++++++++++++---- app/soldados/SoldiersTable.tsx | 30 +- app/soldados/[slug]/SoldierFollowButton.tsx | 232 +++++++++ app/soldados/[slug]/SoldierZapButton.tsx | 535 ++++++++++++++++++++ app/soldados/[slug]/SoldierZapWall.tsx | 167 ++++++ app/soldados/[slug]/page.tsx | 21 + lib/follows.ts | 279 ++++++++++ lib/nostrCacheTags.ts | 1 + lib/nostrSoldiersCache.ts | 93 ++++ lib/soldiers.ts | 51 +- lib/soldiersRanking.ts | 77 +++ lib/zap.ts | 359 +++++++++++++ 18 files changed, 3023 insertions(+), 149 deletions(-) create mode 100644 app/api/soldiers/ranking/route.ts create mode 100644 app/soldados/AdminRepublishRanking.tsx create mode 100644 app/soldados/ConfirmUnfollow.tsx create mode 100644 app/soldados/SoldiersFollows.tsx create mode 100644 app/soldados/[slug]/SoldierFollowButton.tsx create mode 100644 app/soldados/[slug]/SoldierZapButton.tsx create mode 100644 app/soldados/[slug]/SoldierZapWall.tsx create mode 100644 lib/follows.ts create mode 100644 lib/nostrSoldiersCache.ts create mode 100644 lib/soldiersRanking.ts create mode 100644 lib/zap.ts diff --git a/app/api/lnurl-invoice/route.ts b/app/api/lnurl-invoice/route.ts index 32f9b5c..8db7145 100644 --- a/app/api/lnurl-invoice/route.ts +++ b/app/api/lnurl-invoice/route.ts @@ -4,6 +4,7 @@ type InvoiceRequestBody = { endpoint?: unknown; amount?: unknown; nostr?: unknown; + comment?: unknown; }; function isAllowedEndpoint(endpoint: string): boolean { @@ -25,10 +26,19 @@ function isAllowedEndpoint(endpoint: string): boolean { } } -async function resolveInvoiceCallback(endpoint: string): Promise { +type ResolvedCallback = { + callback: string; + /** Max comment length the service accepts (LUD-12), or null if unknown. */ + commentAllowed: number | null; +}; + +async function resolveInvoiceCallback( + endpoint: string, +): Promise { const url = new URL(endpoint); if (!url.pathname.toLowerCase().includes("/.well-known/lnurlp/")) { - return endpoint; + // Already a callback URL (e.g. a NIP-57 zap endpoint) — pass through. + return { callback: endpoint, commentAllowed: null }; } const metadataRes = await fetch(endpoint, { @@ -39,6 +49,7 @@ async function resolveInvoiceCallback(endpoint: string): Promise { callback?: unknown; status?: unknown; reason?: unknown; + commentAllowed?: unknown; }; if (!metadataRes.ok || metadata.status === "ERROR") { throw new Error( @@ -50,7 +61,13 @@ async function resolveInvoiceCallback(endpoint: string): Promise { if (typeof metadata.callback !== "string" || !isAllowedEndpoint(metadata.callback)) { throw new Error("La lightning address no devolvió un callback válido."); } - return metadata.callback; + return { + callback: metadata.callback, + commentAllowed: + typeof metadata.commentAllowed === "number" + ? metadata.commentAllowed + : null, + }; } export async function POST(req: Request) { @@ -67,6 +84,7 @@ export async function POST(req: Request) { const endpoint = typeof body.endpoint === "string" ? body.endpoint : ""; const amount = typeof body.amount === "string" ? body.amount : ""; const nostr = typeof body.nostr === "string" ? body.nostr : ""; + const comment = typeof body.comment === "string" ? body.comment : ""; if (!isAllowedEndpoint(endpoint)) { return NextResponse.json( @@ -74,16 +92,16 @@ export async function POST(req: Request) { { status: 400 }, ); } - if (!/^\d+$/.test(amount) || !nostr) { + if (!/^\d+$/.test(amount)) { return NextResponse.json( - { ok: false, reason: "Faltan amount o nostr." }, + { ok: false, reason: "Falta amount." }, { status: 400 }, ); } - let callback: string; + let resolved: ResolvedCallback; try { - callback = await resolveInvoiceCallback(endpoint); + resolved = await resolveInvoiceCallback(endpoint); } catch (error) { return NextResponse.json( { @@ -94,9 +112,19 @@ export async function POST(req: Request) { ); } - const url = new URL(callback); + const url = new URL(resolved.callback); url.searchParams.set("amount", amount); - url.searchParams.set("nostr", nostr); + if (nostr) { + // NIP-57 zap: the signed zap request carries the comment in its content. + url.searchParams.set("nostr", nostr); + } else if (comment) { + // Plain LNURL-pay (LUD-12): include the comment within the allowed length. + const max = resolved.commentAllowed; + const trimmed = max && max > 0 ? comment.slice(0, max) : comment; + if (max === null || max > 0) { + url.searchParams.set("comment", trimmed); + } + } try { const invoiceRes = await fetch(url.toString(), { diff --git a/app/api/soldiers/ranking/route.ts b/app/api/soldiers/ranking/route.ts new file mode 100644 index 0000000..8f564f0 --- /dev/null +++ b/app/api/soldiers/ranking/route.ts @@ -0,0 +1,189 @@ +import { NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import type { SignedEvent } from "@/lib/nostrSigner"; +import { computeRanking } from "@/lib/soldiers"; +import { getFreshNostrSubmissionsSnapshot } from "@/lib/nostrCache"; +import { DEFAULT_RELAYS } from "@/lib/nostrRelayConfig"; +import { + NOSTR_PROJECTS_TAG, + NOSTR_SOLDIERS_RANKING_TAG, +} from "@/lib/nostrCacheTags"; +import { + RANKING_D_TAG, + RANKING_KIND, + RANKING_SCHEMA_VERSION, + RANKING_T_TAG, + serializeRankingSnapshot, + type SoldiersRankingSnapshot, +} from "@/lib/soldiersRanking"; + +const ACTION = "publish-soldiers-ranking"; + +function jsonError(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +async function getBackendSecret(): Promise { + const nsec = process.env.LACRYPTA_NSEC; + if (!nsec) throw new Error("Falta LACRYPTA_NSEC."); + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(nsec); + if (decoded.type !== "nsec") throw new Error("LACRYPTA_NSEC invalido."); + return decoded.data as Uint8Array; +} + +async function getAdminPubkey(): Promise { + const npub = + process.env.NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB || + process.env.NEXT_PUBLIC_LACRYPTA_NPUB; + if (!npub) throw new Error("Falta NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB."); + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(npub); + if (decoded.type !== "npub") { + throw new Error("NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB invalido."); + } + return decoded.data as string; +} + +function requestHasTag( + request: SignedEvent, + name: string, + value: string, +): boolean { + return request.tags.some((tag) => tag[0] === name && tag[1] === value); +} + +async function publishToRelays( + signed: SignedEvent, + relays: string[], + perRelayTimeoutMs = 8000, +): Promise<{ relay: string; ok: boolean; error?: string }[]> { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const promises = pool.publish(relays, signed); + const results = await Promise.all( + relays.map(async (relay, i) => { + try { + await Promise.race([ + promises[i], + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), perRelayTimeoutMs), + ), + ]); + return { relay, ok: true }; + } catch (error) { + return { + relay, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + try { + pool.close(relays); + } catch { + /* noop */ + } + return results; +} + +export async function POST(req: Request) { + let body: { request?: SignedEvent }; + try { + body = (await req.json()) as { request?: SignedEvent }; + } catch { + return jsonError("Body JSON invalido."); + } + const request = body.request; + if (!request) return jsonError("Falta request firmado."); + + try { + const { finalizeEvent, getPublicKey, verifyEvent } = await import( + "nostr-tools/pure" + ); + const secret = await getBackendSecret(); + const issuerPubkey = getPublicKey(secret); + const adminPubkey = await getAdminPubkey(); + + if (!verifyEvent(request)) return jsonError("Request Nostr invalido.", 401); + if (request.pubkey !== adminPubkey) { + return jsonError( + "El usuario logueado debe coincidir con NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB.", + 403, + ); + } + if (Math.abs(Math.floor(Date.now() / 1000) - request.created_at) > 10 * 60) { + return jsonError("Request expirado.", 401); + } + if (!requestHasTag(request, "action", ACTION)) { + return jsonError("Request no autorizado para publicar el ranking.", 401); + } + + // Recompute from ALL sources: curated (static) + fresh Nostr submissions. + const fresh = await getFreshNostrSubmissionsSnapshot(); + const soldiers = computeRanking(fresh.projects); + const nostrCutoff = fresh.projects.reduce( + (max, p) => (p.eventCreatedAt > max ? p.eventCreatedAt : max), + 0, + ); + + const now = Math.floor(Date.now() / 1000); + const snapshot: SoldiersRankingSnapshot = { + version: RANKING_SCHEMA_VERSION, + generatedAt: new Date(now * 1000).toISOString(), + generatedAtUnix: now, + nostrCutoff, + counts: { + soldiers: soldiers.length, + nostr: soldiers.filter((s) => s.hasNostr).length, + }, + soldiers, + }; + + const signed = finalizeEvent( + { + kind: RANKING_KIND, + created_at: now, + content: serializeRankingSnapshot(snapshot), + tags: [ + ["d", RANKING_D_TAG], + ["t", RANKING_T_TAG], + ["client", "La Crypta Dev"], + ["soldiers", String(soldiers.length)], + ], + }, + secret, + ); + + const relayResults = await publishToRelays(signed, DEFAULT_RELAYS); + if (!relayResults.some((r) => r.ok)) { + return NextResponse.json( + { + error: "Ningún relay aceptó el ranking.", + relays: relayResults, + }, + { status: 502 }, + ); + } + + // Bust the server cache so /soldados reads the freshly-published snapshot. + revalidateTag(NOSTR_SOLDIERS_RANKING_TAG, { expire: 0 }); + revalidateTag(NOSTR_PROJECTS_TAG, { expire: 0 }); + + return NextResponse.json({ + ok: true, + eventId: signed.id, + issuerPubkey, + soldierCount: soldiers.length, + generatedAt: snapshot.generatedAt, + relays: relayResults, + }); + } catch (error) { + console.error("[api/soldiers/ranking] failed", error); + return jsonError( + error instanceof Error ? error.message : "No se pudo publicar el ranking.", + 500, + ); + } +} diff --git a/app/soldados/AdminRepublishRanking.tsx b/app/soldados/AdminRepublishRanking.tsx new file mode 100644 index 0000000..1b88eb6 --- /dev/null +++ b/app/soldados/AdminRepublishRanking.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, RefreshCw } from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { getSigner } from "@/lib/nostrSigner"; +import { useToast } from "@/components/Toast"; + +type Step = "idle" | "signing" | "publishing"; + +/** + * Admin-only control on /soldados: recomputes the ranking from all sources + * (curated + Nostr) and publishes it as the official replaceable snapshot. The + * server does the heavy lifting (compute + sign with LACRYPTA_NSEC + publish + + * revalidate); the admin just signs an authorization request. Self-hides for + * anyone whose pubkey isn't the configured La Crypta admin. + */ +export default function AdminRepublishRanking() { + const { auth } = useAuth(); + const { push } = useToast(); + const router = useRouter(); + + const [adminPubkey, setAdminPubkey] = useState(null); + const [step, setStep] = useState("idle"); + + useEffect(() => { + let cancelled = false; + fetch("/api/lacrypta-pubkeys") + .then((res) => (res.ok ? res.json() : null)) + .then((data: { adminPubkey?: string } | null) => { + if (!cancelled) setAdminPubkey(data?.adminPubkey ?? null); + }) + .catch(() => { + if (!cancelled) setAdminPubkey(null); + }); + return () => { + cancelled = true; + }; + }, []); + + const isAdmin = !!auth?.pubkey && !!adminPubkey && auth.pubkey === adminPubkey; + if (!isAdmin) return null; + + const busy = step !== "idle"; + + async function handleClick() { + if (!auth || busy) return; + setStep("signing"); + try { + const signer = await getSigner(auth); + const request = await signer.signEvent({ + kind: 27235, + pubkey: signer.pubkey, + created_at: Math.floor(Date.now() / 1000), + content: "Recompute & publish soldiers ranking", + tags: [ + ["u", "/api/soldiers/ranking"], + ["method", "POST"], + ["action", "publish-soldiers-ranking"], + ], + }); + + setStep("publishing"); + const res = await fetch("/api/soldiers/ranking", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request }), + }); + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean; + soldierCount?: number; + error?: string; + }; + if (!res.ok || !data.ok) { + throw new Error(data.error || "No se pudo publicar el ranking."); + } + + push({ + kind: "success", + title: "Ranking publicado", + description: `${data.soldierCount ?? 0} soldados actualizados y cacheados.`, + }); + router.refresh(); + } catch (error) { + push({ + kind: "error", + title: "No se pudo recrear el ranking", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + } finally { + setStep("idle"); + } + } + + const label = + step === "signing" + ? "Firmando…" + : step === "publishing" + ? "Publicando…" + : "Recrear ranking"; + + return ( + + ); +} diff --git a/app/soldados/ConfirmUnfollow.tsx b/app/soldados/ConfirmUnfollow.tsx new file mode 100644 index 0000000..56cf333 --- /dev/null +++ b/app/soldados/ConfirmUnfollow.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect } from "react"; +import { Loader2, UserMinus, X } from "lucide-react"; + +function unfollowInitials(name: string): string { + const parts = name + .replace(/[^\p{L}\p{N}\s]+/gu, " ") + .trim() + .split(/\s+/); + if (parts.length === 0 || !parts[0]) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0]! + parts[1]![0]!).toUpperCase(); +} + +/** Styled confirmation dialog shown before removing someone from your follows. */ +export default function ConfirmUnfollow({ + open, + busy, + targetName, + targetAvatar, + onCancel, + onConfirm, +}: { + open: boolean; + busy: boolean; + targetName: string; + targetAvatar?: string | null; + onCancel: () => void; + onConfirm: () => void; +}) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onCancel]); + + if (!open) return null; + + return ( +
+ + +
+ {/* Avatar with a danger ring + unfollow badge. */} +
+ + {targetAvatar ? ( + // eslint-disable-next-line @next/next/no-img-element + {targetName} + ) : ( + + {unfollowInitials(targetName)} + + )} + + + +
+ +

+ ¿Dejar de seguir? +

+

+ Vas a dejar de seguir a{" "} + {targetName}. + Se actualizará tu lista de contactos en Nostr. +

+ +
+ + +
+
+
+ + ); +} diff --git a/app/soldados/SoldiersClient.tsx b/app/soldados/SoldiersClient.tsx index 56f0128..e09f90b 100644 --- a/app/soldados/SoldiersClient.tsx +++ b/app/soldados/SoldiersClient.tsx @@ -6,11 +6,14 @@ import { cn } from "@/lib/cn"; import type { Soldier } from "@/lib/soldiers"; import SoldiersGrid from "./SoldiersGrid"; import SoldiersTable from "./SoldiersTable"; +import { SoldiersFollowsProvider } from "./SoldiersFollows"; +import AdminRepublishRanking from "./AdminRepublishRanking"; type View = "grid" | "table"; export default function SoldiersClient({ soldiers }: { soldiers: Soldier[] }) { const [view, setView] = useState("table"); + const pubkeys = soldiers.map((s) => s.pubkey); return (
@@ -22,27 +25,32 @@ export default function SoldiersClient({ soldiers }: { soldiers: Soldier[] }) { builders en la comunidad {view === "table" && " · ranking por score"}.

-
- setView("table")} - icon={} - label="Ranking" - /> - setView("grid")} - icon={} - label="Grilla" - /> +
+ +
+ setView("table")} + icon={} + label="Ranking" + /> + setView("grid")} + icon={} + label="Grilla" + /> +
- {view === "table" ? ( - - ) : ( - - )} + + {view === "table" ? ( + + ) : ( + + )} +
); } diff --git a/app/soldados/SoldiersFollows.tsx b/app/soldados/SoldiersFollows.tsx new file mode 100644 index 0000000..b1d4a86 --- /dev/null +++ b/app/soldados/SoldiersFollows.tsx @@ -0,0 +1,327 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { Loader2, UserCheck, UserMinus, UserPlus, Users } from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { useToast } from "@/components/Toast"; +import { + fetchContactList, + fetchContactLists, + getCachedContactList, + onContactsChanged, + setFollow, +} from "@/lib/follows"; +import { useScrollLock } from "@/lib/useScrollLock"; +import { cn } from "@/lib/cn"; +import LoginModal from "@/components/LoginModal"; +import ConfirmUnfollow from "./ConfirmUnfollow"; + +function isHexPubkey(value?: string): value is string { + return !!value && /^[0-9a-f]{64}$/iu.test(value); +} + +type FollowsContextValue = { + me: string | null; + /** Pubkeys the logged-in user follows. */ + follows: Set; + /** Pubkeys (among the provided list) that follow the logged-in user back. */ + followsMe: Set; + /** Optimistically flip a follow locally; reverted by the caller on error. */ + setLocalFollow: (pubkey: string, value: boolean) => void; +}; + +const FollowsContext = createContext(null); + +/** + * Shares follow state across the whole soldiers list so we make at most two + * batched relay round-trips (my contact list + everyone else's) instead of one + * per card/row. + */ +export function SoldiersFollowsProvider({ + pubkeys, + children, +}: { + pubkeys: (string | undefined)[]; + children: ReactNode; +}) { + const { auth } = useAuth(); + const me = auth?.pubkey ?? null; + + const targets = useMemo( + () => [...new Set(pubkeys.filter(isHexPubkey))], + [pubkeys], + ); + const targetsKey = targets.join(","); + + const [follows, setFollows] = useState>(new Set()); + const [followsMe, setFollowsMe] = useState>(new Set()); + + // My own follow set (seed from cache, then confirm against relays). + useEffect(() => { + if (!me) { + setFollows(new Set()); + return; + } + const cached = getCachedContactList(me); + if (cached) setFollows(new Set(cached.follows)); + + let cancelled = false; + fetchContactList(me) + .then((list) => { + if (!cancelled && list) setFollows(new Set(list.follows)); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [me]); + + // Who (among the listed soldiers) follows me back. + useEffect(() => { + if (!me || targets.length === 0) { + setFollowsMe(new Set()); + return; + } + let cancelled = false; + fetchContactLists(targets) + .then((map) => { + if (cancelled) return; + const next = new Set(); + for (const [pk, list] of map) { + if (list.follows.includes(me)) next.add(pk); + } + setFollowsMe(next); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [me, targetsKey, targets]); + + // Keep my follow set in sync with changes elsewhere. + useEffect(() => { + if (!me) return; + return onContactsChanged((pk) => { + if (pk !== me) return; + const cached = getCachedContactList(me); + if (cached) setFollows(new Set(cached.follows)); + }); + }, [me]); + + const setLocalFollow = useCallback((pubkey: string, value: boolean) => { + setFollows((prev) => { + const next = new Set(prev); + if (value) next.add(pubkey); + else next.delete(pubkey); + return next; + }); + }, []); + + const value = useMemo( + () => ({ me, follows, followsMe, setLocalFollow }), + [me, follows, followsMe, setLocalFollow], + ); + + return ( + {children} + ); +} + +function useFollows(): FollowsContextValue | null { + return useContext(FollowsContext); +} + +/** Idle/hover label pair. In compact mode the text only appears from `lg` up. */ +function LabelSwap({ + compact, + idle, + hover, +}: { + compact: boolean; + idle: string; + hover: string; +}) { + const inner = ( + <> + {idle} + {hover} + + ); + if (!compact) return inner; + return {inner}; +} + +/** + * Compact follow control + relationship status for list/grid rows. Reads shared + * state from SoldiersFollowsProvider; renders nothing on your own entry. + */ +export function InlineFollowButton({ + pubkey, + name, + avatar, + className, + compact = false, +}: { + pubkey: string; + name: string; + avatar?: string | null; + className?: string; + /** Icon-only until `lg` to fit dense layouts like the ranking table. */ + compact?: boolean; +}) { + const ctx = useFollows(); + const { auth } = useAuth(); + const { push } = useToast(); + + const [busy, setBusy] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [loginOpen, setLoginOpen] = useState(false); + const [pendingFollow, setPendingFollow] = useState(false); + + useScrollLock(confirmOpen); + + const me = ctx?.me ?? null; + const isSelf = !!me && me === pubkey; + const iFollow = ctx?.follows.has(pubkey) ?? false; + const theyFollowMe = ctx?.followsMe.has(pubkey) ?? false; + const mutual = iFollow && theyFollowMe; + + const apply = useCallback( + async (next: boolean) => { + if (!auth) return; + setBusy(true); + ctx?.setLocalFollow(pubkey, next); + try { + await setFollow(auth, pubkey, next); + push({ + kind: "success", + title: next ? `Siguiendo a ${name}` : `Dejaste de seguir a ${name}`, + }); + } catch (error) { + ctx?.setLocalFollow(pubkey, !next); + push({ + kind: "error", + title: "No se pudo actualizar el seguimiento", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + } finally { + setBusy(false); + } + }, + [auth, ctx, pubkey, name, push], + ); + + // Resume a follow intent deferred behind the login flow. + useEffect(() => { + if (auth && pendingFollow) { + setPendingFollow(false); + setLoginOpen(false); + void apply(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [auth, pendingFollow]); + + if (isSelf) return null; + + function handleClick() { + if (!auth) { + setPendingFollow(true); + setLoginOpen(true); + return; + } + if (busy) return; + if (iFollow) setConfirmOpen(true); + else void apply(true); + } + + const pillVis = compact ? "lg:inline-flex" : "sm:inline-flex"; + + return ( +
+ {mutual ? ( + + + Se siguen + + ) : theyFollowMe ? ( + + Te sigue + + ) : null} + + + + { + setLoginOpen(false); + setPendingFollow(false); + }} + /> + + setConfirmOpen(false)} + onConfirm={() => { + setConfirmOpen(false); + void apply(false); + }} + /> +
+ ); +} diff --git a/app/soldados/SoldiersGrid.tsx b/app/soldados/SoldiersGrid.tsx index df2f7cc..019a3f6 100644 --- a/app/soldados/SoldiersGrid.tsx +++ b/app/soldados/SoldiersGrid.tsx @@ -1,11 +1,17 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { Zap, Trophy } from "lucide-react"; import { GithubIcon } from "@/components/BrandIcons"; import { HACKATHON_LABELS } from "@/lib/projects"; +import { getCachedProfile, type NostrProfile } from "@/lib/nostrProfile"; import { cn } from "@/lib/cn"; import type { Soldier } from "@/lib/soldiers"; +import { InlineFollowButton } from "./SoldiersFollows"; -function avatarSrc(s: Soldier): string | null { +function avatarSrc(s: Soldier, profilePicture?: string): string | null { + if (profilePicture) return profilePicture; if (s.picture) return s.picture; if (s.github) return `https://github.com/${s.github}.png?size=160`; return null; @@ -21,6 +27,33 @@ function initials(name: string): string { return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); } +/** A name with no human-readable info — a bare key, "Anónimo", or hex stub. */ +function isPlaceholderName(name: string): boolean { + const n = name.trim(); + if (!n || n.toLowerCase() === "anónimo" || n.toLowerCase() === "anonimo") { + return true; + } + if (/^npub1[0-9a-z]+/i.test(n)) return true; + if (/^[0-9a-f]{8,}$/i.test(n)) return true; // pure hex + if (/^[0-9a-f]{6,}.*[…]/i.test(n)) return true; // truncated hex (e.g. "13e700e2…") + return false; +} + +function displayNameFor(s: Soldier, profile?: NostrProfile | null): string { + if (!isPlaceholderName(s.name)) return s.name; + return profile?.display_name || profile?.name || s.name; +} + +/** + * Higher = more complete. Soldiers missing an avatar and/or a real name sort + * toward the end of the grid. 3 = name + avatar, 0 = neither. + */ +function completenessScore(s: Soldier, profile?: NostrProfile | null): number { + const hasName = !isPlaceholderName(displayNameFor(s, profile)); + const hasAvatar = !!(profile?.picture || s.picture || s.github); + return (hasName ? 2 : 0) + (hasAvatar ? 1 : 0); +} + function uniqHackathons(s: Soldier): string[] { const set = new Set(); for (const p of s.projects) if (p.hackathonId) set.add(p.hackathonId); @@ -34,7 +67,87 @@ function hackathonLabel(id: string): string { return id; } +function isHexPubkey(pubkey?: string): pubkey is string { + return !!pubkey && /^[0-9a-f]{64}$/i.test(pubkey); +} + +/** Load Nostr profiles for the whole grid (one batched request + cache). */ +function useGridProfiles(soldiers: Soldier[]) { + const [profiles, setProfiles] = useState>({}); + + const pubkeys = useMemo( + () => soldiers.map((s) => s.pubkey).filter(isHexPubkey), + [soldiers], + ); + + useEffect(() => { + if (pubkeys.length === 0) return; + + // 1. Hydrate instantly from any locally-cached profiles. + const seeded: Record = {}; + for (const pk of pubkeys) { + const cached = getCachedProfile(pk); + if (cached) seeded[pk] = cached.profile; + } + if (Object.keys(seeded).length) { + setProfiles((prev) => ({ ...seeded, ...prev })); + } + + // 2. Fetch the rest from the server batch endpoint (chunked at 50). + let cancelled = false; + (async () => { + for (let i = 0; i < pubkeys.length; i += 50) { + const chunk = pubkeys.slice(i, i + 50); + try { + const res = await fetch( + `/api/nostr/profiles?pubkeys=${chunk.join(",")}`, + { cache: "no-store" }, + ); + if (!res.ok) continue; + const data = (await res.json()) as { + profiles?: Record; + }; + if (cancelled) return; + const incoming = data.profiles ?? {}; + setProfiles((prev) => { + const next = { ...prev }; + for (const pk of chunk) { + const p = incoming[pk]; + if (p) next[pk] = p; + } + return next; + }); + } catch { + /* ignore — keep whatever we already have */ + } + } + })(); + + return () => { + cancelled = true; + }; + }, [pubkeys]); + + return profiles; +} + export default function SoldiersGrid({ soldiers }: { soldiers: Soldier[] }) { + const profiles = useGridProfiles(soldiers); + + // Stable sort: complete profiles first, incomplete (missing avatar/name) + // pushed to the end. Ties keep the original (server-provided) order. + const ordered = useMemo(() => { + return soldiers + .map((s, index) => ({ s, index })) + .sort((a, b) => { + const ca = completenessScore(a.s, profiles[a.s.pubkey ?? ""]); + const cb = completenessScore(b.s, profiles[b.s.pubkey ?? ""]); + if (ca !== cb) return cb - ca; + return a.index - b.index; + }) + .map((x) => x.s); + }, [soldiers, profiles]); + if (soldiers.length === 0) { return (
@@ -45,147 +158,287 @@ export default function SoldiersGrid({ soldiers }: { soldiers: Soldier[] }) { return (
    - {soldiers.map((s) => ( - + {ordered.map((s) => ( + ))}
); } -function SoldierCard({ soldier }: { soldier: Soldier }) { - const src = avatarSrc(soldier); +/** Max tilt in degrees at the card edges. */ +const TILT_MAX = 12; + +/** + * Mouse-tracking 3D tilt. The card rotates in perspective toward the cursor + * and exposes CSS custom properties (`--rx`, `--ry`, `--mx`, `--my`, `--tz`) + * that the markup binds to its transform + glare. Updates are coalesced with + * requestAnimationFrame and written straight to the DOM, so dragging the mouse + * never triggers a React re-render. No-ops under prefers-reduced-motion. + */ +function useTilt() { + const ref = useRef(null); + const frame = useRef(null); + const reduced = useRef(false); + + useEffect(() => { + reduced.current = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + return () => { + if (frame.current) cancelAnimationFrame(frame.current); + }; + }, []); + + const onMouseMove = useCallback((e: React.MouseEvent) => { + if (reduced.current) return; + const el = ref.current; + if (!el) return; + const { clientX, clientY } = e; + if (frame.current) cancelAnimationFrame(frame.current); + frame.current = requestAnimationFrame(() => { + const r = el.getBoundingClientRect(); + const px = (clientX - r.left) / r.width; // 0 (left) … 1 (right) + const py = (clientY - r.top) / r.height; // 0 (top) … 1 (bottom) + el.style.setProperty("--ry", `${(px - 0.5) * TILT_MAX}deg`); + el.style.setProperty("--rx", `${(0.5 - py) * TILT_MAX}deg`); + el.style.setProperty("--mx", `${px * 100}%`); + el.style.setProperty("--my", `${py * 100}%`); + }); + }, []); + + const onMouseEnter = useCallback(() => { + if (reduced.current) return; + const el = ref.current; + if (!el) return; + el.style.transition = "transform 100ms ease-out"; + el.style.setProperty("--tz", "1.04"); + }, []); + + const onMouseLeave = useCallback(() => { + const el = ref.current; + if (!el) return; + if (frame.current) cancelAnimationFrame(frame.current); + // Smooth, springy settle back to flat. + el.style.transition = "transform 600ms cubic-bezier(0.22, 1, 0.36, 1)"; + el.style.setProperty("--rx", "0deg"); + el.style.setProperty("--ry", "0deg"); + el.style.setProperty("--tz", "1"); + }, []); + + return { ref, onMouseMove, onMouseEnter, onMouseLeave }; +} + +function SoldierCard({ + soldier, + profile, +}: { + soldier: Soldier; + profile?: NostrProfile | null; +}) { + const tilt = useTilt(); + const src = avatarSrc(soldier, profile?.picture); + const banner = profile?.banner; + const name = displayNameFor(soldier, profile); const hackathons = uniqHackathons(soldier); return (
  • - {soldier.hasNostr && ( + {/* Glare highlight that tracks the cursor, floating above the card. */} + + {/* Cover (social-network style) */} + + {banner ? ( + // eslint-disable-next-line @next/next/no-img-element + { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + )} + {/* Fade the cover into the card so the avatar/name stay legible. */} - )} + -
    - - {src ? ( - // eslint-disable-next-line @next/next/no-img-element - {soldier.name} - ) : ( - - {initials(soldier.name)} - - )} - -
    -
    - - {soldier.name} - - {soldier.hasNostr && ( - - +
    +
    + + {src ? ( + // eslint-disable-next-line @next/next/no-img-element + {name} + ) : ( + + {initials(name)} )} + +
    +
    + + {name} + + {soldier.hasNostr && ( + + + + )} +
    + {soldier.github && ( + + + {soldier.github} + + )}
    - {soldier.github && ( - - - {soldier.github} - - )}
    -
    -
    - {soldier.projects.length}{" "} - {soldier.projects.length === 1 ? "proyecto" : "proyectos"} - {hackathons.length > 0 && ( - <> - {" · "} - {hackathons.length}{" "} - {hackathons.length === 1 ? "hackatón" : "hackatones"} - + {isHexPubkey(soldier.pubkey) && ( + )} -
    - {hackathons.length > 0 && ( -
    - {hackathons.map((h) => ( - - - {hackathonLabel(h)} - - ))} +
    + {soldier.projects.length}{" "} + {soldier.projects.length === 1 ? "proyecto" : "proyectos"} + {hackathons.length > 0 && ( + <> + {" · "} + {hackathons.length}{" "} + {hackathons.length === 1 ? "hackatón" : "hackatones"} + + )}
    - )} -
      - {soldier.projects.slice(0, 4).map((p, i) => { - const href = p.hackathonId - ? `/hackathons/${p.hackathonId}/${p.projectId}` - : p.source === "nostr" && p.authorPubkey - ? `/projects/${p.authorPubkey}/${p.projectId}` - : `/projects/${p.projectId}`; - return ( -
    • + {hackathons.length > 0 && ( +
      + {hackathons.map((h) => ( - - {p.projectName} - - · {p.role} - + + {hackathonLabel(h)} -
    • - ); - })} - {soldier.projects.length > 4 && ( -
    • - - +{soldier.projects.length - 4} más - -
    • + ))} +
    )} - + +
      + {soldier.projects.slice(0, 4).map((p, i) => { + const href = p.hackathonId + ? `/hackathons/${p.hackathonId}/${p.projectId}` + : p.source === "nostr" && p.authorPubkey + ? `/projects/${p.authorPubkey}/${p.projectId}` + : `/projects/${p.projectId}`; + return ( +
    • + + + {p.projectName} + + · {p.role} + + +
    • + ); + })} + {soldier.projects.length > 4 && ( +
    • + + +{soldier.projects.length - 4} más + +
    • + )} +
    +
  • ); } diff --git a/app/soldados/SoldiersTable.tsx b/app/soldados/SoldiersTable.tsx index 5b809df..8a1bf73 100644 --- a/app/soldados/SoldiersTable.tsx +++ b/app/soldados/SoldiersTable.tsx @@ -4,6 +4,11 @@ import { Trophy, Zap } from "lucide-react"; import { GithubIcon } from "@/components/BrandIcons"; import { cn } from "@/lib/cn"; import type { Soldier } from "@/lib/soldiers"; +import { InlineFollowButton } from "./SoldiersFollows"; + +function isHexPubkey(pubkey?: string): pubkey is string { + return !!pubkey && /^[0-9a-f]{64}$/iu.test(pubkey); +} // Mirrors the scoring constants in lib/soldiers.ts. Kept local to render // the per-cell breakdown without re-exporting internals from that module. @@ -98,6 +103,7 @@ export default function SoldiersTable({ soldiers }: { soldiers: Soldier[] }) { Medallas Mejor Score + @@ -315,6 +321,17 @@ export default function SoldiersTable({ soldiers }: { soldiers: Soldier[] }) { + + {isHexPubkey(s.pubkey) && ( + + )} + ); })} @@ -328,10 +345,10 @@ export default function SoldiersTable({ soldiers }: { soldiers: Soldier[] }) { const src = avatarSrc(s); const bp = bestPosition(s); return ( -
  • +
  • + {isHexPubkey(s.pubkey) && ( + + )}
  • ); })} diff --git a/app/soldados/[slug]/SoldierFollowButton.tsx b/app/soldados/[slug]/SoldierFollowButton.tsx new file mode 100644 index 0000000..caa3c0e --- /dev/null +++ b/app/soldados/[slug]/SoldierFollowButton.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, UserCheck, UserMinus, UserPlus, Users } from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { useToast } from "@/components/Toast"; +import { + fetchFollowStatus, + getCachedContactList, + onContactsChanged, + setFollow, + type FollowStatus, +} from "@/lib/follows"; +import { useScrollLock } from "@/lib/useScrollLock"; +import { cn } from "@/lib/cn"; +import LoginModal from "@/components/LoginModal"; +import ConfirmUnfollow from "../ConfirmUnfollow"; + +export default function SoldierFollowButton({ + recipientPubkey, + recipientName, + recipientAvatar, +}: { + recipientPubkey: string; + recipientName: string; + recipientAvatar?: string | null; +}) { + const { auth, ready } = useAuth(); + const { push } = useToast(); + const me = auth?.pubkey ?? null; + const isSelf = !!me && me === recipientPubkey; + + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [loginOpen, setLoginOpen] = useState(false); + const [pendingFollow, setPendingFollow] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + + useScrollLock(confirmOpen); + + // Load the follow relationship once we know who the viewer is. + useEffect(() => { + if (!ready) return; + if (!me || isSelf) { + setStatus(null); + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + + // Instant optimistic seed from the cached contact list. + const cached = getCachedContactList(me); + if (cached) { + setStatus((prev) => ({ + iFollow: cached.follows.includes(recipientPubkey), + followsMe: prev?.followsMe ?? false, + })); + } + + fetchFollowStatus(me, recipientPubkey) + .then((res) => { + if (!cancelled) setStatus(res); + }) + .catch(() => { + /* keep whatever the cache gave us */ + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [ready, me, isSelf, recipientPubkey]); + + // Keep `iFollow` in sync when the list changes elsewhere (another tab, the + // dashboard, a second follow button on screen). + useEffect(() => { + if (!me || isSelf) return; + return onContactsChanged((pk) => { + if (pk !== me) return; + const cached = getCachedContactList(me); + if (!cached) return; + setStatus((prev) => ({ + iFollow: cached.follows.includes(recipientPubkey), + followsMe: prev?.followsMe ?? false, + })); + }); + }, [me, isSelf, recipientPubkey]); + + const doFollow = useCallback( + async (next: boolean) => { + if (!auth) return; + setBusy(true); + setStatus((prev) => ({ + iFollow: next, + followsMe: prev?.followsMe ?? false, + })); + try { + await setFollow(auth, recipientPubkey, next); + push({ + kind: "success", + title: next + ? `Siguiendo a ${recipientName}` + : `Dejaste de seguir a ${recipientName}`, + }); + } catch (error) { + // Revert the optimistic flip. + setStatus((prev) => ({ + iFollow: !next, + followsMe: prev?.followsMe ?? false, + })); + push({ + kind: "error", + title: "No se pudo actualizar el seguimiento", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + } finally { + setBusy(false); + } + }, + [auth, recipientPubkey, recipientName, push], + ); + + // Resume a follow intent that was deferred behind the login flow. + const pendingRef = useRef(pendingFollow); + pendingRef.current = pendingFollow; + useEffect(() => { + if (auth && pendingRef.current) { + setPendingFollow(false); + setLoginOpen(false); + void doFollow(true); + } + }, [auth, doFollow]); + + function handleClick() { + if (!auth) { + setPendingFollow(true); + setLoginOpen(true); + return; + } + if (busy) return; + // Following already → ask for confirmation before unfollowing. + if (status?.iFollow) { + setConfirmOpen(true); + return; + } + void doFollow(true); + } + + // Don't render a follow button on your own profile. + if (isSelf) return null; + + const iFollow = status?.iFollow ?? false; + const followsMe = status?.followsMe ?? false; + const mutual = iFollow && followsMe; + + return ( + <> +
    + + + {/* Relationship status badges. */} + {mutual ? ( + + + Se siguen + + ) : followsMe ? ( + + + Te sigue + + ) : null} +
    + + { + setLoginOpen(false); + setPendingFollow(false); + }} + /> + + setConfirmOpen(false)} + onConfirm={() => { + setConfirmOpen(false); + void doFollow(false); + }} + /> + + ); +} diff --git a/app/soldados/[slug]/SoldierZapButton.tsx b/app/soldados/[slug]/SoldierZapButton.tsx new file mode 100644 index 0000000..48616b2 --- /dev/null +++ b/app/soldados/[slug]/SoldierZapButton.tsx @@ -0,0 +1,535 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Check, Copy, Loader2, X, Zap } from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { getSigner } from "@/lib/nostrSigner"; +import { + buildZapRequest, + createAnonymousSigner, + getZapRecipientInfo, + isWeblnAvailable, + payInvoiceWithWebln, + requestZapInvoice, + type ZapRecipientInfo, +} from "@/lib/zap"; +import { useToast } from "@/components/Toast"; +import { useScrollLock } from "@/lib/useScrollLock"; +import { cn } from "@/lib/cn"; + +const PRESET_AMOUNTS = [21, 100, 500, 1000, 5000, 21000]; +const DEFAULT_AMOUNT = 1000; + +type Stage = "form" | "invoice" | "done"; + +function formatSats(n: number): string { + return n.toLocaleString("es-AR"); +} + +export default function SoldierZapButton({ + recipientPubkey, + recipientName, + lud16, +}: { + recipientPubkey: string; + recipientName: string; + lud16?: string | null; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} + recipientPubkey={recipientPubkey} + recipientName={recipientName} + lud16={lud16 ?? null} + /> + + ); +} + +function ZapModal({ + open, + onClose, + recipientPubkey, + recipientName, + lud16, +}: { + open: boolean; + onClose: () => void; + recipientPubkey: string; + recipientName: string; + lud16: string | null; +}) { + const { auth } = useAuth(); + const { push } = useToast(); + useScrollLock(open); + + const [stage, setStage] = useState("form"); + const [amount, setAmount] = useState(DEFAULT_AMOUNT); + const [customAmount, setCustomAmount] = useState(""); + const [comment, setComment] = useState(""); + const [info, setInfo] = useState(null); + const [infoError, setInfoError] = useState(null); + const [loadingInfo, setLoadingInfo] = useState(false); + const [busy, setBusy] = useState(false); + const [invoice, setInvoice] = useState(""); + const [weblnAvailable, setWeblnAvailable] = useState(false); + + // Reset + discover the recipient's zap endpoint each time the modal opens. + useEffect(() => { + if (!open) return; + setStage("form"); + setAmount(DEFAULT_AMOUNT); + setCustomAmount(""); + setComment(""); + setInvoice(""); + setInfo(null); + setInfoError(null); + setWeblnAvailable(isWeblnAvailable()); + + let cancelled = false; + setLoadingInfo(true); + getZapRecipientInfo(recipientPubkey) + .then((result) => { + if (cancelled) return; + setInfo(result); + if (!result.zapEndpoint && !result.payEndpoint) { + setInfoError( + "Este soldado no tiene una lightning address para recibir pagos.", + ); + } + }) + .catch((error: unknown) => { + if (cancelled) return; + setInfoError( + error instanceof Error + ? error.message + : "No se pudo cargar la info de zaps.", + ); + }) + .finally(() => { + if (!cancelled) setLoadingInfo(false); + }); + return () => { + cancelled = true; + }; + }, [open, recipientPubkey]); + + // Close on Escape. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + const effectiveAmount = useMemo(() => { + const parsed = parseInt(customAmount, 10); + if (customAmount.trim() && Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return amount; + }, [amount, customAmount]); + + if (!open) return null; + + async function handleGenerate() { + const endpoint = info?.zapEndpoint ?? info?.payEndpoint; + if (!endpoint) return; + if (effectiveAmount <= 0) { + push({ kind: "error", title: "Elegí un monto válido en sats." }); + return; + } + setBusy(true); + try { + // When the recipient advertises NIP-57, sign a zap request (logged-in + // users from their own key, otherwise an ephemeral anonymous key). For a + // plain lightning address we just send a regular LNURL-pay with comment. + let zapRequest = null; + if (info?.supportsNostr && info.zapEndpoint) { + const signer = auth + ? await getSigner(auth) + : await createAnonymousSigner(); + zapRequest = await signer.signEvent( + buildZapRequest({ + senderPubkey: signer.pubkey, + recipientPubkey, + sats: effectiveAmount, + comment: comment.trim(), + }), + ); + } + const pr = await requestZapInvoice({ + endpoint, + sats: effectiveAmount, + zapRequest, + comment: comment.trim(), + }); + setInvoice(pr); + setStage("invoice"); + + // If WebLN is present, try to pay straight away. + if (isWeblnAvailable()) { + await handleWebln(pr); + } + } catch (error) { + push({ + kind: "error", + title: "No se pudo generar el pago", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + } finally { + setBusy(false); + } + } + + async function handleWebln(pr: string) { + setBusy(true); + try { + await payInvoiceWithWebln(pr); + setStage("done"); + push({ + kind: "success", + title: "⚡ Zap enviado", + description: `${formatSats(effectiveAmount)} sats a ${recipientName}.`, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/cancel|reject|denied|declin|abort|user/i.test(message)) { + push({ + kind: "error", + title: "El pago con WebLN falló", + description: message, + }); + } + } finally { + setBusy(false); + } + } + + return ( +
    + +
    + +
    + {loadingInfo ? ( +
    + + Buscando endpoint de zaps… +
    + ) : infoError ? ( +

    + {infoError} +

    + ) : stage === "form" ? ( + + ) : stage === "invoice" ? ( + handleWebln(invoice)} + /> + ) : ( + + )} +
    +
    + + ); +} + +function ZapForm({ + lightningAddress, + amount, + setAmount, + customAmount, + setCustomAmount, + comment, + setComment, + effectiveAmount, + weblnAvailable, + busy, + onGenerate, +}: { + lightningAddress?: string | null; + amount: number; + setAmount: (n: number) => void; + customAmount: string; + setCustomAmount: (s: string) => void; + comment: string; + setComment: (s: string) => void; + effectiveAmount: number; + weblnAvailable: boolean; + busy: boolean; + onGenerate: () => void; +}) { + return ( +
    + {lightningAddress && ( +

    + + {lightningAddress} +

    + )} + +
    + +
    + {PRESET_AMOUNTS.map((preset) => { + const active = !customAmount.trim() && amount === preset; + return ( + + ); + })} +
    + setCustomAmount(e.target.value)} + placeholder="Otro monto…" + className="mt-2 w-full rounded-xl border border-border bg-white/[0.03] px-3 py-2 text-sm font-mono tabular-nums outline-none focus:border-lightning/60" + /> +
    + +
    + +