diff --git a/app/api/nostr-opportunity-notification/route.ts b/app/api/nostr-opportunity-notification/route.ts new file mode 100644 index 0000000..5ca4fed --- /dev/null +++ b/app/api/nostr-opportunity-notification/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; + +type NotificationBody = { + handle?: string; + recipientPubkey?: string; +}; + +function jsonError(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +async function getBackendSecret() { + 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; +} + +function isHexPubkey(value: string): boolean { + return /^[0-9a-f]{64}$/iu.test(value); +} + +export async function POST(req: Request) { + let body: NotificationBody; + try { + body = (await req.json()) as NotificationBody; + } catch { + return jsonError("Body JSON invalido."); + } + + const recipientPubkey = body.recipientPubkey?.trim().toLowerCase(); + if (!recipientPubkey || !isHexPubkey(recipientPubkey)) { + return jsonError("Falta recipientPubkey valido."); + } + + try { + const secret = await getBackendSecret(); + const { finalizeEvent, getPublicKey } = await import("nostr-tools/pure"); + const { encrypt } = await import("nostr-tools/nip04"); + const senderPubkey = getPublicKey(secret); + const handle = body.handle?.trim(); + const message = [ + "Hola! Te anotaste para recibir oportunidades de La Crypta Dev.", + "Vamos a enviarte hackatones, proyectos y oportunidades relevantes por Nostr.", + handle ? `Identificador: ${handle}` : null, + ] + .filter(Boolean) + .join("\n\n"); + const content = encrypt(secret, recipientPubkey, message); + const event = finalizeEvent( + { + kind: 4, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["p", recipientPubkey], + ["t", "lacrypta-dev"], + ["t", "opportunities"], + ], + content, + }, + secret, + ); + + return NextResponse.json({ event, senderPubkey }); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : "No se pudo firmar la notificacion.", + 500, + ); + } +} diff --git a/components/sections/NewsletterCTA.tsx b/components/sections/NewsletterCTA.tsx index b369047..d1015b0 100644 --- a/components/sections/NewsletterCTA.tsx +++ b/components/sections/NewsletterCTA.tsx @@ -1,47 +1,263 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react"; import { motion } from "framer-motion"; -import { ArrowRight, Check, Mail, Sparkles } from "lucide-react"; +import { + AlertCircle, + ArrowRight, + Check, + Code2, + Loader2, + Sparkles, + UserRoundSearch, + X, +} from "lucide-react"; +import { fetchNostrProfile, type NostrProfile } from "@/lib/nostrProfile"; +import { DEFAULT_RELAYS, mergeDataRelays } from "@/lib/nostrRelayConfig"; +import type { SignedEvent } from "@/lib/nostrSigner"; +import { cn } from "@/lib/cn"; -type Status = "idle" | "submitting" | "success" | "error"; +type ResolveStatus = "idle" | "resolving" | "found" | "missing" | "error"; +type PublishPhase = "idle" | "signing" | "publishing" | "done" | "error"; + +type ResolvedUser = { + handle: string; + inputType: "npub" | "nip05"; + profile: NostrProfile; + pubkey: string; + relays: string[]; +}; + +type RelayProgress = { + error?: string; + ok?: boolean; + relay: string; + state: "pending" | "publishing" | "ok" | "error"; +}; + +function shortPubkey(pubkey: string): string { + return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`; +} + +function displayName(user: ResolvedUser): string { + return ( + user.profile.display_name?.trim() || + user.profile.name?.trim() || + user.profile.nip05?.trim() || + user.handle || + shortPubkey(user.pubkey) + ); +} + +function initials(name: string): string { + return name + .split(/\s+/u) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(""); +} + +async function resolveInput(raw: string): Promise { + const value = raw.trim(); + if (!value) throw new Error("Pegá un npub o NIP-05."); + + let pubkey = ""; + let relays: string[] = []; + let inputType: ResolvedUser["inputType"] = "nip05"; + const normalized = value.toLowerCase(); + + if (normalized.startsWith("npub1")) { + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(normalized); + if (decoded.type !== "npub") throw new Error("npub invalido."); + pubkey = decoded.data as string; + inputType = "npub"; + } else { + const { queryProfile } = await import("nostr-tools/nip05"); + const pointer = await queryProfile(normalized); + if (!pointer?.pubkey) { + throw new Error("No pudimos resolver ese NIP-05."); + } + pubkey = pointer.pubkey; + relays = pointer.relays ?? []; + } + + const readRelays = mergeDataRelays(DEFAULT_RELAYS, relays); + const cached = await fetchNostrProfile(pubkey, readRelays, 4500); + if (!cached) { + throw new Error("La npub se resolvio, pero no encontramos perfil en relays."); + } + + return { + handle: normalized, + inputType, + profile: cached.profile, + pubkey, + relays: cached.relaysUsed.length ? cached.relaysUsed : readRelays, + }; +} + +async function publishNotification( + event: SignedEvent, + relays: string[], + onProgress: (relay: string, patch: Partial) => void, +): Promise { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const publishRelays = mergeDataRelays(DEFAULT_RELAYS, relays); + const results = await Promise.all( + publishRelays.map(async (relay) => { + onProgress(relay, { state: "publishing" }); + try { + const [published] = pool.publish([relay], event); + await Promise.race([ + published, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout publicando")), 7000), + ), + ]); + onProgress(relay, { ok: true, state: "ok" }); + return { relay, ok: true, state: "ok" as const }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onProgress(relay, { error: message, ok: false, state: "error" }); + return { relay, error: message, ok: false, state: "error" as const }; + } + }), + ); + try { + pool.close(publishRelays); + } catch { + /* noop */ + } + return results; +} export default function NewsletterCTA() { - const [email, setEmail] = useState(""); - const [status, setStatus] = useState("idle"); + const [value, setValue] = useState(""); + const [resolveStatus, setResolveStatus] = useState("idle"); + const [resolveError, setResolveError] = useState(""); + 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 canNotify = !!resolved && !["signing", "publishing"].includes(publishPhase); + + useEffect(() => { + const raw = value.trim(); + setPublishPhase("idle"); + setPublishError(""); + setSignedEvent(null); + setEventModalOpen(false); + setProgress([]); + if (!raw) { + setResolveStatus("idle"); + setResolveError(""); + setResolved(null); + return; + } + + let cancelled = false; + setResolveStatus("resolving"); + setResolveError(""); + setResolved(null); + const timeout = window.setTimeout(() => { + resolveInput(raw) + .then((user) => { + if (cancelled) return; + setResolved(user); + setResolveStatus("found"); + }) + .catch((error) => { + if (cancelled) return; + setResolveError(error instanceof Error ? error.message : String(error)); + setResolveStatus("error"); + }); + }, 500); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [value]); + + const previewTitle = useMemo( + () => (resolved ? displayName(resolved) : ""), + [resolved], + ); async function handleSubmit(e: FormEvent) { e.preventDefault(); - if (status === "submitting" || status === "success") return; - if (!email.trim()) return; - setStatus("submitting"); - // TODO: cablear endpoint real cuando definamos servicio. - console.log("[newsletter] subscribe:", email); - await new Promise((r) => setTimeout(r, 400)); - setStatus("success"); + if (!resolved || !canNotify) return; + + setPublishPhase("signing"); + setPublishError(""); + setSignedEvent(null); + setEventModalOpen(false); + const relays = mergeDataRelays(DEFAULT_RELAYS, resolved.relays); + setProgress(relays.map((relay) => ({ relay, state: "pending" }))); + + try { + const res = await fetch("/api/nostr-opportunity-notification", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + handle: resolved.profile.nip05 || resolved.handle, + recipientPubkey: resolved.pubkey, + }), + }); + const data = (await res.json()) as { error?: string; event?: SignedEvent }; + 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) => + prev.map((item) => (item.relay === relay ? { ...item, ...patch } : item)), + ); + }); + const accepted = results.filter((item) => item.ok).length; + if (accepted === 0) { + throw new Error("Ningun relay acepto la notificacion."); + } + setPublishPhase("done"); + } catch (error) { + setPublishPhase("error"); + setPublishError( + error instanceof Error ? error.message : "No se pudo enviar la notificacion.", + ); + } } return ( -
-
+
+
-
+
-
+
Oportunidades @@ -52,7 +268,7 @@ export default function NewsletterCTA() { whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-60px" }} transition={{ duration: 0.5, ease: "easeOut", delay: 0.05 }} - className="mt-5 font-display text-3xl sm:text-4xl md:text-5xl font-black tracking-tight leading-[1.05]" + className="mt-5 font-display text-3xl font-black leading-[1.05] tracking-tight sm:text-4xl md:text-5xl" > La data que importa{" "} en tu inbox @@ -63,9 +279,9 @@ export default function NewsletterCTA() { whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-60px" }} transition={{ duration: 0.5, ease: "easeOut", delay: 0.1 }} - className="mt-4 text-sm sm:text-base text-foreground-muted leading-relaxed" + className="mt-4 text-sm leading-relaxed text-foreground-muted sm:text-base" > - Cero spam. Nuevas hackatons y oportunidades de trabajo. + Cero spam. Nuevas hackatons y oportunidades de trabajo por Nostr. -
); } + +function StatusLine({ + children, + icon, + tone = "muted", +}: { + children: ReactNode; + icon: ReactNode; + tone?: "muted" | "error"; +}) { + return ( +
+ {icon} + {children} +
+ ); +} + +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; + const picture = user.profile.picture; + const subtitle = user.profile.nip05 || user.handle || shortPubkey(user.pubkey); + + return ( + +
+ {banner && ( + + )} +
+
+
+
+
+ {picture ? ( + + ) : ( + initials(title) + )} +
+
+
+

{title}

+ + + Verificado + +
+

+ {subtitle} +

+
+
+ + {user.profile.about && ( +

+ {user.profile.about} +

+ )} + +
+
+
+

+ Notificación NIP-04 +

+

+ 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}
+        
+ +
+ ); +}