From 5a952cd090a83bc90c9e206fe3935773276d994f Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Thu, 7 May 2026 19:08:31 -0300 Subject: [PATCH 1/5] feat(home): redesign benefits, scroll sections, newsletter and sponsor hero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeBenefits: reorder + relabel cards (Práctica/Identidad/Resultado), refresh stats (8 hackatons / 8M sats / 48 premios), update copy, bump JobTicker yearly sats x8. - HomeScroll mobile/desktop: * Hackathons: floating Lightning/Nostr/NWC tech badges with parallax, connecting lines and ambient glow. * Projects: dynamic count from PROJECTS, 3 punchy features, infinite name marquee, giant top-right "VER PROYECTOS" CTA. * Tech: hex-grid TechMatrix with circular nodes, neuron-like signal pulses, full-mesh foreground links and background node network. * Bigger uppercase section CTAs, "Usá nuestra infra" → /infrastructure. - NewsletterCTA: rename badge to "Oportunidades", tighter copy, anchor id="oportunidades" so the home Outro CTA scrolls there. - Hackathon detail: SponsorHero banner highlighting Wapu (+2 puntos bonus, sats→ARS) when only one sponsor; bigger Wapu logo on list card. - HackathonInscripcionButton: bigger uppercase "INSCRIBITE GRATIS" with UserPlus icon; resume project modal automatically after login. - Navbar: LogIn icon for "Ingresar"; rename infra link to "Infra propia". - Footer: drop the Dev column. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/hackathons/[id]/page.tsx | 102 ++ app/hackathons/page.tsx | 2 +- components/Footer.tsx | 8 - components/HackathonInscripcionButton.tsx | 21 +- components/Navbar.tsx | 4 +- components/sections/HomeBenefits.tsx | 58 +- components/sections/HomeScroll.tsx | 1118 ++++++++++++++++++--- components/sections/NewsletterCTA.tsx | 11 +- data/hackathons/hackathons.json | 2 +- public/sponsors/wapulogo.png | Bin 0 -> 65776 bytes 10 files changed, 1144 insertions(+), 182 deletions(-) create mode 100644 public/sponsors/wapulogo.png diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index 84ae06f..f943468 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -105,6 +105,8 @@ function medal(position: number | null): string { function SponsorStrip({ sponsors }: { sponsors: Sponsor[] }) { if (sponsors.length === 0) return null; + if (sponsors.length === 1) return ; + return (
@@ -174,6 +176,106 @@ function SponsorStrip({ sponsors }: { sponsors: Sponsor[] }) { ); } +function SponsorHero({ sponsor }: { sponsor: Sponsor }) { + return ( +
+ {/* ambient glows */} +
+
+ {/* eyebrow strip */} +
+ + Partner oficial · Bonus ARS +
+ +
+ {/* Logo block */} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {sponsor.name} +
+ + + +2 puntos de bonus + +
+ + {/* Copy + CTAs */} +
+

+ Pasá de{" "} + sats a{" "} + ARS +

+

+ Sin KYC, directo a tu cuenta. Integrá{" "} + {sponsor.name}{" "} + en tu proyecto y sumás{" "} + + 2 puntos más + {" "} + al puntaje final. +

+ +
+ {sponsor.url && ( + + Probar {sponsor.name} + + + )} + {sponsor.docs && ( + + + Documentación + + )} +
+
+
+
+ ); +} + export default async function HackathonPage({ params, }: { diff --git a/app/hackathons/page.tsx b/app/hackathons/page.tsx index 472ab79..498bde9 100644 --- a/app/hackathons/page.tsx +++ b/app/hackathons/page.tsx @@ -444,7 +444,7 @@ function FeaturedHackathon({ key={s.name} src={s.logo} alt={s.name} - className="h-7 sm:h-8 w-auto object-contain" + className="h-14 sm:h-16 w-auto object-contain" loading="lazy" /> ))} diff --git a/components/Footer.tsx b/components/Footer.tsx index 46f0216..59c844c 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -5,14 +5,6 @@ import { GithubIcon, XIcon } from "@/components/BrandIcons"; import { version as appVersion } from "@/package.json"; const COLS = [ - { - title: "Dev", - links: [ - { href: "/", label: "Inicio" }, - { href: "/infrastructure", label: "Infraestructura" }, - { href: "/projects", label: "Proyectos" }, - ], - }, { title: "Comunidad", links: [ diff --git a/components/HackathonInscripcionButton.tsx b/components/HackathonInscripcionButton.tsx index 0f36277..423addc 100644 --- a/components/HackathonInscripcionButton.tsx +++ b/components/HackathonInscripcionButton.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { Zap } from "lucide-react"; +import { useEffect, useState } from "react"; +import { UserPlus } from "lucide-react"; import { useAuth } from "@/lib/auth"; import LoginModal from "./LoginModal"; import NewProjectModal from "./NewProjectModal"; @@ -14,23 +14,34 @@ export default function HackathonInscripcionButton({ const { auth } = useAuth(); const [loginOpen, setLoginOpen] = useState(false); const [projectOpen, setProjectOpen] = useState(false); + const [pendingInscription, setPendingInscription] = useState(false); function handleClick() { if (auth) { setProjectOpen(true); } else { + setPendingInscription(true); setLoginOpen(true); } } + // After login succeeds, resume the inscription intent automatically. + useEffect(() => { + if (auth && pendingInscription) { + setPendingInscription(false); + setLoginOpen(false); + setProjectOpen(true); + } + }, [auth, pendingInscription]); + return ( <> setLoginOpen(true)} className="relative inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold bg-gradient-to-r from-bitcoin to-bitcoin/80 text-black hover:from-bitcoin hover:to-yellow-500 transition-all shadow-lg shadow-bitcoin/20 hover:shadow-bitcoin/40 hover:scale-[1.02] active:scale-[0.98]" > - + Ingresar )} diff --git a/components/sections/HomeBenefits.tsx b/components/sections/HomeBenefits.tsx index 64692cc..933c923 100644 --- a/components/sections/HomeBenefits.tsx +++ b/components/sections/HomeBenefits.tsx @@ -30,8 +30,8 @@ export default function HomeBenefits() {
- +
@@ -54,10 +54,10 @@ function Header() { > - Tu próximo nivel + Sin experiencia

- Tu primer laburo como{" "} + Tu primer trabajo{" "} bitcoiner open-source.

@@ -84,12 +84,12 @@ function BenefitGitHub() {

- 01 · Identidad + 02 · Experiencia verificable

- Tu currículum + Tu GitHub
- es tu GitHub + es tu currículum

Talk is cheap. Show me the code. @@ -145,7 +145,7 @@ function ContributionGraph() { últimos 6 meses · 312 commits - menos + front {[0, 1, 2, 3, 4].map((l) => ( ))} - más + back

@@ -221,7 +221,7 @@ function CommitTerminal() {
$ - git log --oneline -1 + git commit -m "primer proyecto"
- 02 · Práctica + 01 · Práctica

Adquirí @@ -272,7 +272,7 @@ function BenefitExperience() { experiencia real

- Hackatones, open source, mentores. Aprendés construyendo. + Aprendé construyendo participando en hackatons

@@ -395,9 +395,9 @@ function Constellation() { function ExperienceStats() { return (
- - - + + +
); } @@ -480,7 +480,7 @@ function BenefitJobs() {

- Las BTC-only leen Nostr. Tu trabajo firmado les llega solo. + Se buscan builders que tenga experiencia en Bitcoin.

@@ -488,7 +488,7 @@ function BenefitJobs() { - 7 búsquedas activas + búsquedas activas
@@ -504,16 +504,16 @@ function BenefitJobs() { type Job = { co: string; role: string; sats: string; loc: string }; const JOBS: Job[] = [ - { co: "Strike", role: "Lightning Engineer", sats: "6.0M", loc: "Remote" }, - { co: "Galoy", role: "Backend Rust", sats: "5.4M", loc: "Remote · LATAM" }, - { co: "Voltage", role: "DevRel", sats: "4.8M", loc: "Remote" }, - { co: "Fedi", role: "Mobile Engineer", sats: "5.1M", loc: "Remote" }, - { co: "Lightspark", role: "SDK Engineer", sats: "6.5M", loc: "Remote" }, - { co: "Wapupay", role: "Product Engineer", sats: "3.8M", loc: "Buenos Aires" }, - { co: "Cash App BTC", role: "Senior Engineer", sats: "7.2M", loc: "Remote" }, - { co: "Coinos", role: "Full-stack", sats: "4.4M", loc: "Remote" }, - { co: "Mutiny", role: "Web Engineer", sats: "5.0M", loc: "Remote" }, - { co: "Breez", role: "iOS Engineer", sats: "5.6M", loc: "Remote" }, + { co: "Strike", role: "Lightning Engineer", sats: "48.0M", loc: "Remote" }, + { co: "Galoy", role: "Backend Rust", sats: "43.2M", loc: "Remote · LATAM" }, + { co: "Voltage", role: "DevRel", sats: "38.4M", loc: "Remote" }, + { co: "Fedi", role: "Mobile Engineer", sats: "40.8M", loc: "Remote" }, + { co: "Lightspark", role: "SDK Engineer", sats: "52.0M", loc: "Remote" }, + { co: "Wapupay", role: "Product Engineer", sats: "30.4M", loc: "Buenos Aires" }, + { co: "Cash App BTC", role: "Senior Engineer", sats: "57.6M", loc: "Remote" }, + { co: "Coinos", role: "Full-stack", sats: "35.2M", loc: "Remote" }, + { co: "Mutiny", role: "Web Engineer", sats: "40.0M", loc: "Remote" }, + { co: "Breez", role: "iOS Engineer", sats: "44.8M", loc: "Remote" }, ]; function JobTicker() { @@ -598,15 +598,15 @@ function Outro() {

- Empezá tu historial pública. + Enterate de las propuestas

- Ver hackatones + Ver oportunidades diff --git a/components/sections/HomeScroll.tsx b/components/sections/HomeScroll.tsx index 37a990c..11bca7f 100644 --- a/components/sections/HomeScroll.tsx +++ b/components/sections/HomeScroll.tsx @@ -1,9 +1,14 @@ "use client"; -import { useRef, useState, useCallback } from "react"; +import { useRef, useState, useCallback, useEffect } from "react"; import { + animate, motion, + useInView, + useMotionValue, + useReducedMotion, useScroll, + useSpring, useTransform, useMotionValueEvent, type MotionValue, @@ -20,13 +25,15 @@ import { Award, Users, GitBranch, + Sparkles, } from "lucide-react"; import { cn } from "@/lib/cn"; +import { PROJECTS } from "@/lib/projects"; type Feature = { icon: React.ComponentType<{ className?: string }>; title: string; - description: string; + description?: string; }; type Pillar = { @@ -34,12 +41,18 @@ type Pillar = { label: string; }; +type Stat = { + value: number; + label: string; + suffix?: string; +}; + type Section = { id: string; number: string; label: string; tagline: string; - description: string; + description?: string; colorClass: string; bgClass: string; borderClass: string; @@ -49,6 +62,8 @@ type Section = { cta: string; features?: Feature[]; pillars?: Pillar[]; + techTags?: string[]; + stats?: Stat[]; }; const SECTIONS: Section[] = [ @@ -56,9 +71,8 @@ const SECTIONS: Section[] = [ id: "hackathons", number: "01", label: "Hackatones", - tagline: "Aprendé construyendo", - description: - "Cada mes, un desafío real. Construís, iterás con feedback y ganás sats por participar. No necesitás experiencia previa.", + tagline: "Desafío mensual", + description: "Un finde. Un build. Premios reales.", colorClass: "text-bitcoin", bgClass: "bg-bitcoin/10", borderClass: "border-bitcoin/30", @@ -67,23 +81,14 @@ const SECTIONS: Section[] = [ href: "/hackathons", cta: "Ver hackatones", features: [ - { - icon: Code2, - title: "Aprendé haciendo", - description: - "Instrucciones claras y feedback real en cada etapa del proceso.", - }, - { - icon: Zap, - title: "Vibecoding", - description: - "Sin experiencia previa en programación. Solo ganas de construir.", - }, - { - icon: Award, - title: "Ganás por participar", - description: "Premios en sats reales para todos los proyectos presentados.", - }, + { icon: Code2, title: "Aprendé haciendo" }, + { icon: Sparkles, title: "Sin experiencia técnica" }, + { icon: Award, title: "Ganá premios por participar" }, + ], + techTags: ["Lightning", "Nostr", "NWC"], + stats: [ + { value: 26, label: "Proyectos concursados" }, + { value: 2, label: "Hackatones" }, ], }, { @@ -91,8 +96,6 @@ const SECTIONS: Section[] = [ number: "02", label: "Proyectos", tagline: "Lo que construye la comunidad", - description: - "Proyectos reales, open source, usables hoy. Apps, servicios y herramientas construidas sobre Bitcoin, Lightning y Nostr.", colorClass: "text-nostr", bgClass: "bg-nostr/10", borderClass: "border-nostr/30", @@ -101,21 +104,9 @@ const SECTIONS: Section[] = [ href: "/projects", cta: "Ver proyectos", features: [ - { - icon: Users, - title: "Hechos por la comunidad", - description: "Builders de toda Latinoamérica construyen juntos.", - }, - { - icon: GitBranch, - title: "Open source", - description: "Todo el código es público y auditable. Sin secretos.", - }, - { - icon: Zap, - title: "Productos y servicios reales", - description: "Apps y herramientas que se pueden usar hoy mismo.", - }, + { icon: Users, title: "HECHOS por la comunidad" }, + { icon: GitBranch, title: "100% Open Source" }, + { icon: Zap, title: "Apps funcionales" }, ], }, { @@ -130,8 +121,8 @@ const SECTIONS: Section[] = [ borderClass: "border-cyan/30", dotBgClass: "bg-cyan", icon: Layers, - href: "/hackathons", - cta: "Ver hackatones", + href: "/infrastructure", + cta: "Usá nuestra infra", pillars: [ { src: "/pilares/lightning.svg", label: "Lightning" }, { src: "/pilares/nostr.svg", label: "Nostr" }, @@ -219,11 +210,21 @@ export default function HomeScroll() { return (
+ {s.id === "hackathons" && } + {s.id === "projects" && ( + + {s.cta} + + + )}
@@ -238,38 +239,32 @@ export default function HomeScroll() { >
-

+

{s.label}

-

- {s.description} -

+ {s.description && ( +

+ {s.description} +

+ )} + {s.id === "projects" && ( + + )} {s.pillars ? ( -
- {s.pillars.map((p) => ( -
- {p.label} - - {p.label} - -
- ))} -
+ ) : (
    - {s.features?.map((f) => { + {s.features?.map((f, fi) => { const FIcon = f.icon; return ( -
  • +
    {f.title}
    -
    - {f.description} -
    + {f.description && ( +
    + {f.description} +
    + )}
    -
  • + ); })}
)} - - {s.cta} - + {s.id !== "hackathons" && s.techTags && ( + + )} + {s.stats && ( + + )} + {s.id === "projects" && ( + p.name)} + className="mb-8" + /> + )} + {s.id !== "projects" && ( + + {s.cta} + + )}
); })} @@ -371,8 +393,26 @@ function SectionPanel({ style={{ opacity, y: yShift }} className="absolute inset-0 flex items-center px-12 xl:px-20 py-12 pointer-events-none" > + {section.id === "hackathons" && } + {section.id === "projects" && ( + + + {section.cta} + + + + )} {/* Number + icon row */} @@ -402,34 +442,18 @@ function SectionPanel({ {/* Description */} -

- {section.description} -

+ {section.description && ( +

+ {section.description} +

+ )} + + {section.id === "projects" && ( + + )} {section.pillars ? ( -
- {section.pillars.map((p) => ( -
- {p.label} - - {p.label} - -
- ))} -
+ ) : (
    {section.features?.map((f, fi) => { @@ -456,9 +480,11 @@ function SectionPanel({
    {f.title}
    -
    - {f.description} -
    + {f.description && ( +
    + {f.description} +
    + )}
); @@ -466,22 +492,40 @@ function SectionPanel({ )} - {/* CTA */} - - - {section.cta} - - - + {section.stats && ( + + )} + + {section.id === "projects" && ( + p.name)} + className="mb-10" + /> + )} + + {/* CTA — hidden for projects (rendered top-right) */} + {section.id !== "projects" && ( + + + {section.cta} + + + + )} ); @@ -600,3 +644,817 @@ function Timeline({
); } + +/* ── Tech chips ───────────────────────────────────────────────────────── */ +function TechChips({ + tags, + colorClass, + bgClass, + borderClass, + className, + orientation = "horizontal", +}: { + tags: string[]; + colorClass: string; + bgClass: string; + borderClass: string; + className?: string; + orientation?: "horizontal" | "vertical"; +}) { + return ( +
+ {tags.map((t, i) => ( + + + {t} + + ))} +
+ ); +} + +/* ── Stats row ────────────────────────────────────────────────────────── */ +function StatsRow({ + stats, + colorClass, + borderClass, + className, +}: { + stats: Stat[]; + colorClass: string; + borderClass: string; + className?: string; +}) { + return ( +
+ {stats.map((s, i) => ( + + ))} +
+ ); +} + +function AnimatedStat({ + stat, + delay, + colorClass, + borderClass, +}: { + stat: Stat; + delay: number; + colorClass: string; + borderClass: string; +}) { + const ref = useRef(null); + const inView = useInView(ref, { once: true, margin: "-40px" }); + const reduced = useReducedMotion(); + const display = useMotionValue(0); + const [shown, setShown] = useState("0"); + + useEffect(() => { + if (!inView) return; + if (reduced) { + setShown(String(stat.value)); + return; + } + const controls = animate(display, stat.value, { + duration: 1.4, + delay, + ease: "easeOut", + onUpdate: (v) => setShown(Math.round(v).toString()), + }); + return () => controls.stop(); + }, [inView, reduced, stat.value, delay, display]); + + return ( + +
+ {shown} + {stat.suffix && {stat.suffix}} +
+
+ {stat.label} +
+
+ ); +} + +/* ── Floating tech icons (Lightning · Nostr · NWC) ────────────────────── */ +type FloatingItem = + | { + kind: "image"; + src: string; + label: string; + x: string; + y: string; + size: number; + drift: number; + tone: "bitcoin" | "nostr" | "cyan"; + } + | { + kind: "text"; + label: string; + x: string; + y: string; + size: number; + drift: number; + tone: "bitcoin" | "nostr" | "cyan"; + }; + +function FloatingTechIcons({ variant }: { variant: "desktop" | "mobile" }) { + const reduced = useReducedMotion(); + const desktop = variant === "desktop"; + + const items: FloatingItem[] = desktop + ? [ + { kind: "image", src: "/pilares/lightning.svg", label: "Lightning", x: "30%", y: "18%", size: 76, drift: 9, tone: "bitcoin" }, + { kind: "image", src: "/pilares/nostr.svg", label: "Nostr", x: "78%", y: "48%", size: 68, drift: 11, tone: "nostr" }, + { kind: "text", label: "NWC", x: "38%", y: "78%", size: 64, drift: 7, tone: "cyan" }, + ] + : [ + { kind: "image", src: "/pilares/lightning.svg", label: "Lightning", x: "78%", y: "12%", size: 52, drift: 8, tone: "bitcoin" }, + { kind: "image", src: "/pilares/nostr.svg", label: "Nostr", x: "88%", y: "44%", size: 48, drift: 10, tone: "nostr" }, + { kind: "text", label: "NWC", x: "72%", y: "76%", size: 46, drift: 6, tone: "cyan" }, + ]; + + const toneStyles: Record< + "bitcoin" | "nostr" | "cyan", + { border: string; ring: string; text: string; shadow: string; line: string } + > = { + bitcoin: { + border: "border-bitcoin/45", + ring: "border-bitcoin/30", + text: "text-bitcoin", + shadow: "shadow-[0_0_30px_rgba(247,147,26,0.30)]", + line: "text-bitcoin/20", + }, + nostr: { + border: "border-nostr/45", + ring: "border-nostr/30", + text: "text-nostr", + shadow: "shadow-[0_0_30px_rgba(168,85,247,0.30)]", + line: "text-nostr/20", + }, + cyan: { + border: "border-cyan/45", + ring: "border-cyan/30", + text: "text-cyan", + shadow: "shadow-[0_0_30px_rgba(34,211,238,0.30)]", + line: "text-cyan/20", + }, + }; + + return ( +
+ {/* ambient glow */} + + + {/* connecting lines between icons */} + + {items.flatMap((it, i) => + items.slice(i + 1).map((other, j) => ( + + )), + )} + + + {items.map((it, i) => { + const t = toneStyles[it.tone]; + return ( + + +
+ {it.kind === "image" ? ( + {it.label} + ) : ( + + {it.label} + + )} + {/* outer pinging ring */} + +
+ + {it.label} + +
+
+ ); + })} +
+ ); +} + +/* ── Big project count ────────────────────────────────────────────────── */ +function BigProjectCount({ + count, + className, +}: { + count: number; + className?: string; +}) { + const ref = useRef(null); + const inView = useInView(ref, { once: true, margin: "-30px" }); + const reduced = useReducedMotion(); + const display = useMotionValue(0); + const [shown, setShown] = useState("0"); + + useEffect(() => { + if (!inView) return; + if (reduced) { + setShown(String(count)); + return; + } + const controls = animate(display, count, { + duration: 1.5, + ease: "easeOut", + onUpdate: (v) => setShown(Math.round(v).toString()), + }); + return () => controls.stop(); + }, [inView, reduced, count, display]); + + return ( + + + {shown} + +
+
+ Proyectos +
+
+ construidos por la comunidad +
+
+
+ ); +} + +/* ── Project marquee ──────────────────────────────────────────────────── */ +function ProjectMarquee({ + names, + className, +}: { + names: string[]; + className?: string; +}) { + const reduced = useReducedMotion(); + // Quadruple to guarantee a wider-than-viewport row at any width — the + // -50% shift loops seamlessly because each half is identical. + const row = [...names, ...names, ...names, ...names]; + + return ( +
+ {/* edge fades */} +
+
+ + + {row.map((name, i) => ( + + + {name} + + ))} + +
+ ); +} + +/* ── Tech matrix (Tecnologías) ────────────────────────────────────────── */ +type CloudLayout = { x: number; y: number; depth: 0 | 1 | 2; drift: number; delay: number }; + +// Hex-packed grid: 3-2-3 (rows offset by half a column) +const TECH_LAYOUT: CloudLayout[] = [ + // Row 1 + { x: 22, y: 22, depth: 1, drift: 6, delay: 0.0 }, + { x: 50, y: 22, depth: 1, drift: 7, delay: 0.05 }, + { x: 78, y: 22, depth: 1, drift: 6, delay: 0.1 }, + // Row 2 (offset) + { x: 36, y: 50, depth: 1, drift: 5, delay: 0.15 }, + { x: 64, y: 50, depth: 1, drift: 8, delay: 0.2 }, + // Row 3 + { x: 22, y: 78, depth: 1, drift: 6, delay: 0.25 }, + { x: 50, y: 78, depth: 1, drift: 7, delay: 0.3 }, + { x: 78, y: 78, depth: 1, drift: 5, delay: 0.35 }, +]; + +function TechMatrix({ + pillars, + className, +}: { + pillars: Pillar[]; + className?: string; +}) { + const reduced = useReducedMotion(); + const ref = useRef(null); + const mx = useMotionValue(0); + const my = useMotionValue(0); + const sx = useSpring(mx, { stiffness: 60, damping: 20 }); + const sy = useSpring(my, { stiffness: 60, damping: 20 }); + + const nodes = pillars.slice(0, TECH_LAYOUT.length).map((p, i) => ({ + ...p, + ...TECH_LAYOUT[i], + })); + + function handleMove(e: React.MouseEvent) { + if (!ref.current) return; + const r = ref.current.getBoundingClientRect(); + mx.set((e.clientX - r.left) / r.width - 0.5); + my.set((e.clientY - r.top) / r.height - 0.5); + } + + function handleLeave() { + mx.set(0); + my.set(0); + } + + return ( +
+ + + {nodes.map((node, i) => ( + + ))} + {/* vignette */} +
+
+ ); +} + +// Background network of small nodes connected by faint lines, with neuron-like +// pulses on a subset to suggest constant signal traffic. +const BG_NODES = (() => { + // Deterministic pseudo-random scatter so SSR matches client. + const rng = mulberry32Local(7); + const out: { x: number; y: number }[] = []; + for (let i = 0; i < 28; i++) { + out.push({ x: 4 + rng() * 92, y: 6 + rng() * 88 }); + } + return out; +})(); + +function mulberry32Local(seed: number) { + let t = seed; + return () => { + t = (t + 0x6d2b79f5) | 0; + let x = t; + x = Math.imul(x ^ (x >>> 15), x | 1); + x ^= x + Math.imul(x ^ (x >>> 7), x | 61); + return ((x ^ (x >>> 14)) >>> 0) / 4294967296; + }; +} + +const BG_LINKS: { a: number; b: number }[] = (() => { + const out: { a: number; b: number }[] = []; + for (let i = 0; i < BG_NODES.length; i++) { + const distances = BG_NODES.map((m, j) => ({ + j, + d: Math.hypot(BG_NODES[i].x - m.x, BG_NODES[i].y - m.y), + })) + .filter((m) => m.j !== i) + .sort((a, b) => a.d - b.d) + .slice(0, 3); + distances.forEach(({ j }) => { + const a = Math.min(i, j); + const b = Math.max(i, j); + if (!out.find((l) => l.a === a && l.b === b)) out.push({ a, b }); + }); + } + return out; +})(); + +function MatrixBackground({ reduced }: { reduced: boolean }) { + return ( +
+ {/* corner gradient blobs */} + + + + {/* network of small nodes + connecting lines */} + + {BG_LINKS.map((l, i) => ( + + ))} + {BG_NODES.map((n, i) => ( + + ))} + {/* Subset of background pulses */} + {BG_LINKS.filter((_, i) => i % 4 === 0).map((l, i) => ( + + ))} + + + {/* slow horizontal scan line — reads as a neural sweep */} + +
+ ); +} + +function ConnectionLines({ + nodes, + sx, + sy, +}: { + nodes: (Pillar & CloudLayout)[]; + sx: MotionValue; + sy: MotionValue; +}) { + // Full mesh — every node connects to every other node + const links: { a: number; b: number }[] = []; + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + links.push({ a: i, b: j }); + } + } + + // Subtle parallax for connections (less than nodes themselves) + const tx = useTransform(sx, (v) => -v * 8); + const ty = useTransform(sy, (v) => -v * 8); + + return ( + + {links.map((l, i) => { + const a = nodes[l.a]; + const b = nodes[l.b]; + return ( + + ); + })} + {/* Neuron-like signals travelling between nodes */} + {links.map((l, i) => ( + + ))} + + ); +} + +function NeuronPulse({ + from, + to, + delay, + reverse, +}: { + from: { x: number; y: number }; + to: { x: number; y: number }; + delay: number; + reverse: boolean; +}) { + const t = useMotionValue(0); + const a = reverse ? to : from; + const b = reverse ? from : to; + + useEffect(() => { + const controls = animate(t, 1, { + duration: 1.8, + delay, + ease: "easeInOut", + repeat: Infinity, + repeatDelay: 4 + (delay % 3), + }); + return () => controls.stop(); + }, [t, delay]); + + const cx = useTransform(t, (v) => `${a.x + (b.x - a.x) * v}%`); + const cy = useTransform(t, (v) => `${a.y + (b.y - a.y) * v}%`); + const opacity = useTransform(t, [0, 0.1, 0.85, 1], [0, 1, 1, 0]); + + return ( + + ); +} + +function TechNode({ + node, + index, + sx, + sy, + reduced, +}: { + node: Pillar & CloudLayout; + index: number; + sx: MotionValue; + sy: MotionValue; + reduced: boolean; +}) { + // Uniform parallax for the hex grid + const depthFactor = 30; + const tx = useTransform(sx, (v) => -v * depthFactor); + const ty = useTransform(sy, (v) => -v * depthFactor); + + const baseScale = 1; + const blur = 0; + const targetOpacity = 1; + const sizePx = 68; + + return ( + + +
+ {node.label} + +
+ + {node.label} + +
+
+ ); +} diff --git a/components/sections/NewsletterCTA.tsx b/components/sections/NewsletterCTA.tsx index 3c36de0..b369047 100644 --- a/components/sections/NewsletterCTA.tsx +++ b/components/sections/NewsletterCTA.tsx @@ -22,7 +22,7 @@ export default function NewsletterCTA() { } return ( -
+
- Newsletter + Oportunidades - El próximo hackatón{" "} - en tu inbox. + La data que importa{" "} + en tu inbox - Una vez al mes. Cero spam. Sólo el próximo desafío y los proyectos que - ganaron. + Cero spam. Nuevas hackatons y oportunidades de trabajo. >un$kiMBE1Ac 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 z

!7C=@e?S|XBY05cCzv0Vnzd@Y>T#@!ElaSGg1(Ko5feEPz2X#OToq2t>*LfY3 z%`Z&V-J*e@(eE#z))P0>ViY%5-!A{V8s5nENKuY z9i-ff7zF87eanCUt1ZS=d)4({LnqnNRgnfB?0fo1?9NZB|Dh_>Tw``4k4*cmxZI3Ryu4snNt z5@v{T?9YOWhFen@%8rtWe{xZd-bg`mO1e3J3K7kG0JG;Xz`qNdOFsP;e}&U^I+ujj zV0V#>0!&mw-Y}@>*}M$=kyJ69V5t?e9xcIicG$?12Bb*=EBX0{&jJ%XI5q37kA!6D zM@Y8SX$j@&#vOWRyS2%*23qgdI8fl$J7)=v$iK4ddZ86mrDW_TN32=YaUQ<*2WNr9 z4Q$aGM>g05^D z>>N<>u*5MlB^t0BA9l9uc^+JpjDLAf|KRMX)Tdv07XWZHUDn*fGY=plnxob4*q`pV zBp+Gx= zlFHkc@$Cv#8t0Nd2Q-~p{jZ~u56}2_>bR)U#=J{FR`@S~rzA}a;n2F8nty1&c3=<} zUtPO`5{D#|5Bv!ZqtWeF*j<3uEZ6-2n44Kx<+R4MU%fvO;H&G7UxpF5UH8Xh$E~D( z*C*QD5VYpCAJemz{&RWe)g6Ab%=Vi-q%1pLlr3_`9@jtBo*_JBhAq`%(h+i2ZP~NmUMpBcBB+5ft?>@k0wFE>(fz zu7QiLTEg^9FmdB`DGJv(3G!EOk^xqq7b@?G+|1-rP_D*YeYdK8%f6@$?(gyXPWeS9mVP z+(mq_Bq)|ac)H5Or;5hzeW{w9H?e$PSSSOh<+PMKfp}D#m$z_!GPSq$TCMf ziX_nU)FC5T`|f*(?ZT?J$HT_eQd&dz4{Gs zmLSLXsetjE6R-`k%hyN+aerlwyps6Tm8|{5z)d4#f%~>~C_3TW07tz}{G2JhF1iS+nz zkOsL=w}%~KYG@{q zZ-WL3pg;a%E&bGrx9klJkI}0T)3hS)WgMU$z!+xCK0X&Igw$=yChHKi=`ly3Bbcbi8j;>f5CyLzmjZ4 z(I)~Ns-PEUP$#0e|Gn|1kE>HGt3+kiTkpF+!_=TEM~&*DkH>u3Y#GC>%Nt3S6-kDE z%LSa+dgVy0Yl0TQZ}o!8Fg4ykQ4#N(#2j#PO73D=-c+of# zrL5NIT6H`$Tr3xlVAQ6<#e2u=jgze3)0NxFv6X$gS7fT~%{->zx#HUNwV(g=*<|{O zUZBjILn>1EMqwqh!%_zE%Hj>BU&7 zAuKvnOcpFCX(;0{&h?F$)iRHn+fBmoN~ZjBIZm2l{-@UVW)=x5!Uy#E2=3>e=tmMH zU-zz}w~2kq;-*qEPkXc;bL3W)jgMc)ZUYm{iM|$c5xzb?RCUwXLSmRAml#UaNy%$A zpb;2g6}X+-ENC`U!PH_b`pG6#dQ?C>)$a-gOR|-yZ0GV$BnXxcE3Z!d*0qOfu_B$> zgYn;S2N+fDK1kstv~Q>W0|Q<@--n6*i~lqpwv6|Cx5}U2oUswD^e@J)B1+&z#Et5l zkJ=_YGnm_Zq0qCtlzkqqk57tj4}h;~c)?EGBxwT`Qrm+pzA5|(t~vLcVZq$#Z)$wl zOob!S-JV|);#udK-;^7cGHh@LZqHb;iP?{e`^^@Sj&pH>CBzAZs--6*l^mN|v7576 zZ=IRA_>NqJ15rPq9A+`u;Bwo}-O(Q+>}%xi4B2ONHYK{Rod}{z>3(Bu=Db%FBhM@R zLPlb)fr33iWc%M@%1enviB;*p)&HaYe2@g6GU@0XC4>v~kCSS1(MpfhGY{sypLG0m z@2>)lB0U<$n;sVb{!Ys+t&LYSPg`EZPn9MUHD#3-<}}Gtwpqyu*s9!puy=Z!o^kUppmh!?oaO}k_GQSHy9zNT zG^9A;2U7&iFD49Fjzg|F$6+n5z3dAkm58PF(<0{lt@KZapq!<-r5a+wK`?#rv6gU% zKU=)5W(9*F+k>(ChD>4INd=DN10gRu^Ys=hdi#Qvy7h-W<^CyApg!@iR>;`o*|Wz` z(?CPvo>rG7Iz^JBiJ(bOan&kU6k;~yOzyane2~tko~gAKWrgIu{&g7g{atv10hZa& zmLF5A&l~1{&nKs^j(VvtNAU_!Q8kb`Q~DDzy5~Y)1HWYrpzk@EJVuV$pV9+|bev3P z{da*g5J?T6-|bQiftoU5*WoXL5=*d3FSr%@Ge&U7a}0o`gNHusxL9%nR}}C(X@^?< z9ad4^e`^vPU?RNlL!ggBu7pJnDuLN3>ggr1?KhtoOv~E8#D60{V%=+KdbjVhTcExx z(EgHJ^hot-LOg@eQ*ZOb;@7Lix8Q?LCjQ&a|LSwY^;!;jL!Ml;Io$M}=?;;DOQFik zRk}PrBb-9!iv0kZPcq|X>T+%&ey&ym*F8at4i|c;+&DZ$@@5qg*Q$2u?-%_a9?CU4 zS>%v|)uf&B=b=?0(EO{P@G~E+ZV<75togBX(PmDzt1+ab!5-t-0Z!LqXsxl6G~2@4 zH{rGqeU0F()GguL|Ixehy`{E1qGg1aX)nun1$c|eJMykrB3OZwZpK{G$=UNUXuS2! zK&AqfqWv-Lz zYywdd&1s3yaU)w^=yI1Rg2W~7PPa$HeGz*(j%={03 zl*tX%UoVHviop}}wxk?xy_zz`aIRmjqGs8TBohmoRP2Z?Q(WDfrT|S8a0AP-&3QIY z!fa{`oHncXd&%9RAC?;5Y5qf_7Jh^yq#=k(K1}vnk{J_xJ$o8_OLn$W0M5@^^c6h$ z$MuvIn5FTQ!5qs=!Nu3hBFuQ|D)P{rHLv1yb_C{cjD$Ja%#Z6o24#jMR=lkQTTS7O z59zeRpDl0Y_ol%z54Rn6`=UjZFX*n%-dd)?Lq_wf?;Z+_&CM zfzIVJ>2NsY{@y?l#AR^W_pNtP?-jM*rEXg6>XXl<&K0&Hxq>TGUV!OxuLV)ZqcR}W z&imrzyh6bFKno6aUE=S<6Qac1=Dr0|Eo3P7E!vTRYaEaZ&RDe77C+{5ARnf}z92lE zUs)bm>5D=B`s)&nmwB0ofz)KIRpm)7a!V3Yv8Zt05Phi2SDZ%U`F*R5J``T~c)H!# zrO;0Z&wYZcAv^cma%LHjrwlwgV;T z7bma6zfKB&mzrN_0CeX@18;q11b`S2%YOrx)7Qb!ykopE|HMSo&wJTT_Ttqw| zn-Vm{)z`btLeKcB3a-cLsGu}nLiegy zRxgVWaFQHKvun~NnftpzHE@=l-e#XAas@2t+vfswLeg2Q&pWA9M-+zX_bEQ%*ANoi zZo6eg@;k;~#cyqYwQ{vpc>lJwTx#wmq;e zk0Fz!ugx8E_U0vV`j9zq`5GAp-`(&m+sUexB9I_z1!*Ssi7Bc4@=p@&J;vG`L!C-^ zxNZQA#;5t^q^DHT?`tS@;<76}oHmyZMKLh3)tA4s7?S+aO-T#XMl+!>TDbRtbYG*% z6l|B*lX!Qn(!MXSa~rvw;6nXwu>{r@h?={w5FExLo5)uGg}wcjxW$RsdVDRT7vCQ* zkW)Uyo=z8z39UKxP8UYMAR}>Z{UB)_ zx&z@++Y`G2X$@z8Pk~&6`9B8*lY1eomC4^S0v`5enwG++xXU}IiJ0Lo@(;WzAXrb`-D>N_=gJ<&XZh>eZE z4Y{9i2Sg8XNWAoL0XrpVmMU;DhJoj&a%3{ z^42%8YX}7Kcm9bHI$nqhXg{R6cIMCMQ@xe);CZA1wc$v(fo7QVH$w{2w)^uK+7|Kk z^fMW?<33ZxN}Ia`p_b6pP5`~E0Ph!2U~ z%^Z*4Ak3JI{ssRH(m4Z=j?tdgSTq4^2_n2xLyrk43VxcoZ~FQ@u*NZWzzxQYapH&q z;Y#lH-}qV}^{|}N=PT9(=L3$Z8%6ryIe2lajD~6LsV?nHPW7fBsui8?ABfQjOpl=f z)Q{nGSEY!=hk~Ds(*Z;BEYG>}TWp2ji#lxa&a0IY(U~j!-2O!hxw`_6$roy%4Nc8>SyO3qY<+^P%AG;l3ma|joS{)Agwo{fZ;LNM zQ}P&LEV}C)){+|a^>%GqFe`MvB|@#xcdF(->$agw-cvLWD8Lh>?ulfZ{dWmY1^A-E z5c0MYZaI!#VYH23eYL4ly@d1`CkqB2P2TqV{yuuXuoc*Izl8}@?xo(Kr@MJ;s~AXr zYGJDIIZ=bCv?>Z7HCssM^$(?cwq^@Zx9eVr!fR(kA7p>`CHq1BcOuyIJ`C#^@Q5xI zrU44u8a8EXuw1^@BO1-OI=Hx^tAe0eg2HQ%e@4eE13Ph6s~z4f`dne-`Y3gxYb6|EFmIC0527;1`-E*1@F;lDyHSfW7L z%9!mK5uj*7D1v?eTfM~{edA4vr*hVuyY%|)+#+mBlZtzrhK}FU5;@~6Mav_BrukVT zY1mqQKfg`Sw3&Tfxr@Hj!{_B=tmW*Hf`PjR@uh@5G7RvhFIvi@pvF_L6DpCpu{a)< zG}mzog?8@aiEw`6JV0OFM=4w@y?s7?ZRTWB#Pv1R=L`ph5t;$TcO2op{3i`}l#~`Y z%LoBQ>qdiQ<j7!y3gPW>TlZK;=C56gO0FZ|FT%K!{Fg1+7l zwh-*pne!+v!8IO11t}Qs|1J94l&KzXuLyr*eqG~D#en1Jx}J>Ls=_~xe-aSwSQiJ* z=!yAx=?kRPLZS7k3&)9#Q?*?O*>>FG2o9b>z*MpG_3`x6orcz%(@l2~Y?9cY@_cr9 zocFK{dbWG6@cT8EHW2ioyxpg|e7JV*Bp_z}+bnlqWM@(37qsW&+}>HJBNo5=lE9VL zL%t6pu)zNh%IZLB7X@Q(R($A>=bAwgLg;-rlXmm8Wc!y4pOe`C0ti-3Pjg}J9H;@F zS{>{0k@GsK3AZOg$CRK@#-_{YRi*jI8ArA}w)uYh{7(_AOBNi^tOj^=@cP+l9^#_T z?{KktZp2r>`IWe8-)!)nu~Qa}sfy>j3l9|F_6hzDehLY0{ubGt z0OcMb;A&|Nehg;n_q^1kCfG5 zC+$%kXX;=kc5AWBGp%G zs;$9W$bNXm#Ithq06@9$)H-inW3KntfohK;pcwm-2Cmp`s2C7kdE z;kU{!1i#v7a51zV|BlC&lSlp~j0gt>XnJGSz8!9@kCz&i=IADGRRZwr*!m4R&ysO3 zbnID%%G=7iMMsZG2Np}(&f(N$C6qT8;|+o)*xb#}Pf;I}8QnOKIvN&1xE9D_nE%0K z1w0oo%&>J9gu#9}L#M@$JIu)2!8{pe#(Z3`3`Qq3h&vZ`?|+2`>JIDOVatDEF3@s} zT^6Ibo%7PcSir++tj9qGBxEA(TMj1;j{WOt2TX1zL{Z@y`@B?4yxPp~;)dPx zmr1^%O{-c)H=7`Lym?TX;$dQ76me{3f1K?b@i(0g*&2!1@8A8xb8RymzbOAlsGp7_ z_mFjleFG#cL3IBE;EubUl=D+uumSMW1vv~DZW`X#-Qk$=A`}>#iro`UmuiQWa&!7c ztawmln5Y2Jg9^oVg#3QJO0z0iPo`~vfR{za-3F&Scw)y?TN>CeyaXHth=jKnTXqWF!`J6kTwU}wQ zy$ELPz5fO(_)_nX=w~VA$ma&p`8hv1qH5ngOHj>iDdsnd1_zKBBX&7i9@6Bd`eV&~ zo^&`$-P0=leZ(Ox-@$4veN;7bvA{ewj}e1(DE`=TWMFKl z8+y1IaAl_JPkZ*fmZMpm?&B`b_Wl-J$jLvlFjF@yFC|8LByYxf&cG>IRE6WLm&3(w zy18i^5Na4|tma`ns$)SM431*$>T(1%ubMT!D-K!#6Y?u8_RZHopVBSDgMKZ_rUh1? zfUTy*ua4hiC>^NGxmP-_Z-*SJqiSYm6dS^dw{UHPz&h%t)5K zG3G0`Q-d{}!r9;F%O=8i>0Ot~bKabbUX(34q--co_3ER;d^Ge1Vd=ML+ZD{^>_W?i zyI~AJ%uVju|G|U!T7Cd;9Df^V(?Zx>6BKG(m#bQull6q>Dj9N275_r#Mx2C!(6qIc zKyH6RGhbjFoH4l}4WI>OwF)HErJRNijinMdEbjwDsW60xjP)|`eGVtmM-By{Q!Z~}1;HwyICMI~wd2E5)4d&R_SVll3KY%`RsJ<<^70E+>iTSp;m2sK8R=x0Y@(aj2Q}shs=IuWVEl zE9dHY%1k`&7dQUnv$!cWCgW4-e=3ce+cT8iXsyhL59YznI-RQ1(K(}Fx#m%K<1+Y$ zR4#AMWWUZ$Mp{J)S24(O91!B#^eK%Jt!#PT$`QCr_W162JH=}tG=xZ8k)nb*>VIMm zLN#g$+tdjnMhgjFtRsr{0!^WZz<4AN9I@g({TD&C&{z=A3Fff-Hu>C9rex*57Gfxp z+n2TQo*K-&nvEyrJ}_eAZ-Z$u_R+~F&b7(4y7XRdUyFL{t4f(Nn!aUPXL8Q@AFwoe zV>r|8SYLxovd-Vf|FAl(HO&qJ?G^w;m$0e)?Z*Ve=2 z>1@vuiy#DPd9%7FxoxT`_}R|6k%{z%=dhLafhPnH>2vBFgzpcu9x;PGJ zj&0a5}EE^w&rvWd>gj=ryD#EoHo+Kb_BioYdm!g zsJ2}x{(~#tjg2b9(cywd%>C%?9b%8PR=J;ePq^SA+leS|8GpF(-v7fZv`EZW_qYvgEFT`@o-Tn3z5N5kRJ|~+Cl{HrKTvD$xQo0 z`o&0{hgw}W^T}8C0R^boU<`-LF3AIfyHJajbvbXF(&5q8IGE`FoYv!*t;9vX| zA4Dtcf;9sywmh${RGbjpS#0;W1&R@w^MRHU>SYW`0%Nbkoj)Yo?!A#kO^j%UUU;#rLNdG$W<$|b0-9mS&wv|5Uk%idfbK$TIIYo zu6rz@K%)h9AlZs9`AaB`<-KxFUxxE>KfZ0JO>BLFM~(vWZ_|&wp9!&;cbHf|`?q zv6~uP)FKA^{=Ii2Bb-Gz&T#RF#u{jGPV94S_Ny|=BAl3KO61Pl_;tv2sb+ZpjqfdY zONGUpgDbyY1{iO-P9hY3dwx9rp&~;0#Ye3^(I7_mu6$T!dk9W+)%=k~<;n5`;Bm4U z8aXsk&31Rfy(M`=(tH=$bO&N=H5FzN@3PV@WSp~_9H13BA=%~$U-)ZM5+!`X>C0*n zobmNi{aUk4!|;bje;G}!Mtr%J-`)42^?|y~PpeD+Ywj%D+W4Y&4{d?sEncL+zr_je zF2&s)ifeH9LUAoF0gAgfK=I=4?rs56oS-N4JlFF9&a0C*Mabp3 zv0M`mKQ2WSj`3a)?W>B`Z4q4lOL_(shZBUGnL;qfv!+AR_4%zc*5?2Fjk#0onPY_> z*=@NmOJiSo+_sr8_ pSJ%Vc^L;OVR9*RdHO7FmF166+4=`!xbR9d|;L)9MX^Eoo zcVw>6+=egYR{yOKmk_W~6)hS>*Y(q-tWdR3hi?7%nfBYKk6immJaQQsY^O(5X|s^+ z96`Ecd^DG+DfOXED_bf$ygl@!9A=-B9L=!^2XK+R6FnoR%&?)H5GpT<<{=X2H>gy$iF__0olGZ=(R7 zVHoDPlV=n_Ms@m8jG;PuP(~gZ9^zxFW*SuJ~yace?Kb$MZWkN*T(DLLu)% zid{txPhTn^v3wsHhCGwHK=1d(^LYpq_ER)dyYyXVP~tv3QJ6*%=XDvzn77u7e z%X9W)O5&f|nWm&M+8FJL{P@acMgg~@9{6-B?+5xRER5DIG!swWqf|mtv@lx?emZ4tE4aX)LqNcc<>mwD<3nO+U)U;v zx6*x8w^O(aPGcU6z4d1tZ*^N0RCvBeT1j(<_Vxgg%x61`BwhJw%pC8&(!s?RimKX1 z&R}@(9t#SS@&Tn17XbDh2Wa2ke;0PVPgv{lg$LIH>B53S9H284iSAKm%euJ3#xAnc zH~xFCX9i%re+}C6&S(kHX!`P5&=qo7v^Y3I;fpVEU)(ysE^do9|zOToGT ztKA}SCfe#d0m#A}ytGI{5v`Key;ERJx+O8_jF_asN!$2TMWAPZE+UT3H_c~`*j<$` zc#Xkt=F%dt%QVq#uB0#5?Bm-sl}KhEW`fXWkM|^sLQVz7jOBX4Zi)|H$DtQ4>lOD(TaGC?)1`fl?@E3-WZ@DyYed6i?n}WXwuf_uL5Y^KX0T^ zbcFowqR z*yE0uPZ2$lC2sS&ZG#fOBe{^PNx@!|(spF9P z9+y8RrH6Fcsq8-EN?Olpa^RsQ?p;BBT{utQlnHR;yYue%Ec=}^kN$8bHAU&k?rumCvf+gT2@^)vW3ni1ihyD{IH@Ba z2kluVqb9B_STBQ%`Dc}Hsy6Ig0s1FiuFx)2j27_{w_f6Wu4~kOp0Xj6<9gzBYEY6fKbj|#aj^GQ+7_5jU%^Z&!yax+6O}On_D-@P{0znD$m`$Zam?9M$IA4+u~(th zCd&L5kmRKi8aG2VM=_VKBo##gYj=71=GZ9k)1+*#!#24!lQkjpxhZ9l7`?vMKDsS9#6c;wJcO1bA^lpr%*JdP-Abq$)vgln;bvubui7 zCD|bFejD_Qe2t$f7;y*vM$0~gqZNg~=ZyvsdJri?OW&ZjRSQ~u>i3@^b8QTvhhZ&z zbcHQi##8Drou7ku?Z+z>zds=y!23jnrTRJj=CpsUsXh6f4fW7VYt+Cum%0z zb{KT3CSD)l8jfVkqgQf5WFD!9VZ$6y?18sINAttpaGEwi7x8$+nm|t{3N9k)vrHTL z%1o)Tx2EOm9~onU570OKG)M)6b24V=v;z8`TMi}r%OfmhiP+~$P=zW_NywxPdLj4bG)p}`5Bh%$h-YD zrpv@VV#W*|PWAz&kw1M1SBt+KJtBb@!BmPnl&=T;K(!J2-2B&mX~Qb<&%fL*_#Q{b zLn@`O7cqX3V{-!mRAC<<&ESX2EBI@fZ~jyc4JZrAReK&yiEr1JTnJM!W>-R)Gu;DD(diq1xB5K8!$uLz=?Z@Ul#;Xi$>%@g6*qqs(9zUv5S+iAOl#kQ*l~lgmn1CK#24N6?;l zZW(queLqnw#4)R;N@$UzA1qZy)x$?Vm%h0|+23=b3A4?ec<;C0!$%_`q&-c;``>ZD zVuWMAvK$yRGH-9dJ97m`LgdHa^s{`Vy7I#Vg9`MN%{(!p*M5sDcIz|JsqXipTLHGV zw^LhIi~`nbqR2Vyh8=z30Bto(ise&-Igti)hE;+JW4ji% zo`0FJeo3bW1jxe|Ewe6P;y)}BgoR*jEA5bKnyne^>|nVjW~Q7Pu;R(fi-Me0X9!_` zT2e08&fB}arCRUF)cAA7jISxNhcw>wxE{7^G<)Lrh4>ybuldG5kD+dWl!fjd;_8AV zB!Y%wLEmeGU1{dg&S zTxjgS-8;z?v5&R!bd|p!56&#?q4`8#B&RUKtJ`vQ!g_{M$6fkryNU-zklmnVHh=SP z8o@y(%5n!!*-bjU zH?VoQoL$8c`kAw0Yj#;{xV5t)NY`Y6xhEx&$bz(cGLJO8Bkt8qwnN90FxKcX(ws`V zk+!MPvO8iWrI1jfgsUJ|#{vb$cCQMeuCfU|NqGb{%}XskSAAFfxBl+BCYr5XmY^9b zOJeD{4Ssd3eY9D1fYAIzZ}=jY*u((K{me@OS)q-0YoZBWXidv$TlGI#gZPjbI8nM! zj7C(SPEKFJ^rni4myRA}hnYc&udCkv)aLTI;dKWjM9X5;A&Es+3?+@J0#KG-_+}pl zk=UN0g&=Gnz9^kxx^*a~;A*aJnd%3>ht4Rep(=?p@MFylN(G#A($VxTvGdTCo*Gg} znNMWYr5NpH!>liv@ph5|N%7$|6VBNri&^-TjEW{Ml69GcX<_E1J->V@PQNFjm@bw_ zGviIvx`-i`n-4gl&QG-2QggDKf#fwsERJ&eajpkTu0~TrFe*h1iU?y%?Q#z4P8yf| zMg0Z+NgY*7kSah)CC5J!#dHO4p++b74yjB=AzXH%k{{=1DG@c67JR1?%8%*r7>0oYy6g^_pkUFH8$n8xKJPd5y;_u!j=3|mVI94*2@(n=w~+GZ$*SDhMa@z zTPH;a0;Db0^u9}->4oHAXwGOza{ap*xnn*j=ZH83p-`SE*Jp`~qAbGQmEGYT24B(5D&AYMXtsVI9{iTC%s z%~A&X66MxwxY2AM)=J+kf2UU19lKCvM3<1vnE&|2_=kx9m-frsVu6OcuXQ=4wyW(f zTR+IQ2y&qLa#O`k$O~Cf;^OHKZOp#UbQttBp1KS*9K)X!hXf&O{2bKqhg7w9!fuao zn%k;&2@Y~kY@PR#O$J}eqUO3z8mKJAV*}?jvE1Lgw~UJ*pK-b$iX91nGd4i3ZR79g zmdhy_-K-QT5V1dafA&wB7sQXq4m$1{CVaZ#5xNg~;=x*z0<&}WkHiBfDR|7%0^B

rDjR98mA}dHOq?dRl<0uBwO@g*?9OMCbljK#p+c zZVKho3UU8&zcDY56#9bgmT7$)DKaF!CgxM;T{akbop`X)4Le(J`SDReq5tISY{?xu z1&x!ZHyZm6ylSefKKxoW`Y~Gk&xEye;_Y77k?k}8F+iZCR^^7N2DuK+lHVt$1f0F_ z{kSTp;2S6=_;02FbavjT_N%3J7mtPS7fc%6h)kXuN!7BvIX?QhMb~%ceVAY_I+Ws?=FCJqz{}B$ZJT3=d#9}pvZ@%IzVWh| zwV65q{gh=qc#n>(ZuZ5idnHS}QLDLwse$bqE8Y6jibsw;%Gf8RD7%ktFx|%*y@&R~ zQI#JqY>uk%w^n{BteAU8-f~QKYy%tp1O2Fom9u_BRlIRvL9Ls2ZZuV7RZ0K?2*%>J zjcCMpW3+&_P&e^tU%CuhaNG#RmDmsuQ?uu$Vp>5QyIAsjWb+~Xu|-G1k;@S?EJ~|B zWs)v#!B7nOi?=pw%Y#$zW4*PT2J4ft z**DOUg)h{~XXb&45!EGZm2?9-V-yDM*Q*E7Wtd@59<3=T)pt>u!?2Ib^*j z#XfMx!?5?#`H3=P-sZ*TUSh^GpJsu=2y^z5W9 zyyUGdq=vEYjE480WlkDD*ee(V%@yV5qSk)JJrfa2x;m*#h7tVXej3$lGd&Nfeu&CJ zBZZshg+(%IoZIo}n*<)Wly8>(;jAcK4;%|us1E5xX^tIB9DjffXB#Pyk7(m+sL!5L zCrZHtjE?a6X~qn$kdE|Z_ug7 zFyrKrij6Vw=rv3Yvc#batCcm{-l|F^Qpp&hta0K*Mws(<+UOzFuw~tarH17ZUOM@xHtqHR zHhNe70_qsVOLx1-X1~&!RHsNGFR9(YtrCvhrwblxS2arJg7_Qf=;l)O#p!It)`%dp=qw<$!;ofU^eZ0wE>*B>Jy zPk#_Rb+LETXtUy}C7t90ABJ+Eb{ywy=Nx^jlO`*|`R{^(VyVo!YU;=%9!gQ_0N{=J-+D!=~l6 z(Cq5c3eAn6Px)6l+KWV9oSQ&3wTN(1> zQxjF2i`9xuT8pNq^XPKuy3F;lah}BFvqxt_3_~r6uYBUG<8mVaHGu=rPHdt4>#ECxTwM z0V)lq^UvPkLuHrPh!8&#%@Y#KkPsgP0OPNo2_)ow%f)&R{d|M2owWFmmG#EcmLzk( zOT*EJkwz4_*H_}0_w~1aG4&WCCB7k}%6iC26&PhV-j>4ca-+_96)f+@?ud1 zO8tRgK`mPq4>o;UL7L%-6Z9Q{$ORP@Ikc~?wwd4*_`}m+&7{MRPkF$WKHi!>K`Y=U z4|<=l;y@HP@3cpRxDcb=Cz_OEhr7eIM6Ubq9{SpfOMg+4Une?~niLl*#M(Gby0|Fu z(#5+dCPb&B2s_==JbB949CzzGuR~Au?q5*3wd)W=$GKp|cOMfY-i7{oK#5z%VE+-u3O?FHMm_Ch6r-f;QYIjn;3SKkzoVJNoq zx-Myr{asI}r6AlTTBHEy z*9sb66nB9;Z+!%7VxG5J{Lw#mYiE1!bM5@vz}w_Fs~EiOcUSX{a@u$3IH_X@@Z2lp zuXaK~@^|WX)BA_YbH4yK+CTi9Hh6aS7QHbc>c2GCf2qWIL3ClE>~YVCsg`f!M-wY0 zv3_agOxO<63945T)>c`%H%LHWx zF{XDeTMA zZhccD=1I(6Cl`HLf-s<;pNb~SOW$Fz`aYtmBUcW6 z7_K(yL{~n zYT@~0pWQJZ<>PbQu)@!P&Y86l|8O2^1gkBc^Fql~6loUvjww#Qpj3#ooi?%h@RDe> z(EC(bE+Zy^-++$)6HMSnm<3r~KP?H=)v%Kj3HOg1t+Kl<>A8_H53a4nvfY*r34wvpu-q!YVtKI1?mjBCyeYZ<+^3@v=m19=>As6? zn?g0^e#xQrzaCqlL)?9T;002&Q4AL>Vv%!_pL?qi8yS5>y}d*{!yDEs)?gmjdoQ-> zE{Ilp#$>kP;Uqf&9Q7sm^jq6bo3Vp5f#DY~(>s1NRg|G;v>SFLjcPM6xq>001H|6K^$|z9dS?AEy zf@AcNGe&nuD0{kPinBZq^wxZC&kXmNm~4g!wq84*1wQ=g6HR;o3tB(4ku=YXDm*S? zLHm5p*JeiiVLkP-kU>U~DA_8A7M3g8V_hFxB?Hkwbh_shR=~otS3iwg)ojjZB5jWq zui~88OjP23wFXs9caa%zDK@VUx<5HIc09OQz%H)W+{XOqSFn zvC!~Q!7Hnu<*X|b8|B6q%@=bL%>kD-?Izoax6((IZVd6$TCCRXud`Z43F(DU^Yl-d zB~IocIqMU3GW(z>NosI$s$^{w%+2j(!FypMvTok?T3{Tz1g$U9Po*W6^7ZxF@@GyU zMpF9uibl^R*#QTRFtitTdZr<*<}z+anF;zTJ<+Zd`# zx&gY)szHNfEG+}u)My{}mqhjL7J|?|>G~x_pfuz4^}rBmacS0e@l3;kwXl>m>qR*J z+MA@RLj|gmX+O98!nI9ljp1Gy$O&^o=6#J>EWY9UReh3Jnt`sO{kpgR(Xk8MP1O1lcrsQh+z8YH#9c~!AvL(22IbiTYChsus69zZ`x7~8uZu^*H;B(b5 z|2%$`8F13&a;@{icuZ;K?Jq_l@Njrcc^u_(w$2h$kz{2t)f*N4c3JqiZ~dvYv1H24 zGqu!B8C3blXB)Jm3@O-BKPzoY_>*_5({Sb91|VvXK zq$rkgbYR{-S0YmakbvhKRfG2j&JmM=N#&JGEIk>(wCHQ+!$EeTtZ zb#VMTzG6x42)Ko=&9CI=%^J?0`IXC#gciqwsoVF(j?bxZ8Y6_GlCR zXb{;NrmqGh5ecswY;XJHV%y#ev?*Ha&1DFWP7nBJ{M=TyX^owA%Hl2${Ex5v4sySK zF@6=gq{15u#$1JFunpW7WI*+iu&Cg{>~#dBz-V2D6lo`ZTl0P z7jGj|Fo2mq8l3!}Al7BVnVS8Pe%vSbQzwmUXF87uN1vC=ug@Gh9PRFNi?L5M990;G zxwD0vyHBwb?gB!e`A_)5Uq@A<`-L`hVaIr`CvEm}$xqw1`s5(tGfcTg9967-UdLCy zH0>xoZ^fb~uIT8wJ4Q=ws9x~jy7>V_N7q2mDG#K&NhU1w+b#S(CmSQ*T&?Z#J=9j{ z;g-b+?3gE`4x7y1x{~(Ol|MH|blS1-2BJ}xNb5($!erg%xoF&B3EJVTXzXYo2y6r} zxt-p!mO6_pyV1vc5-7Iv)M#s&r>{4(d3Rvli!9fXG&06KD;`0oNay)E`axEFA&E1y z^ovM%AJFKGjmvG>&DnX!3ZRo5iT)Nt16|kE`A(yMIGzG#S$uNnN$*rjr9*FD!VM(*1PRPyVfq* zqp!=FD(t0y-$yZMTUhznZ5E$hOkh+u&vj%gv&Ckm-Nrq8ZE>M9t{V!u16#3T|Z14cHn@_;*LgmIhRN>MZJRD1rL>5eVo zqNxxt>;3Cw*uf<+&8$ToQ6=Zf0TsYi1&H^Y_T{ejGU5p=S8jeRPQ{{6vg^^=Q2$zs zLK04fedOOL6rBT|dsMk}GKf;iDL*Vf^c8GfBPP>Ljj`;1?s)9^){^m46&UCJr8a_L z&H(@T>Mu=QA2?9@;ko!xsJ*xAVEz`keDm#o4qhhu9~cN}0;XObEmRtp;wl#Le*A7K zN^023ClCV7b9)a>+`isSoBgf2`K1n%HV^YM8|8YPoEruv;}8^9SQe{CB2~(~^SzI* zdd|8N20rU0W1XRBtk!s}hn=qHEF;ch;oUr4kw2@nezf!Y1!#=fOKpVCd&pHq*`#4M z${~0@`T4tQo~WS+!j-dg0w*6-XU17CSI+7Q(`Ib5?T1m?@H{988Vc07`^cvU_&`7xUovm&>vE?c{ z0GyY{)=jQmsak;v?YH;bo_DDrS`MAA2rF}bCs|n;R;6%Y!np0U=pXya?gJ?;M~%tdovReRJ}&cZ6tEIoo}8&)|TKQBMm&bw&^80%dkB`9jH)VSOj zlM$}fY~NYB@L3!8zs7f`QK}rd@0J3Z z>q-w5hfS4o`lp^3dgO~Ome5>V!}>=eniLDYnR?Ir%}Kn$1BketIKS5Ow5gl=t6s06 zs2k$hxEP;BZfhKtB zTaI~$fKJY2UJu{XCFZ%7@tJis2;C(9y2EOfNS70dNhM;tVjAC*T-(V}wKHY#aO|`e zW~{x?4M#Oo>D>E7NJFJ*v+ihW95bBY#{^7`cAGqtN~MFOwO`gZ3iH{2jCxC(s9cBD z#tE31;7=iVunpJ&Kc^mr$`Jee63y5+Et@>1h4Fm1D!ke3EQ6`DX?09_2~I@J{{S#! zYIQ*8aOHmj?>r4I&B-nm$`bU+zvyLDYbaxA%<JS zg`<56`JuD?5$JzxCVBKRg=;QohdlZCkUd~)Rrm=Xoz@9Ga>Q5U&`OkbE&(SDuU2&M zgwtxR$i5ZYG+%iy5mrT!=QV{Y81P@j98g}YpDf@*8T}Noeez*0Nk8~RVi{a3lFwqQ z6d;;W-t_Cl4w zmg@dDqaJz;P52AV1;+YTBxtmGW9Hqv3nM6a^FdSJW4~yhQJT|&4ywINC+{kYy}wXZ zO_U1z6HoBm`TAA4wUl%f*{_tH+@yMoZ3ryvmhi#ROL3u#tR>k$w;H|b*BD$F&F)$9 zOf!>_3V+4t#G$K5xQiJrS={^!0@we8J`BCpYLPxKCxTLLMjdw64V*7(%e! z0KaU7r=D<8JRoxpub6@wfFljCzjf{BpP0~9wdTS*%0dJEjNd6EAoMDu54H0jHQncIM04UlAplGy-x(+`n!YMq?xR$`9b%I8or=%DN2ryMD zic9K6Dh0ovKR1KJK(4@uO>26o)^LzeLo zKUGSaGF_DsDh+VBrQ{sf=cA^7cA<$-5m)w-P zQQ!~Y3E6$PJ!?-!`rqB-TL_Ybz*s%0j*HGU=0DG_Eg#Moo6WP=#Sr7kUZElU^lX2% zwoee$(5Z^jXrn(}htvGx@vE;8MTA~1b4y1;-vVyKNLabJ%v+i(NLjw)RIw&C!tOsq zO}fb>_{#{{i>7Hz|H6TpPwmf9A?#AT*pT%!;WLr z25x_mvl(wTlTg{F-jLpj#y&A^y{I1{I4!NihA!5GfjBQ?kTyCs2i|O>02AxK@R)eSVaTwcs(}Qf`<+iDp_znnhJVFVfkXS_maBGWjG_cINolFzMSiB18*q{ z80>E6sDur22nYkuU#uo!Wh?rjxu-lV4qj+)cMJEli1F+%=l-^o0I&K9Gs#g#Eyo{{ z&tUf}gz$eykXRX#d5OkPX6|7I-`}t~HY;>e;3mAR4}tg7OJ7c0<1tTP?Ubc&zY6W@ z`}GYjg7ZIHSf+v!CNeegXz;oEkly|8@%qlsc6%q5p*W0h-l6~3UM`FjpKI){q)3GP zf7=c1*nig=mYMdRN|bFvdbe$K?>MF0L7;LegfM{lZzkL|D&ySz95DiOUS5q)nCl-k zI&Ou%776{=e-cy+D;VqAwLoLaOU!A;<(vS(ocC7PWYDXhZ(aunH!~LpLgFRe2zz4w*^`C_=xchI8`iaRxa00win~Av;v=IaK|84O)YhlW!2S}`pmb~?NsJpXs-aB zcJ0m=hhX^Sqr6=HuRCYzb>}HfUMM2Dd;;Z5PawU_s-Z&1tvZ?N1z(16Z6cWvoU?9J zIs`VU7uPVVi{{HPbX1|$Rh%x2PSfBiHW)Cvb-mARPtee%l!srTa&BYF}DNIT8;DbxrFcyz~ z_4=}GE$teo+H+AI8t@3_j}6sAh{t~!WYX=)1*kN+%TaQzMG_I%Ki`p7!55Q-=H5qS z*-ZK-$3)*R(^Akyuhptgxr7LHVlOAYy;GX(CFo~!kU*u;s+P(+T9W&h6R3<{Eq<=2-m5AX#Pjb+x{O|r$<#`4F>ZdNLDpPqM8Sj(2r$+lWjUtA>qq%FMf)<6! zVU_Wuqfhgq!>ZRjqQAsPk*YmSpQ{Z|1d&5L)iu~tqu<^JA-SHeUtw9WR)FSNLsh83 zb=jYdd}xb@JWtWK^i(vGmU8x%tBsPgt5k0zY8}9?|ucNfV0A6 zn7XtzJRMJ%yXnE_?!%RfuiO^_MGhg(6-Y*(P{4|1cuvC!JCHa8SI0x7v@3!s#%H5D z;OP(c!xHpKf(Q*^@t+kh!Nu*~7gG)oU4*KX5?jOVdt(LgqH6jdMEhaIcbT0VccV>b z21`XZh;}xVpp`2I55da~@`qiNrdD*3v47?iJioPEYj_^_hbm!{^S1VjMY?az` zT-cS#Oq=5Dar+j5^;FRRp&+LtyC!$s{{CTy2b`h)ynj@_r{y|6X!N+b-kRh1>L=bW z_{_8D8dRfcT)XIex+lNu5%(Cc9IMn=GU#2UKLg_XIS5j2nGoOmaR{wQ<`4hY+}A|$ zSogXSCvS&j-{Wmu?y#ToBPyf8r^wf$gFRTB(Cv>nKiDAibAkO&?BIGl!ar_JZ@XuN zF~KRvE!OL)_8nKT<7?i-K&95hzRV6;7DVO$UK;O}aPGpV3rt*ze2D}Z^XcZGe!l#E z#3jsQ{xwtXe!H95{20WYEd3YB8rN26Rtt9deJI(yaEr(Inz&x$NVOv!Vc^t@k7HM; ze4FbLZ=>*co5ncDxDpqblS9m03Z0<3dMtXxK%hm1yCDPw5iYhC%y%zea$Z^?AeaTi zhZh0@1qs~E!as{IP6+{F0QrCRhIbSEQ}=RvhJX-{2|r~7gh0D@%GoqKmhi7LGG9d1 l;eY>M5A*+B)1I$?pZUw`cj@l_V|h6_841NNRbpR*{tx+YVoLx3 literal 0 HcmV?d00001 From e3e2bcb5b631f1bf2903b871c4ec7cfa74601b4e Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Fri, 8 May 2026 11:29:11 -0300 Subject: [PATCH 2/5] feat(soldados): roster page with profile, scoring and redesigned 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /soldados list (Ranking + Grilla views) merging curated PROJECTS team members with Nostr submission teams; identity merge by case-insensitive github handle so the same builder collapses to one card with the Nostr avatar/badge taking precedence. - Per-soldado scoring: +3 per hackathon, +2 per project, +6/5/4/3/2/1 for 1°…6° place pulled from data/hackathons/reports.json (case- insensitive id match). Table sorts by score desc and shows medal + best position; cells expose a per-component breakdown via title. - /soldados/[slug] profile page (server component, "use cache"): banner + avatar, NIP-05, npub (njump.me link), lightning address, website, bio, project list with hackathon link + position points badge, hackathon sidebar, npub/hex card, roles. New lib/nostrProfileCache.ts adds a server-side kind:0 fetcher cached per pubkey (cacheTag("nostr:profile:")). - Grid cards: hackathon chips and "+N más" overflow are now Links; table rows link to the profile page. - Navbar gains "Soldados" link; sitemap registers /soldados. - 404 page reworked: big "404" heading, eyebrow chip "NOT FOUND", refreshed copy, plus a BrokenCableAnimation Lottie component (lottie-react dep added) and global error.tsx / global-error.tsx that match the same look. - Favicon switched to lacrypta.ar's logo (app/icon.png replaces the old app/favicon.ico). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/error.tsx | 74 +++ app/favicon.ico | Bin 25931 -> 0 bytes app/global-error.tsx | 101 ++++ app/icon.png | Bin 0 -> 967 bytes app/not-found.tsx | 67 +++ app/sitemap.ts | 6 + app/soldados/SoldadosClient.tsx | 76 +++ app/soldados/SoldadosGrid.tsx | 189 +++++++ app/soldados/SoldadosTable.tsx | 266 +++++++++ app/soldados/[slug]/page.tsx | 444 +++++++++++++++ app/soldados/page.tsx | 30 + components/Navbar.tsx | 1 + components/ui/BrokenCableAnimation.tsx | 35 ++ components/ui/animations/brokenCable.json | 648 ++++++++++++++++++++++ lib/nostrProfileCache.ts | 99 ++++ lib/soldados.ts | 279 ++++++++++ package-lock.json | 26 +- package.json | 1 + 18 files changed, 2338 insertions(+), 4 deletions(-) create mode 100644 app/error.tsx delete mode 100644 app/favicon.ico create mode 100644 app/global-error.tsx create mode 100644 app/icon.png create mode 100644 app/not-found.tsx create mode 100644 app/soldados/SoldadosClient.tsx create mode 100644 app/soldados/SoldadosGrid.tsx create mode 100644 app/soldados/SoldadosTable.tsx create mode 100644 app/soldados/[slug]/page.tsx create mode 100644 app/soldados/page.tsx create mode 100644 components/ui/BrokenCableAnimation.tsx create mode 100644 components/ui/animations/brokenCable.json create mode 100644 lib/nostrProfileCache.ts create mode 100644 lib/soldados.ts diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..7c453e5 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { ArrowLeft, RotateCw, Zap } from "lucide-react"; +import BrokenCableAnimation from "@/components/ui/BrokenCableAnimation"; + +export default function Error({ + error, + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +

+
+
+
+
+
+ +
+ + +
+ + ERROR INESPERADO +
+ +

+ Algo se rompió +

+ +

+ Hubo un cortocircuito de nuestro lado. Reintentá o volvé al inicio + mientras lo arreglamos. +

+ +
+ + + + Volver al inicio + +
+ + {error.digest && ( +

+ $ ref:{" "} + {error.digest} +

+ )} +
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fea4835ec2d246af9800eddb7ffb276240c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25931 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 LrJugUA?W`A8`#=m diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..427b426 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect } from "react"; +import { Inter, Space_Grotesk, JetBrains_Mono } from "next/font/google"; +import { RotateCw, Zap } from "lucide-react"; +import BrokenCableAnimation from "@/components/ui/BrokenCableAnimation"; +import "./globals.css"; + +const inter = Inter({ + variable: "--font-inter", + subsets: ["latin"], + display: "swap", +}); + +const spaceGrotesk = Space_Grotesk({ + variable: "--font-space-grotesk", + subsets: ["latin"], + display: "swap", +}); + +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains-mono", + subsets: ["latin"], + display: "swap", +}); + +export default function GlobalError({ + error, + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( + + + Falla crítica · La Crypta Dev + + + + + + ); +} diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..57e0f14781368e277089e6cb14dcde566d33ad7a GIT binary patch literal 967 zcmeAS@N?(olHy`uVBq!ia0vp^1t8491|*L?_~H$uI14-?iy0WWg+Z8+Vb&Z8pde#$ zkh>GZx^prwfgF}}M_)$E)e-c?47?@)`T^vIy7~kG?^iv8HX?>Vo&Oc`*_k|q{ zz0w6QJRUQZunLGV9G=I>t+0WCiCZD3;Xn^(qv!!94(%83f<2c`&2UM+`~BSN&#G+J zFBzYlUt49f>cfjKU%vEnu9a82A~InP)1+XBA}$X<#*>-~maHnv8=l}6{GH0VWAEOr zt5<99-nZ{uou8lIn@5ixUHu=q{`$@HdD}O?EXi7ZRjWFs{76NhFq7h~%a?<{b=23p z$`xD4+^VoKa|>YQ+xm8n*|r!x-7OEgCFaD7b+>LjD3X*YY?XAu zmFH37!9yDLvmgKcR}z1r!Q%bVEw}#Y*nIa}o~+2q-SIiD=k%xZhfayCSDm8p{J@_J zCv9r=AJ$Y97f-gW;x=l0(|)e@XIeGKJK+<%UfHlLyHLIRY%P;v?MkkXw_?-{p1y1r zc8{pR}X zgd@a$K5vtYVS02Svh8p>-}qo8`M|kJ8zbe~(^KG)GW~f7siSgav0u@?b<@u%v6Y`XR&aXr_7tW^)#pC6+N3Ua z)<8{Gx43mDd~zopr E0L&|^3jhEB literal 0 HcmV?d00001 diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..8ebd7a5 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowLeft, Zap } from "lucide-react"; +import BrokenCableAnimation from "@/components/ui/BrokenCableAnimation"; + +export const metadata: Metadata = { + title: "404 — Cable cortado", + description: "La página que buscás se desconectó del aire.", + robots: { index: false, follow: false }, +}; + +export default function NotFound() { + return ( +
+
+
+
+
+
+ +
+ + +
+ + NOT FOUND +
+ +

+ 404 +

+ +

+ Cable cortado +

+ +

+ La página que buscás se desconectó del aire. Probá reconectarte desde + otro lado. +

+ +
+ + + Volver al inicio + + + Ver hackatones + +
+ +

+ $ status: DISCONNECTED +

+
+
+ ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index bb80bc6..2928473 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -43,6 +43,12 @@ function staticSitemap(): MetadataRoute.Sitemap { changeFrequency: "weekly", priority: 0.9, }, + { + url: `${BASE_URL}/soldados`, + lastModified: now, + changeFrequency: "weekly", + priority: 0.8, + }, { url: `${BASE_URL}/infrastructure`, lastModified: now, diff --git a/app/soldados/SoldadosClient.tsx b/app/soldados/SoldadosClient.tsx new file mode 100644 index 0000000..5749759 --- /dev/null +++ b/app/soldados/SoldadosClient.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import { LayoutGrid, List } from "lucide-react"; +import { cn } from "@/lib/cn"; +import type { Soldado } from "@/lib/soldados"; +import SoldadosGrid from "./SoldadosGrid"; +import SoldadosTable from "./SoldadosTable"; + +type View = "grid" | "table"; + +export default function SoldadosClient({ soldados }: { soldados: Soldado[] }) { + const [view, setView] = useState("table"); + + return ( +
+
+

+ + {soldados.length} + {" "} + builders en la comunidad + {view === "table" && " · ranking por score"}. +

+
+ setView("table")} + icon={} + label="Ranking" + /> + setView("grid")} + icon={} + label="Grilla" + /> +
+
+ + {view === "table" ? ( + + ) : ( + + )} +
+ ); +} + +function ViewButton({ + active, + onClick, + icon, + label, +}: { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; +}) { + return ( + + ); +} diff --git a/app/soldados/SoldadosGrid.tsx b/app/soldados/SoldadosGrid.tsx new file mode 100644 index 0000000..a0a21c0 --- /dev/null +++ b/app/soldados/SoldadosGrid.tsx @@ -0,0 +1,189 @@ +import Link from "next/link"; +import { Zap, Trophy } from "lucide-react"; +import { GithubIcon } from "@/components/BrandIcons"; +import { HACKATHON_LABELS } from "@/lib/projects"; +import { cn } from "@/lib/cn"; +import type { Soldado } from "@/lib/soldados"; + +function avatarSrc(s: Soldado): string | null { + if (s.picture) return s.picture; + if (s.github) return `https://github.com/${s.github}.png?size=160`; + return null; +} + +function initials(name: string): string { + const parts = name + .replace(/[^\p{L}\p{N}\s]+/gu, " ") + .trim() + .split(/\s+/); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); + return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); +} + +function uniqHackathons(s: Soldado): string[] { + const set = new Set(); + for (const p of s.projects) if (p.hackathonId) set.add(p.hackathonId); + return [...set]; +} + +function hackathonLabel(id: string): string { + if (id in HACKATHON_LABELS) { + return HACKATHON_LABELS[id as keyof typeof HACKATHON_LABELS]; + } + return id; +} + +export default function SoldadosGrid({ soldados }: { soldados: Soldado[] }) { + if (soldados.length === 0) { + return ( +
+ Todavía no hay soldados. +
+ ); + } + + return ( +
    + {soldados.map((s) => ( + + ))} +
+ ); +} + +function SoldadoCard({ soldado }: { soldado: Soldado }) { + const src = avatarSrc(soldado); + const hackathons = uniqHackathons(soldado); + + return ( +
  • + {soldado.hasNostr && ( + + )} + +
    + + {src ? ( + // eslint-disable-next-line @next/next/no-img-element + {soldado.name} + ) : ( + + {initials(soldado.name)} + + )} + +
    +
    + + {soldado.name} + + {soldado.hasNostr && ( + + + + )} +
    + {soldado.github && ( + + + {soldado.github} + + )} +
    +
    + +
    + {soldado.projects.length}{" "} + {soldado.projects.length === 1 ? "proyecto" : "proyectos"} + {hackathons.length > 0 && ( + <> + {" · "} + {hackathons.length}{" "} + {hackathons.length === 1 ? "hackatón" : "hackatones"} + + )} +
    + + {hackathons.length > 0 && ( +
    + {hackathons.map((h) => ( + + + {hackathonLabel(h)} + + ))} +
    + )} + +
      + {soldado.projects.slice(0, 4).map((p, i) => { + const href = p.hackathonId + ? `/hackathons/${p.hackathonId}/${p.projectId}` + : `/projects#${p.projectId}`; + return ( +
    • + + + {p.projectName} + + · {p.role} + + +
    • + ); + })} + {soldado.projects.length > 4 && ( +
    • + + +{soldado.projects.length - 4} más + +
    • + )} +
    +
  • + ); +} diff --git a/app/soldados/SoldadosTable.tsx b/app/soldados/SoldadosTable.tsx new file mode 100644 index 0000000..986cfb1 --- /dev/null +++ b/app/soldados/SoldadosTable.tsx @@ -0,0 +1,266 @@ +import Link from "next/link"; +import { Trophy, Zap } from "lucide-react"; +import { GithubIcon } from "@/components/BrandIcons"; +import { cn } from "@/lib/cn"; +import type { Soldado } from "@/lib/soldados"; + +function avatarSrc(s: Soldado): string | null { + if (s.picture) return s.picture; + if (s.github) return `https://github.com/${s.github}.png?size=80`; + return null; +} + +function initials(name: string): string { + const parts = name + .replace(/[^\p{L}\p{N}\s]+/gu, " ") + .trim() + .split(/\s+/); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); + return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); +} + +function uniqHackathonsCount(s: Soldado): number { + const set = new Set(); + for (const p of s.projects) if (p.hackathonId) set.add(p.hackathonId); + return set.size; +} + +function bestPosition(s: Soldado): number | null { + let best: number | null = null; + for (const p of s.projects) { + if (p.position == null) continue; + if (best == null || p.position < best) best = p.position; + } + return best; +} + +function medal(position: number | null): string { + if (position === 1) return "🥇"; + if (position === 2) return "🥈"; + if (position === 3) return "🥉"; + return ""; +} + +export default function SoldadosTable({ soldados }: { soldados: Soldado[] }) { + // Sort by score desc, then name asc. + const ranked = [...soldados].sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); + + return ( +
    + {/* Desktop / tablet */} + + + + + + + + + + + + + {ranked.map((s, i) => { + const rank = i + 1; + const src = avatarSrc(s); + const bp = bestPosition(s); + const ths = uniqHackathonsCount(s); + return ( + + + + + + + + + ); + })} + +
    #BuilderHackatonesProyectosMejorScore
    + 3 && "bg-white/[0.04] text-foreground-muted", + )} + > + {rank} + + +
    + + {src ? ( + // eslint-disable-next-line @next/next/no-img-element + {s.name} + ) : ( + + {initials(s.name)} + + )} + +
    +
    + + {s.name} + + {s.hasNostr && ( + + + + )} +
    + {s.github && ( + + + {s.github} + + )} +
    +
    +
    + {ths} + + {s.projects.length} + + {bp ? ( + + {medal(bp) || ( + + )} + {bp}° + + ) : ( + + )} + + + {s.score} + +
    + + {/* Mobile */} +
      + {ranked.map((s, i) => { + const rank = i + 1; + const src = avatarSrc(s); + const bp = bestPosition(s); + return ( +
    • + + + {rank} + + {src ? ( + // eslint-disable-next-line @next/next/no-img-element + {s.name} + ) : ( + + {initials(s.name)} + + )} +
      +
      + + {s.name} + + {s.hasNostr && ( + + + + )} +
      +
      + {s.projects.length}p + · + {uniqHackathonsCount(s)}h + {bp && ( + <> + · + {medal(bp) || `${bp}°`} + + )} +
      +
      + + {s.score} + + +
    • + ); + })} +
    +
    + ); +} diff --git a/app/soldados/[slug]/page.tsx b/app/soldados/[slug]/page.tsx new file mode 100644 index 0000000..6447b1b --- /dev/null +++ b/app/soldados/[slug]/page.tsx @@ -0,0 +1,444 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { nip19 } from "nostr-tools"; +import { + ArrowLeft, + AtSign, + Award, + ExternalLink, + Globe, + Trophy, + Zap, +} from "lucide-react"; +import { GithubIcon } from "@/components/BrandIcons"; +import { HACKATHON_LABELS } from "@/lib/projects"; +import { + getSoldadoBySlug, + getSoldados, + type Soldado, + type SoldadoProjectRef, +} from "@/lib/soldados"; +import { getCachedNostrProfile } from "@/lib/nostrProfileCache"; +import { cn } from "@/lib/cn"; + +type RouteParams = { slug: string }; + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { slug } = await params; + const s = await getSoldadoBySlug(slug); + if (!s) return { title: "Soldado", robots: { index: false } }; + return { + title: `${s.name} — Soldados`, + description: `Builder de La Crypta · ${s.projects.length} proyecto(s) · score ${s.score}.`, + }; +} + +function avatarSrc(s: Soldado, 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=320`; + return null; +} + +function initials(name: string): string { + const parts = name + .replace(/[^\p{L}\p{N}\s]+/gu, " ") + .trim() + .split(/\s+/); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); + return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); +} + +function medal(position?: number): string { + if (position === 1) return "🥇"; + if (position === 2) return "🥈"; + if (position === 3) return "🥉"; + return ""; +} + +function hackathonLabel(id: string): string { + if (id in HACKATHON_LABELS) { + return HACKATHON_LABELS[id as keyof typeof HACKATHON_LABELS]; + } + return id; +} + +function safeNpub(pubkey?: string): string | null { + if (!pubkey) return null; + try { + return nip19.npubEncode(pubkey); + } catch { + return null; + } +} + +export default async function SoldadoProfilePage({ + params, +}: { + params: Promise; +}) { + const { slug } = await params; + const soldado = await getSoldadoBySlug(slug); + if (!soldado) notFound(); + + // Live Nostr profile (kind 0) for richer data when we know a pubkey. + const profile = soldado.pubkey + ? await getCachedNostrProfile(soldado.pubkey) + : null; + + const displayName = + profile?.display_name || profile?.name || soldado.name; + const banner = profile?.banner; + const avatar = avatarSrc(soldado, profile?.picture); + const npub = safeNpub(soldado.pubkey); + const lud16 = profile?.lud16; + const website = profile?.website; + const nip05 = profile?.nip05 ?? soldado.nip05; + const about = profile?.about; + + const hackathons = new Set(); + for (const p of soldado.projects) if (p.hackathonId) hackathons.add(p.hackathonId); + + const sortedProjects = [...soldado.projects].sort((a, b) => { + const pa = a.position ?? 999; + const pb = b.position ?? 999; + if (pa !== pb) return pa - pb; + return a.projectName.localeCompare(b.projectName); + }); + + return ( +
    + + +
    + + + Soldados + + +
    + {avatar ? ( + // eslint-disable-next-line @next/next/no-img-element + {displayName} + ) : ( + + {initials(displayName)} + + )} +
    +

    + {displayName} + {soldado.hasNostr && ( + + + Nostr + + )} +

    +
    + {nip05 && ( + + + {nip05} + + )} + {soldado.github && ( + + + {soldado.github} + + )} + {website && ( + + + {website.replace(/^https?:\/\//, "")} + + )} + {lud16 && ( + + + {lud16} + + )} +
    +
    +
    + + {/* Score banner */} +
    + + + + +
    + +
    +
    + {about && ( + }> +

    + {about} +

    +
    + )} + + } + > +
      + {sortedProjects.map((p, i) => ( + + ))} +
    +
    +
    + +
    + {hackathons.size > 0 && ( + } + > +
      + {[...hackathons].map((h) => ( +
    • + + + {hackathonLabel(h)} + +
    • + ))} +
    +
    + )} + + {npub && ( + }> +
    +
    +
    + npub +
    + + {npub} + + +
    + {soldado.pubkey && ( +
    +
    + pubkey (hex) +
    + + {soldado.pubkey} + +
    + )} +
    +
    + )} + + {soldado.roles.length > 0 && ( + }> +
    + {soldado.roles.map((r) => ( + + {r} + + ))} +
    +
    + )} +
    +
    +
    +
    + ); +} + +function ProfileBanner({ banner }: { banner?: string }) { + if (banner) { + return ( +
    +
    +
    + ); + } + return ( +
    +
    +
    +
    +
    + ); +} + +function ScoreCell({ + label, + value, + sub, + tone, + big, +}: { + label: string; + value: string | number; + sub?: string; + tone?: "bitcoin"; + big?: boolean; +}) { + return ( +
    +
    + {label} +
    +
    + {value} +
    + {sub && ( +
    + {sub} +
    + )} +
    + ); +} + +function Card({ + title, + icon, + children, +}: { + title: string; + icon?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
    +
    + {icon} +

    + {title} +

    +
    + {children} +
    + ); +} + +function ProjectRow({ project }: { project: SoldadoProjectRef }) { + const href = project.hackathonId + ? `/hackathons/${project.hackathonId}/${project.projectId}` + : `/projects#${project.projectId}`; + return ( +
  • + +
    +
    + {project.position && project.position <= 3 && ( + + {medal(project.position)} + + )} + + {project.projectName} + +
    +
    + {project.role} + {project.hackathonId && ( + <> + {" · "} + {hackathonLabel(project.hackathonId)} + + )} + {project.position && ( + <> + {" · "} + {project.position}° + + )} +
    +
    + {project.positionPoints > 0 && ( + + +{project.positionPoints} + + )} + +
  • + ); +} diff --git a/app/soldados/page.tsx b/app/soldados/page.tsx new file mode 100644 index 0000000..053960d --- /dev/null +++ b/app/soldados/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { Users } from "lucide-react"; +import PageHero from "@/components/ui/PageHero"; +import { getSoldados } from "@/lib/soldados"; +import SoldadosClient from "./SoldadosClient"; + +export const metadata: Metadata = { + title: "Soldados", + description: + "La gente detrás de los proyectos de La Crypta Dev — builders que armaron, presentaron y ganaron en cada hackatón.", +}; + +export default async function SoldadosPage() { + const soldados = await getSoldados(); + return ( + <> + } + title={ + + Soldados. + + } + description="Builders, devs y diseñadores que armaron, presentaron y ganaron en cada hackatón. Curados de los proyectos oficiales y de la red Nostr." + /> + + + ); +} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 1d83c5e..b7bb77f 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -36,6 +36,7 @@ type NavLink = { const NAV_LINKS: NavLink[] = [ { href: "/hackathons", label: "Hackatones" }, { href: "/projects", label: "Proyectos" }, + { href: "/soldados", label: "Soldados" }, { href: "/infrastructure", label: "Infra propia" }, ]; diff --git a/components/ui/BrokenCableAnimation.tsx b/components/ui/BrokenCableAnimation.tsx new file mode 100644 index 0000000..86af6c6 --- /dev/null +++ b/components/ui/BrokenCableAnimation.tsx @@ -0,0 +1,35 @@ +"use client"; + +import dynamic from "next/dynamic"; +import animationData from "./animations/brokenCable.json"; + +const Lottie = dynamic(() => import("lottie-react"), { + ssr: false, + loading: () => ( + ); diff --git a/app/soldados/SoldadosGrid.tsx b/app/soldados/SoldiersGrid.tsx similarity index 81% rename from app/soldados/SoldadosGrid.tsx rename to app/soldados/SoldiersGrid.tsx index a0a21c0..be0f5dc 100644 --- a/app/soldados/SoldadosGrid.tsx +++ b/app/soldados/SoldiersGrid.tsx @@ -3,9 +3,9 @@ import { Zap, Trophy } from "lucide-react"; import { GithubIcon } from "@/components/BrandIcons"; import { HACKATHON_LABELS } from "@/lib/projects"; import { cn } from "@/lib/cn"; -import type { Soldado } from "@/lib/soldados"; +import type { Soldier } from "@/lib/soldiers"; -function avatarSrc(s: Soldado): string | null { +function avatarSrc(s: Soldier): string | null { if (s.picture) return s.picture; if (s.github) return `https://github.com/${s.github}.png?size=160`; return null; @@ -21,7 +21,7 @@ function initials(name: string): string { return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); } -function uniqHackathons(s: Soldado): string[] { +function uniqHackathons(s: Soldier): string[] { const set = new Set(); for (const p of s.projects) if (p.hackathonId) set.add(p.hackathonId); return [...set]; @@ -34,39 +34,39 @@ function hackathonLabel(id: string): string { return id; } -export default function SoldadosGrid({ soldados }: { soldados: Soldado[] }) { - if (soldados.length === 0) { +export default function SoldiersGrid({ soldiers }: { soldiers: Soldier[] }) { + if (soldiers.length === 0) { return (
    - Todavía no hay soldados. + Todavía no hay soldiers.
    ); } return (
      - {soldados.map((s) => ( - + {soldiers.map((s) => ( + ))}
    ); } -function SoldadoCard({ soldado }: { soldado: Soldado }) { - const src = avatarSrc(soldado); - const hackathons = uniqHackathons(soldado); +function SoldierCard({ soldier }: { soldier: Soldier }) { + const src = avatarSrc(soldier); + const hackathons = uniqHackathons(soldier); return (
  • - {soldado.hasNostr && ( + {soldier.hasNostr && ( {src ? ( // eslint-disable-next-line @next/next/no-img-element {soldado.name} ) : ( - {initials(soldado.name)} + {initials(soldier.name)} )}
    - {soldado.name} + {soldier.name} - {soldado.hasNostr && ( + {soldier.hasNostr && ( )}
    - {soldado.github && ( + {soldier.github && ( - {soldado.github} + {soldier.github} )}
  • - {soldado.projects.length}{" "} - {soldado.projects.length === 1 ? "proyecto" : "proyectos"} + {soldier.projects.length}{" "} + {soldier.projects.length === 1 ? "proyecto" : "proyectos"} {hackathons.length > 0 && ( <> {" · "} @@ -153,7 +153,7 @@ function SoldadoCard({ soldado }: { soldado: Soldado }) { )}
      - {soldado.projects.slice(0, 4).map((p, i) => { + {soldier.projects.slice(0, 4).map((p, i) => { const href = p.hackathonId ? `/hackathons/${p.hackathonId}/${p.projectId}` : `/projects#${p.projectId}`; @@ -173,13 +173,13 @@ function SoldadoCard({ soldado }: { soldado: Soldado }) { ); })} - {soldado.projects.length > 4 && ( + {soldier.projects.length > 4 && (
    • - +{soldado.projects.length - 4} más + +{soldier.projects.length - 4} más
    • )} diff --git a/app/soldados/SoldadosTable.tsx b/app/soldados/SoldiersTable.tsx similarity index 62% rename from app/soldados/SoldadosTable.tsx rename to app/soldados/SoldiersTable.tsx index 986cfb1..803acab 100644 --- a/app/soldados/SoldadosTable.tsx +++ b/app/soldados/SoldiersTable.tsx @@ -2,9 +2,9 @@ import Link from "next/link"; import { Trophy, Zap } from "lucide-react"; import { GithubIcon } from "@/components/BrandIcons"; import { cn } from "@/lib/cn"; -import type { Soldado } from "@/lib/soldados"; +import type { Soldier } from "@/lib/soldiers"; -function avatarSrc(s: Soldado): string | null { +function avatarSrc(s: Soldier): string | null { if (s.picture) return s.picture; if (s.github) return `https://github.com/${s.github}.png?size=80`; return null; @@ -20,13 +20,13 @@ function initials(name: string): string { return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); } -function uniqHackathonsCount(s: Soldado): number { +function uniqHackathonsCount(s: Soldier): number { const set = new Set(); for (const p of s.projects) if (p.hackathonId) set.add(p.hackathonId); return set.size; } -function bestPosition(s: Soldado): number | null { +function bestPosition(s: Soldier): number | null { let best: number | null = null; for (const p of s.projects) { if (p.position == null) continue; @@ -42,15 +42,32 @@ function medal(position: number | null): string { return ""; } -export default function SoldadosTable({ soldados }: { soldados: Soldado[] }) { +function medalCounts(s: Soldier): { + gold: number; + silver: number; + bronze: number; + total: number; +} { + let gold = 0; + let silver = 0; + let bronze = 0; + for (const p of s.projects) { + if (p.position === 1) gold++; + else if (p.position === 2) silver++; + else if (p.position === 3) bronze++; + } + return { gold, silver, bronze, total: gold + silver + bronze }; +} + +export default function SoldiersTable({ soldiers }: { soldiers: Soldier[] }) { // Sort by score desc, then name asc. - const ranked = [...soldados].sort((a, b) => { + const ranked = [...soldiers].sort((a, b) => { if (b.score !== a.score) return b.score - a.score; return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); }); return ( -
      +
      {/* Desktop / tablet */} @@ -59,6 +76,7 @@ export default function SoldadosTable({ soldados }: { soldados: Soldado[] }) { + @@ -150,6 +168,54 @@ export default function SoldadosTable({ soldados }: { soldados: Soldado[] }) { + - @@ -264,3 +330,60 @@ export default function SoldadosTable({ soldados }: { soldados: Soldado[] }) { ); } + +function ScoreBreakdownTooltip({ soldier }: { soldier: Soldier }) { + const { scoreBreakdown: b, projects } = soldier; + const hackathonsCount = new Set( + projects.filter((p) => p.hackathonId).map((p) => p.hackathonId), + ).size; + return ( + +
      + Composición del score +
      +
        +
      • + + Hackatones ·{" "} + {hackathonsCount} × 3 + + + +{b.hackathons} + +
      • +
      • + + Proyectos ·{" "} + {projects.length} × 2 + + + +{b.projects} + +
      • +
      • + Posiciones + + +{b.positions} + +
      • +
      +
      + + Total + + + {b.total} + +
      +
      + ); +} diff --git a/app/soldados/[slug]/page.tsx b/app/soldados/[slug]/page.tsx index 6447b1b..da983c0 100644 --- a/app/soldados/[slug]/page.tsx +++ b/app/soldados/[slug]/page.tsx @@ -14,11 +14,11 @@ import { import { GithubIcon } from "@/components/BrandIcons"; import { HACKATHON_LABELS } from "@/lib/projects"; import { - getSoldadoBySlug, - getSoldados, - type Soldado, - type SoldadoProjectRef, -} from "@/lib/soldados"; + getSoldierBySlug, + getSoldiers, + type Soldier, + type SoldierProjectRef, +} from "@/lib/soldiers"; import { getCachedNostrProfile } from "@/lib/nostrProfileCache"; import { cn } from "@/lib/cn"; @@ -30,7 +30,7 @@ export async function generateMetadata({ params: Promise; }): Promise { const { slug } = await params; - const s = await getSoldadoBySlug(slug); + const s = await getSoldierBySlug(slug); if (!s) return { title: "Soldado", robots: { index: false } }; return { title: `${s.name} — Soldados`, @@ -38,7 +38,7 @@ export async function generateMetadata({ }; } -function avatarSrc(s: Soldado, profilePicture?: string): 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=320`; @@ -78,34 +78,34 @@ function safeNpub(pubkey?: string): string | null { } } -export default async function SoldadoProfilePage({ +export default async function SoldierProfilePage({ params, }: { params: Promise; }) { const { slug } = await params; - const soldado = await getSoldadoBySlug(slug); - if (!soldado) notFound(); + const soldier = await getSoldierBySlug(slug); + if (!soldier) notFound(); // Live Nostr profile (kind 0) for richer data when we know a pubkey. - const profile = soldado.pubkey - ? await getCachedNostrProfile(soldado.pubkey) + const profile = soldier.pubkey + ? await getCachedNostrProfile(soldier.pubkey) : null; const displayName = - profile?.display_name || profile?.name || soldado.name; + profile?.display_name || profile?.name || soldier.name; const banner = profile?.banner; - const avatar = avatarSrc(soldado, profile?.picture); - const npub = safeNpub(soldado.pubkey); + const avatar = avatarSrc(soldier, profile?.picture); + const npub = safeNpub(soldier.pubkey); const lud16 = profile?.lud16; const website = profile?.website; - const nip05 = profile?.nip05 ?? soldado.nip05; + const nip05 = profile?.nip05 ?? soldier.nip05; const about = profile?.about; const hackathons = new Set(); - for (const p of soldado.projects) if (p.hackathonId) hackathons.add(p.hackathonId); + for (const p of soldier.projects) if (p.hackathonId) hackathons.add(p.hackathonId); - const sortedProjects = [...soldado.projects].sort((a, b) => { + const sortedProjects = [...soldier.projects].sort((a, b) => { const pa = a.position ?? 999; const pb = b.position ?? 999; if (pa !== pb) return pa - pb; @@ -141,7 +141,7 @@ export default async function SoldadoProfilePage({

      {displayName} - {soldado.hasNostr && ( + {soldier.hasNostr && ( )} - {soldado.github && ( + {soldier.github && ( - {soldado.github} + {soldier.github} )} {website && ( @@ -194,23 +194,23 @@ export default async function SoldadoProfilePage({
      @@ -226,7 +226,7 @@ export default async function SoldadoProfilePage({ )} } >
        @@ -276,13 +276,13 @@ export default async function SoldadoProfilePage({

      - {soldado.pubkey && ( + {soldier.pubkey && (
      pubkey (hex)
      - {soldado.pubkey} + {soldier.pubkey}
      )} @@ -290,10 +290,10 @@ export default async function SoldadoProfilePage({ )} - {soldado.roles.length > 0 && ( + {soldier.roles.length > 0 && ( }>
      - {soldado.roles.map((r) => ( + {soldier.roles.map((r) => ( - + ); } diff --git a/lib/soldados.ts b/lib/soldados.ts deleted file mode 100644 index dd960a6..0000000 --- a/lib/soldados.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Server-only roster builder for the /soldados page. - * - * Merges curated team members from `lib/projects.ts` with community Nostr - * submissions (`lib/nostrCache.ts`). No new relay round-trip — reuses the - * cached `getAllNostrSubmissionsForSitemap()` so this page invalidates on - * the same `nostr:hackathon-submissions` tag as everything else. - */ - -import { cacheLife, cacheTag } from "next/cache"; -import { PROJECTS, type TeamMember } from "./projects"; -import { - getAllNostrSubmissionsForSitemap, - type CachedNostrTeamMember, -} from "./nostrCache"; -import reportsData from "@/data/hackathons/reports.json"; - -type ReportEntry = { - position?: number; - finalScore?: number; -}; -type ReportsByHackathon = Record>; -const REPORTS = reportsData as unknown as ReportsByHackathon; - -// Position → points (1° gives 6 down to 6° giving 1; 0 below). -const POSITION_POINTS: Record = { - 1: 6, - 2: 5, - 3: 4, - 4: 3, - 5: 2, - 6: 1, -}; -const POINTS_PER_PROJECT = 2; -const POINTS_PER_HACKATHON = 3; - -export type SoldadoProjectRef = { - hackathonId: string | null; - projectId: string; - projectName: string; - role: string; - source: "curated" | "nostr"; - position?: number; - positionPoints: number; -}; - -export type SoldadoScoreBreakdown = { - hackathons: number; - projects: number; - positions: number; - total: number; -}; - -export type Soldado = { - id: string; - slug: string; - name: string; - github?: string; - pubkey?: string; - nip05?: string; - picture?: string; - hasNostr: boolean; - roles: string[]; - projects: SoldadoProjectRef[]; - score: number; - scoreBreakdown: SoldadoScoreBreakdown; -}; - -function toSlug(id: string): string { - return id.replace(/:/g, "-").slice(0, 80); -} - -function lookupPosition( - hackathonId: string | null, - projectId: string, -): number | undefined { - if (!hackathonId) return undefined; - const hReports = REPORTS[hackathonId]; - if (!hReports) return undefined; - // Case-insensitive match against report project IDs (reports may use - // different casing, e.g. "SatsParty" vs PROJECTS' "satsparty"). - const idLc = projectId.toLowerCase(); - for (const [rid, entry] of Object.entries(hReports)) { - if (rid.toLowerCase() === idLc) return entry.position; - } - return undefined; -} - -function pointsForPosition(position: number | undefined): number { - if (!position) return 0; - return POSITION_POINTS[position] ?? 0; -} - -function computeScore( - refs: SoldadoProjectRef[], -): { score: number; breakdown: SoldadoScoreBreakdown } { - const hackathons = new Set(); - let positions = 0; - for (const r of refs) { - if (r.hackathonId) hackathons.add(r.hackathonId); - positions += r.positionPoints; - } - const projects = refs.length * POINTS_PER_PROJECT; - const hk = hackathons.size * POINTS_PER_HACKATHON; - const total = projects + hk + positions; - return { - score: total, - breakdown: { hackathons: hk, projects, positions, total }, - }; -} - -function slugify(s: string): string { - return s - .toLowerCase() - .normalize("NFD") - .replace(/[̀-ͯ]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - -function keyFor(opts: { - github?: string; - pubkey?: string; - nip05?: string; - name?: string; -}): string { - if (opts.github) return `gh:${opts.github.toLowerCase()}`; - if (opts.pubkey) return `pk:${opts.pubkey.toLowerCase()}`; - if (opts.nip05) return `nip05:${opts.nip05.toLowerCase()}`; - if (opts.name) return `name:${slugify(opts.name)}`; - return `anon:${Math.random().toString(36).slice(2)}`; -} - -function pushUnique(arr: T[], item: T) { - if (!arr.includes(item)) arr.push(item); -} - -function curatedHackathonId(p: (typeof PROJECTS)[number]): string | null { - // `hackathon` is "foundations" | "identity" | null in lib/projects.ts. - return p.hackathon ?? null; -} - -async function buildSoldados(): Promise { - "use cache"; - cacheLife("hours"); - cacheTag("nostr:hackathon-submissions"); - - const map = new Map(); - - // ── Curated pass ────────────────────────────────────────────────────── - for (const project of PROJECTS) { - for (const m of project.team as TeamMember[]) { - const githubLc = m.github?.trim().toLowerCase() || undefined; - const k = keyFor({ github: githubLc, name: m.name }); - const existing = map.get(k); - const hackathonId = curatedHackathonId(project); - const position = lookupPosition(hackathonId, project.id); - const ref: SoldadoProjectRef = { - hackathonId, - projectId: project.id, - projectName: project.name, - role: m.role, - source: "curated", - position, - positionPoints: pointsForPosition(position), - }; - if (existing) { - existing.projects.push(ref); - pushUnique(existing.roles, m.role); - if (!existing.github && githubLc) existing.github = githubLc; - } else { - map.set(k, { - id: k, - slug: toSlug(k), - name: m.name, - github: githubLc, - hasNostr: false, - roles: [m.role], - projects: [ref], - score: 0, - scoreBreakdown: { hackathons: 0, projects: 0, positions: 0, total: 0 }, - }); - } - } - } - - // ── Nostr pass ──────────────────────────────────────────────────────── - const nostrProjects = await getAllNostrSubmissionsForSitemap(); - for (const project of nostrProjects) { - for (const m of project.team as CachedNostrTeamMember[]) { - const githubLc = m.github?.trim().toLowerCase() || undefined; - const pubkeyLc = m.pubkey?.trim().toLowerCase() || undefined; - const nip05Lc = m.nip05?.trim().toLowerCase() || undefined; - - // Try to find an existing soldado with priority: github > pubkey > nip05 > name. - let resolvedKey: string | undefined; - const candidates: string[] = []; - if (githubLc) candidates.push(`gh:${githubLc}`); - if (pubkeyLc) candidates.push(`pk:${pubkeyLc}`); - if (nip05Lc) candidates.push(`nip05:${nip05Lc}`); - if (m.name) candidates.push(`name:${slugify(m.name)}`); - for (const c of candidates) { - if (map.has(c)) { - resolvedKey = c; - break; - } - } - const hackathonId = project.hackathon ?? null; - const position = lookupPosition(hackathonId, project.id); - const ref: SoldadoProjectRef = { - hackathonId, - projectId: project.id, - projectName: project.name, - role: m.role, - source: "nostr", - position, - positionPoints: pointsForPosition(position), - }; - - if (resolvedKey) { - const existing = map.get(resolvedKey)!; - existing.hasNostr = true; - if (!existing.pubkey && pubkeyLc) existing.pubkey = pubkeyLc; - if (!existing.nip05 && nip05Lc) existing.nip05 = nip05Lc; - if (!existing.picture && m.picture) existing.picture = m.picture; - if (!existing.github && githubLc) existing.github = githubLc; - existing.projects.push(ref); - pushUnique(existing.roles, m.role); - } else { - const k = keyFor({ - github: githubLc, - pubkey: pubkeyLc, - nip05: nip05Lc, - name: m.name, - }); - map.set(k, { - id: k, - slug: toSlug(k), - name: m.name || (m.nip05?.split("@")[0] ?? "Anónimo"), - github: githubLc, - pubkey: pubkeyLc, - nip05: nip05Lc, - picture: m.picture, - hasNostr: true, - roles: [m.role], - projects: [ref], - score: 0, - scoreBreakdown: { hackathons: 0, projects: 0, positions: 0, total: 0 }, - }); - } - } - } - - // ── Compute final scores ────────────────────────────────────────────── - for (const s of map.values()) { - const { score, breakdown } = computeScore(s.projects); - s.score = score; - s.scoreBreakdown = breakdown; - } - - // ── Sort: alphabetical by name (case-insensitive); Nostr-linked first on ties. - const list = [...map.values()].sort((a, b) => { - const n = a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); - if (n !== 0) return n; - return Number(b.hasNostr) - Number(a.hasNostr); - }); - - return list; -} - -export async function getSoldados(): Promise { - return buildSoldados(); -} - -export async function getSoldadoBySlug(slug: string): Promise { - const all = await getSoldados(); - const lc = slug.toLowerCase(); - return all.find((s) => s.slug.toLowerCase() === lc) ?? null; -} diff --git a/lib/soldiers.ts b/lib/soldiers.ts new file mode 100644 index 0000000..f84d64c --- /dev/null +++ b/lib/soldiers.ts @@ -0,0 +1,451 @@ +/** + * Server-only roster builder for the /soldados page. + * + * Merges curated team members from `lib/projects.ts` with community Nostr + * submissions (`lib/nostrCache.ts`). No new relay round-trip — reuses the + * cached `getAllNostrSubmissionsForSitemap()` so this page invalidates on + * the same `nostr:hackathon-submissions` tag as everything else. + */ + +import { cacheLife, cacheTag } from "next/cache"; +import { PROJECTS, type TeamMember } from "./projects"; +import { + getAllNostrSubmissionsForSitemap, + type CachedNostrTeamMember, +} from "./nostrCache"; +import reportsData from "@/data/hackathons/reports.json"; + +type ReportEntry = { + position?: number; + finalScore?: number; +}; +type ReportsByHackathon = Record>; +const REPORTS = reportsData as unknown as ReportsByHackathon; + +// Position → points (1° gives 6 down to 6° giving 1; 0 below). +const POSITION_POINTS: Record = { + 1: 6, + 2: 5, + 3: 4, + 4: 3, + 5: 2, + 6: 1, +}; +const POINTS_PER_PROJECT = 2; +const POINTS_PER_HACKATHON = 3; + +export type SoldierProjectRef = { + hackathonId: string | null; + projectId: string; + projectName: string; + role: string; + source: "curated" | "nostr"; + position?: number; + positionPoints: number; + /** + * Carried for dedupe — Nostr re-submissions sometimes use a different + * presentation name than the curated entry (e.g. "Scammer & Come + * Empanadas Detector" vs "NOSTR identity hub") but the same repo URL. + */ + repo?: string; +}; + +export type SoldierScoreBreakdown = { + hackathons: number; + projects: number; + positions: number; + total: number; +}; + +export type Soldier = { + id: string; + slug: string; + name: string; + github?: string; + pubkey?: string; + nip05?: string; + picture?: string; + hasNostr: boolean; + roles: string[]; + projects: SoldierProjectRef[]; + score: number; + scoreBreakdown: SoldierScoreBreakdown; +}; + +function toSlug(id: string): string { + return id.replace(/:/g, "-").slice(0, 80); +} + +function lookupPosition( + hackathonId: string | null, + projectId: string, +): number | undefined { + if (!hackathonId) return undefined; + const hReports = REPORTS[hackathonId]; + if (!hReports) return undefined; + // Case-insensitive match against report project IDs (reports may use + // different casing, e.g. "SatsParty" vs PROJECTS' "satsparty"). + const idLc = projectId.toLowerCase(); + for (const [rid, entry] of Object.entries(hReports)) { + if (rid.toLowerCase() === idLc) return entry.position; + } + return undefined; +} + +function pointsForPosition(position: number | undefined): number { + if (!position) return 0; + return POSITION_POINTS[position] ?? 0; +} + +function normalizeRepo(url: string | undefined): string | undefined { + if (!url) return undefined; + return url + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/^github\.com\//, "") + .replace(/\.git$/, "") + .replace(/\/+$/, ""); +} + +// Two refs describe the same project when, scoped to the same hackathon, +// they share either: +// 1. a slugified project name — covers Nostr re-submissions that just +// reformat the name ("Proof of Attendance (HDMP)" vs "Proof of +// attendance HDMP"), or +// 2. a normalized repo URL — covers re-submissions that change the +// presentation name entirely ("Scammer & Come Empanadas Detector" +// vs "NOSTR identity hub", both → Lo0ker-Noma/nostr-identity-hub). +function projectDedupeKeys(ref: SoldierProjectRef): string[] { + const h = ref.hackathonId ?? "-"; + const out: string[] = [`${h}::name:${slugify(ref.projectName)}`]; + const repo = normalizeRepo(ref.repo); + if (repo) out.push(`${h}::repo:${repo}`); + return out; +} + +function mergeRefs( + primary: SoldierProjectRef, + secondary: SoldierProjectRef, +): SoldierProjectRef { + // Prefer curated source — its projectId matches `lib/projects.ts` and + // routes correctly to /hackathons//. Inherit position + // info and repo URL from whichever ref actually has them. + const curatedFirst = + primary.source === "curated" + ? primary + : secondary.source === "curated" + ? secondary + : primary; + const other = curatedFirst === primary ? secondary : primary; + const merged: SoldierProjectRef = { ...curatedFirst }; + if (!merged.position && other.position) { + merged.position = other.position; + merged.positionPoints = other.positionPoints; + } + if (!merged.repo && other.repo) merged.repo = other.repo; + return merged; +} + +function dedupeProjects(refs: SoldierProjectRef[]): SoldierProjectRef[] { + // A ref can match an existing group by name OR by repo. After merging, + // re-register all keys of the merged ref so a later ref that matches + // either key still collapses into the same group. + const indexByKey = new Map(); + const out: SoldierProjectRef[] = []; + for (const r of refs) { + const keys = projectDedupeKeys(r); + let groupIdx: number | undefined; + for (const k of keys) { + const i = indexByKey.get(k); + if (i !== undefined) { + groupIdx = i; + break; + } + } + if (groupIdx === undefined) { + const idx = out.length; + out.push(r); + for (const k of keys) indexByKey.set(k, idx); + } else { + const merged = mergeRefs(out[groupIdx]!, r); + out[groupIdx] = merged; + for (const k of projectDedupeKeys(merged)) indexByKey.set(k, groupIdx); + } + } + return out; +} + +function computeScore( + refs: SoldierProjectRef[], +): { score: number; breakdown: SoldierScoreBreakdown } { + const hackathons = new Set(); + let positions = 0; + for (const r of refs) { + if (r.hackathonId) hackathons.add(r.hackathonId); + positions += r.positionPoints; + } + const projects = refs.length * POINTS_PER_PROJECT; + const hk = hackathons.size * POINTS_PER_HACKATHON; + const total = projects + hk + positions; + return { + score: total, + breakdown: { hackathons: hk, projects, positions, total }, + }; +} + +function slugify(s: string): string { + return s + .toLowerCase() + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function keyFor(opts: { + github?: string; + pubkey?: string; + nip05?: string; + name?: string; +}): string { + if (opts.github) return `gh:${opts.github.toLowerCase()}`; + if (opts.pubkey) return `pk:${opts.pubkey.toLowerCase()}`; + if (opts.nip05) return `nip05:${opts.nip05.toLowerCase()}`; + if (opts.name) return `name:${slugify(opts.name)}`; + return `anon:${Math.random().toString(36).slice(2)}`; +} + +function pushUnique(arr: T[], item: T) { + if (!arr.includes(item)) arr.push(item); +} + +// Register all known identity keys for `s` in `map` so later lookups by +// any of them (gh / pk / nip05) resolve to the same soldier object. Multiple +// keys may point to the same Soldier instance — the final list dedupes by +// reference. +function indexAliases(s: Soldier, map: Map) { + if (s.github) map.set(`gh:${s.github.toLowerCase()}`, s); + if (s.pubkey) map.set(`pk:${s.pubkey.toLowerCase()}`, s); + if (s.nip05) map.set(`nip05:${s.nip05.toLowerCase()}`, s); +} + +function curatedHackathonId(p: (typeof PROJECTS)[number]): string | null { + // `hackathon` is "foundations" | "identity" | null in lib/projects.ts. + return p.hackathon ?? null; +} + +async function buildSoldiers(): Promise { + "use cache"; + cacheLife("hours"); + cacheTag("nostr:hackathon-submissions"); + + const map = new Map(); + + // ── Curated pass ────────────────────────────────────────────────────── + for (const project of PROJECTS) { + for (const m of project.team as TeamMember[]) { + const githubLc = m.github?.trim().toLowerCase() || undefined; + const k = keyFor({ github: githubLc, name: m.name }); + const existing = map.get(k); + const hackathonId = curatedHackathonId(project); + const position = lookupPosition(hackathonId, project.id); + const ref: SoldierProjectRef = { + hackathonId, + projectId: project.id, + projectName: project.name, + role: m.role, + source: "curated", + position, + positionPoints: pointsForPosition(position), + repo: project.repo, + }; + if (existing) { + existing.projects.push(ref); + pushUnique(existing.roles, m.role); + if (!existing.github && githubLc) existing.github = githubLc; + indexAliases(existing, map); + } else { + const created: Soldier = { + id: k, + slug: toSlug(k), + name: m.name, + github: githubLc, + hasNostr: false, + roles: [m.role], + projects: [ref], + score: 0, + scoreBreakdown: { hackathons: 0, projects: 0, positions: 0, total: 0 }, + }; + map.set(k, created); + indexAliases(created, map); + } + } + } + + // Index curated team members by lowercased project id AND by slugified + // project name. Nostr re-submissions of an existing project may use a + // UUID for `id` (different from the curated slug) but a similar `name`, + // so the name-slug fallback is what actually matches them. When found, + // we positionally inherit the github handles from the curated team. + const curatedTeamByProjectKey = new Map(); + for (const p of PROJECTS) { + curatedTeamByProjectKey.set(p.id.toLowerCase(), p.team); + curatedTeamByProjectKey.set(slugify(p.name), p.team); + } + + // ── Nostr pass ──────────────────────────────────────────────────────── + const nostrProjects = await getAllNostrSubmissionsForSitemap(); + for (const project of nostrProjects) { + const curatedTeam = + curatedTeamByProjectKey.get(project.id.toLowerCase()) ?? + (project.name + ? curatedTeamByProjectKey.get(slugify(project.name)) + : undefined); + const sameSizeTeam = + curatedTeam && curatedTeam.length === project.team.length; + + for (let i = 0; i < project.team.length; i++) { + const m = project.team[i] as CachedNostrTeamMember; + const githubLc = m.github?.trim().toLowerCase() || undefined; + const pubkeyLc = m.pubkey?.trim().toLowerCase() || undefined; + const nip05Lc = m.nip05?.trim().toLowerCase() || undefined; + // Heuristic: many builders reuse the same handle for github and the + // local-part of their nip05 (e.g. github "Fierillo" + nip05 + // "fierillo@hodl.arx.com"). Use the nip05 local-part as a github-key + // candidate so the Nostr submission can collapse into the curated + // entry even when the team-member object didn't set `github`. + const nip05LocalLc = nip05Lc?.split("@")[0]?.replace(/[^a-z0-9-]/g, ""); + const nameSlug = m.name ? slugify(m.name) : undefined; + // Inherited github from the curated team of the same project, when + // both teams are the same size — handles the case where a Nostr + // re-submission of an existing project has different `name` / + // missing `github` for the same person (e.g. "Looker" on Nostr ≡ + // curated "Lo0ker-Noma" on `proof-of-attendance-hdmp`). + const inheritedGhLc = + sameSizeTeam && curatedTeam![i]?.github + ? curatedTeam![i].github.trim().toLowerCase() + : undefined; + + // Match priority — strongest first: + // 1. explicit github handle, 2. inherited github (same-size team on + // a re-submitted curated project), 3. pubkey, 4. nip05, 5. nip05 + // local-part interpreted as a github handle, 6. name slug + // interpreted as a github handle, 7. raw name slug. + let resolvedKey: string | undefined; + const candidates: string[] = []; + if (githubLc) candidates.push(`gh:${githubLc}`); + if (inheritedGhLc && inheritedGhLc !== githubLc) + candidates.push(`gh:${inheritedGhLc}`); + if (pubkeyLc) candidates.push(`pk:${pubkeyLc}`); + if (nip05Lc) candidates.push(`nip05:${nip05Lc}`); + if ( + nip05LocalLc && + nip05LocalLc !== githubLc && + nip05LocalLc !== inheritedGhLc + ) + candidates.push(`gh:${nip05LocalLc}`); + if ( + nameSlug && + nameSlug !== githubLc && + nameSlug !== inheritedGhLc && + nameSlug !== nip05LocalLc + ) + candidates.push(`gh:${nameSlug}`); + if (nameSlug) candidates.push(`name:${nameSlug}`); + for (const c of candidates) { + if (map.has(c)) { + resolvedKey = c; + break; + } + } + const hackathonId = project.hackathon ?? null; + const position = lookupPosition(hackathonId, project.id); + const ref: SoldierProjectRef = { + hackathonId, + projectId: project.id, + projectName: project.name, + role: m.role, + source: "nostr", + position, + positionPoints: pointsForPosition(position), + repo: project.repo, + }; + + if (resolvedKey) { + const existing = map.get(resolvedKey)!; + existing.hasNostr = true; + if (!existing.pubkey && pubkeyLc) existing.pubkey = pubkeyLc; + if (!existing.nip05 && nip05Lc) existing.nip05 = nip05Lc; + if (!existing.picture && m.picture) existing.picture = m.picture; + if (!existing.github) { + const newGh = githubLc ?? inheritedGhLc; + if (newGh) existing.github = newGh; + } + existing.projects.push(ref); + pushUnique(existing.roles, m.role); + // Re-index aliases: subsequent Nostr submissions for the same + // person should resolve to this soldier via any of their known + // identities (pubkey / nip05 / inherited github). + indexAliases(existing, map); + } else { + const k = keyFor({ + github: githubLc, + pubkey: pubkeyLc, + nip05: nip05Lc, + name: m.name, + }); + const created: Soldier = { + id: k, + slug: toSlug(k), + name: m.name || (m.nip05?.split("@")[0] ?? "Anónimo"), + github: githubLc, + pubkey: pubkeyLc, + nip05: nip05Lc, + picture: m.picture, + hasNostr: true, + roles: [m.role], + projects: [ref], + score: 0, + scoreBreakdown: { hackathons: 0, projects: 0, positions: 0, total: 0 }, + }; + map.set(k, created); + indexAliases(created, map); + } + } + } + + // Deduplicate — alias indexing means the same Soldier instance can be + // reachable from multiple keys (gh / pk / nip05). + const unique = [...new Set(map.values())]; + + // ── Dedupe & score ──────────────────────────────────────────────────── + // A soldier on a curated team whose project gets re-published on Nostr + // ends up with two refs to the same project (one per pass). Collapse + // them so projects.length / score / medals reflect reality. + for (const s of unique) { + s.projects = dedupeProjects(s.projects); + const { score, breakdown } = computeScore(s.projects); + s.score = score; + s.scoreBreakdown = breakdown; + } + + // ── Sort: alphabetical by name (case-insensitive); Nostr-linked first on ties. + const list = unique.sort((a, b) => { + const n = a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + if (n !== 0) return n; + return Number(b.hasNostr) - Number(a.hasNostr); + }); + + return list; +} + +export async function getSoldiers(): Promise { + return buildSoldiers(); +} + +export async function getSoldierBySlug(slug: string): Promise { + const all = await getSoldiers(); + const lc = slug.toLowerCase(); + return all.find((s) => s.slug.toLowerCase() === lc) ?? null; +} From 2e5beb5bc4ee87aaf7caaf2833ae0f3bcb7fabdb Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Tue, 12 May 2026 11:09:02 -0300 Subject: [PATCH 4/5] feat(soldados): tooltips, project pages, CodeRabbit fixes Session work - Per-cell tooltips on the ranking table (Hackatones, Proyectos, Medallas, Mejor): multiplication formula on top, single Total at the bottom. Score badge always bitcoin-colored, with "puntos" suffix on the badge and tooltip totals. - Curated non-hackathon projects (e.g. LaWallet) get a real single page at /projects/, dispatched from /projects/[pubkey]/page.tsx when the segment matches a curated id in lib/projects.ts. - Add SoldierProjectRef.authorPubkey so Nostr-only projects route to /projects//; preserved through dedupeProjects. ProjectRow / SoldiersGrid use a projectHref() helper covering all three cases (hackathon, Nostr, curated). CodeRabbit feedback on PR #4 - ViewButton: explicit type="button" to avoid accidental form submit. - HackathonInscripcionButton: reset pendingInscription when LoginModal is dismissed without auth, so a later auth recovery can't pop NewProjectModal out of context. - HomeBenefits Spanish copy: add missing "y" between gerunds; fix "tenga" -> "tengan" subject-verb agreement. - HomeScroll: drop unreachable techTags data on the hackathons section (the guard excluded it; no other section defined them). - lib/soldiers.ts keyFor: replace Math.random() anon fallback with a deterministic sentinel so cache revalidation can't generate new slugs and 404 external links. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/projects/[pubkey]/CuratedProjectPage.tsx | 196 ++++++++++++++++ app/projects/[pubkey]/page.tsx | 35 ++- app/soldados/SoldiersClient.tsx | 1 + app/soldados/SoldiersGrid.tsx | 4 +- app/soldados/SoldiersTable.tsx | 231 +++++++++++++++---- app/soldados/[slug]/page.tsx | 18 +- components/HackathonInscripcionButton.tsx | 8 +- components/sections/HomeBenefits.tsx | 4 +- components/sections/HomeScroll.tsx | 3 +- lib/soldiers.ts | 16 +- 10 files changed, 452 insertions(+), 64 deletions(-) create mode 100644 app/projects/[pubkey]/CuratedProjectPage.tsx diff --git a/app/projects/[pubkey]/CuratedProjectPage.tsx b/app/projects/[pubkey]/CuratedProjectPage.tsx new file mode 100644 index 0000000..9d9b2f8 --- /dev/null +++ b/app/projects/[pubkey]/CuratedProjectPage.tsx @@ -0,0 +1,196 @@ +import Link from "next/link"; +import { + ArrowLeft, + Calendar, + ExternalLink, + Users, + FlaskConical, +} from "lucide-react"; +import { GithubIcon } from "@/components/BrandIcons"; +import { cn } from "@/lib/cn"; +import type { Project } from "@/lib/projects"; + +const STATUS_BADGE: Record = { + official: "bg-bitcoin/10 border-bitcoin/40 text-bitcoin", + live: "bg-success/10 border-success/30 text-success", + winner: "bg-lightning/10 border-lightning/40 text-lightning", + finalist: "bg-cyan/10 border-cyan/40 text-cyan", + submitted: "bg-nostr/10 border-nostr/30 text-nostr", + building: "bg-white/5 border-border text-foreground-muted", + idea: "bg-white/5 border-border text-foreground-subtle", + archived: "bg-white/5 border-border text-foreground-subtle", +}; + +export default function CuratedProjectPage({ project }: { project: Project }) { + return ( +
      +
      + + + Proyectos + + +
      +
      +
      + + {project.status} + +
      + +

      + {project.name} +

      +

      + {project.description} +

      + +
      + {project.repo && ( + + + Repo + + )} + {project.demo && ( + + + Demo + + )} +
      + + {project.tech && project.tech.length > 0 && ( +
      + +
      + )} +
      + + +
      +
      +
      + ); +} + +function StackCardBody({ tech }: { tech: string[] }) { + return ( + <> +
      + +

      + Stack +

      +
      +
      + {tech.map((t) => ( + + {t} + + ))} +
      + + ); +} diff --git a/app/projects/[pubkey]/page.tsx b/app/projects/[pubkey]/page.tsx index 6a300cb..8ddefe1 100644 --- a/app/projects/[pubkey]/page.tsx +++ b/app/projects/[pubkey]/page.tsx @@ -1,10 +1,32 @@ import type { Metadata } from "next"; +import { PROJECTS } from "@/lib/projects"; +import CuratedProjectPage from "./CuratedProjectPage"; import UserProjectsPage from "./UserProjectsPage"; -export const metadata: Metadata = { - title: "Proyectos del usuario", - robots: { index: false, follow: false }, -}; +function findCurated(slug: string) { + const lc = slug.toLowerCase(); + return PROJECTS.find((p) => p.id.toLowerCase() === lc) ?? null; +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ pubkey: string }>; +}): Promise { + const { pubkey } = await params; + const curated = findCurated(pubkey); + if (curated) { + return { + title: `${curated.name} · Proyecto`, + description: curated.description, + alternates: { canonical: `/projects/${curated.id}` }, + }; + } + return { + title: "Proyectos del usuario", + robots: { index: false, follow: false }, + }; +} export default async function Page({ params, @@ -12,5 +34,10 @@ export default async function Page({ params: Promise<{ pubkey: string }>; }) { const { pubkey } = await params; + // The [pubkey] segment doubles as a curated-project id when it matches one + // in lib/projects.ts. Otherwise it's interpreted as a Nostr pubkey and the + // page lists that user's NIP-78 projects. + const curated = findCurated(pubkey); + if (curated) return ; return ; } diff --git a/app/soldados/SoldiersClient.tsx b/app/soldados/SoldiersClient.tsx index b86f4a4..56f0128 100644 --- a/app/soldados/SoldiersClient.tsx +++ b/app/soldados/SoldiersClient.tsx @@ -60,6 +60,7 @@ function ViewButton({ }) { return (
      -
      -
      Builder Hackatones ProyectosMedallas Mejor Score
      {s.projects.length} + {(() => { + const m = medalCounts(s); + if (m.total === 0) + return ( + + — + + ); + return ( + + {m.gold > 0 && ( + + 🥇 + + {m.gold} + + + )} + {m.silver > 0 && ( + + 🥈 + + {m.silver} + + + )} + {m.bronze > 0 && ( + + 🥉 + + {m.bronze} + + + )} + + ); + })()} + {bp ? ( @@ -162,21 +228,21 @@ export default function SoldadosTable({ soldados }: { soldados: Soldado[] }) { )} - - {s.score} + + + + {s.score} + +
      - {ths} + + + + {ths} + + {ths > 0 && ( + + )} + - {s.projects.length} + + + + {s.projects.length} + + {s.projects.length > 0 && ( + + )} + {(() => { @@ -177,52 +224,76 @@ export default function SoldiersTable({ soldiers }: { soldiers: Soldier[] }) { — ); + const goldPts = m.gold * POSITION_POINTS[1]!; + const silverPts = m.silver * POSITION_POINTS[2]!; + const bronzePts = m.bronze * POSITION_POINTS[3]!; + const medalsTotal = goldPts + silverPts + bronzePts; + const rows: TooltipRow[] = []; + if (m.gold > 0) + rows.push({ + left: `🥇 ${m.gold} × ${POSITION_POINTS[1]}`, + }); + if (m.silver > 0) + rows.push({ + left: `🥈 ${m.silver} × ${POSITION_POINTS[2]}`, + }); + if (m.bronze > 0) + rows.push({ + left: `🥉 ${m.bronze} × ${POSITION_POINTS[3]}`, + }); return ( - - {m.gold > 0 && ( - - 🥇 - - {m.gold} + + + {m.gold > 0 && ( + + 🥇 + + {m.gold} + - - )} - {m.silver > 0 && ( - - 🥈 - - {m.silver} + )} + {m.silver > 0 && ( + + 🥈 + + {m.silver} + - - )} - {m.bronze > 0 && ( - - 🥉 - - {m.bronze} + )} + {m.bronze > 0 && ( + + 🥉 + + {m.bronze} + - - )} + )} + + ); })()} {bp ? ( - - {medal(bp) || ( - - )} - {bp}° + + + {medal(bp) || ( + + )} + {bp}° + + ) : ( @@ -232,15 +303,14 @@ export default function SoldiersTable({ soldiers }: { soldiers: Soldier[] }) { {s.score} + + puntos + @@ -331,6 +401,61 @@ export default function SoldiersTable({ soldiers }: { soldiers: Soldier[] }) { ); } +type TooltipRow = { left: ReactNode }; + +function StatTooltip({ + hoverClass, + title, + rows, + total, +}: { + // Hover trigger classes. Passed as a literal string from the call site so + // Tailwind can detect the named-group variants at build time (e.g. + // "group-hover/hk:opacity-100 group-hover/hk:translate-y-0"). + hoverClass: string; + title?: string; + rows: TooltipRow[]; + total?: number; +}) { + return ( + + {title && ( +
      + {title} +
      + )} +
        + {rows.map((r, i) => ( +
      • {r.left}
      • + ))} +
      + {total !== undefined && ( +
      + + Total + + + +{total}{" "} + + puntos + + +
      + )} +
      + ); +} + function ScoreBreakdownTooltip({ soldier }: { soldier: Soldier }) { const { scoreBreakdown: b, projects } = soldier; const hackathonsCount = new Set( @@ -357,7 +482,8 @@ function ScoreBreakdownTooltip({ soldier }: { soldier: Soldier }) { {hackathonsCount} × 3
      - +{b.hackathons} + +{b.hackathons}{" "} + puntos
    • @@ -366,13 +492,15 @@ function ScoreBreakdownTooltip({ soldier }: { soldier: Soldier }) { {projects.length} × 2 - +{b.projects} + +{b.projects}{" "} + puntos
    • Posiciones - +{b.positions} + +{b.positions}{" "} + puntos
    • @@ -381,7 +509,10 @@ function ScoreBreakdownTooltip({ soldier }: { soldier: Soldier }) { Total - {b.total} + {b.total}{" "} + + puntos + diff --git a/app/soldados/[slug]/page.tsx b/app/soldados/[slug]/page.tsx index da983c0..67899d7 100644 --- a/app/soldados/[slug]/page.tsx +++ b/app/soldados/[slug]/page.tsx @@ -396,10 +396,22 @@ function Card({ ); } +function projectHref(project: SoldierProjectRef): string { + // 1. Hackathon-scoped project → /hackathons// + if (project.hackathonId) { + return `/hackathons/${project.hackathonId}/${project.projectId}`; + } + // 2. Nostr-only project with known author → /projects// + if (project.source === "nostr" && project.authorPubkey) { + return `/projects/${project.authorPubkey}/${project.projectId}`; + } + // 3. Curated non-hackathon project → /projects/ (dispatched + // by /projects/[pubkey]/page.tsx, which detects curated ids). + return `/projects/${project.projectId}`; +} + function ProjectRow({ project }: { project: SoldierProjectRef }) { - const href = project.hackathonId - ? `/hackathons/${project.hackathonId}/${project.projectId}` - : `/projects#${project.projectId}`; + const href = projectHref(project); return (
    • setLoginOpen(false)} + onClose={() => { + setLoginOpen(false); + // Drop the deferred inscription intent so a later auth recovery + // (signer revival, other-tab login) doesn't pop NewProjectModal + // out of nowhere. + setPendingInscription(false); + }} redirectTo={`/hackathons/${hackathonId}`} /> experiencia real

      - Aprendé construyendo participando en hackatons + Aprendé construyendo y participando en hackatons

      @@ -480,7 +480,7 @@ function BenefitJobs() {

      - Se buscan builders que tenga experiencia en Bitcoin. + Se buscan builders que tengan experiencia en Bitcoin.

      diff --git a/components/sections/HomeScroll.tsx b/components/sections/HomeScroll.tsx index 11bca7f..5bd12e6 100644 --- a/components/sections/HomeScroll.tsx +++ b/components/sections/HomeScroll.tsx @@ -85,7 +85,6 @@ const SECTIONS: Section[] = [ { icon: Sparkles, title: "Sin experiencia técnica" }, { icon: Award, title: "Ganá premios por participar" }, ], - techTags: ["Lightning", "Nostr", "NWC"], stats: [ { value: 26, label: "Proyectos concursados" }, { value: 2, label: "Hackatones" }, @@ -287,7 +286,7 @@ export default function HomeScroll() { })} )} - {s.id !== "hackathons" && s.techTags && ( + {s.techTags && ( /` when + * the project isn't tied to a hackathon. + */ + authorPubkey?: string; }; export type SoldierScoreBreakdown = { @@ -144,6 +150,8 @@ function mergeRefs( merged.positionPoints = other.positionPoints; } if (!merged.repo && other.repo) merged.repo = other.repo; + if (!merged.authorPubkey && other.authorPubkey) + merged.authorPubkey = other.authorPubkey; return merged; } @@ -213,7 +221,12 @@ function keyFor(opts: { if (opts.pubkey) return `pk:${opts.pubkey.toLowerCase()}`; if (opts.nip05) return `nip05:${opts.nip05.toLowerCase()}`; if (opts.name) return `name:${slugify(opts.name)}`; - return `anon:${Math.random().toString(36).slice(2)}`; + // Unreachable in practice — parseTeam (lib/nostrCache.ts) drops members + // with no name/nip05/pubkey, and curated TeamMember requires `name`. Keep + // a deterministic sentinel so any future regression collapses anon + // entries into a single soldier instead of generating a new slug per + // cache cycle (Math.random would 404 external links after revalidation). + return "anon:_"; } function pushUnique(arr: T[], item: T) { @@ -370,6 +383,7 @@ async function buildSoldiers(): Promise { position, positionPoints: pointsForPosition(position), repo: project.repo, + authorPubkey: project.author, }; if (resolvedKey) { From 5d86c771d7a174c99cb589e6593c1de9903d34ec Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Tue, 12 May 2026 11:24:15 -0300 Subject: [PATCH 5/5] fix(home): honor prefers-reduced-motion in NeuronPulse Skips the infinite animate() loop and renders nothing when the user prefers reduced motion. Addresses CodeRabbit #8 on PR #4. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/sections/HomeScroll.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/components/sections/HomeScroll.tsx b/components/sections/HomeScroll.tsx index 5bd12e6..a8e981d 100644 --- a/components/sections/HomeScroll.tsx +++ b/components/sections/HomeScroll.tsx @@ -1347,11 +1347,13 @@ function NeuronPulse({ delay: number; reverse: boolean; }) { + const reduced = useReducedMotion(); const t = useMotionValue(0); const a = reverse ? to : from; const b = reverse ? from : to; useEffect(() => { + if (reduced) return; const controls = animate(t, 1, { duration: 1.8, delay, @@ -1360,12 +1362,17 @@ function NeuronPulse({ repeatDelay: 4 + (delay % 3), }); return () => controls.stop(); - }, [t, delay]); + }, [t, delay, reduced]); const cx = useTransform(t, (v) => `${a.x + (b.x - a.x) * v}%`); const cy = useTransform(t, (v) => `${a.y + (b.y - a.y) * v}%`); const opacity = useTransform(t, [0, 0.1, 0.85, 1], [0, 1, 1, 0]); + // Reduced motion: render the pulse at rest on its midpoint, faded + // out, so the SVG composition stays balanced without continuous + // motion. + if (reduced) return null; + return (
    +