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.
+
+
+
+
unstable_retry()}
+ className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-bitcoin text-black font-semibold text-sm hover:bg-bitcoin/90 transition shadow-lg shadow-bitcoin/20"
+ >
+
+ Reintentar
+
+
+
+ Volver al inicio
+
+
+
+ {error.digest && (
+
+ $ ref:{" "}
+ {error.digest}
+
+ )}
+
+
+ );
+}
diff --git a/app/favicon.ico b/app/favicon.ico
deleted file mode 100644
index 718d6fe..0000000
Binary files a/app/favicon.ico and /dev/null differ
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 (
+
+
+
@@ -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 */}
+
+
+
+
+ +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.
+
+
+
+
+
+
+ );
+}
+
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/app/icon.png b/app/icon.png
new file mode 100644
index 0000000..57e0f14
Binary files /dev/null and b/app/icon.png differ
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/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.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/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/SoldiersClient.tsx b/app/soldados/SoldiersClient.tsx
new file mode 100644
index 0000000..56f0128
--- /dev/null
+++ b/app/soldados/SoldiersClient.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useState } from "react";
+import { LayoutGrid, List } from "lucide-react";
+import { cn } from "@/lib/cn";
+import type { Soldier } from "@/lib/soldiers";
+import SoldiersGrid from "./SoldiersGrid";
+import SoldiersTable from "./SoldiersTable";
+
+type View = "grid" | "table";
+
+export default function SoldiersClient({ soldiers }: { soldiers: Soldier[] }) {
+ const [view, setView] = useState("table");
+
+ return (
+
+
+
+
+ {soldiers.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 (
+
+ {icon}
+ {label}
+
+ );
+}
diff --git a/app/soldados/SoldiersGrid.tsx b/app/soldados/SoldiersGrid.tsx
new file mode 100644
index 0000000..df2f7cc
--- /dev/null
+++ b/app/soldados/SoldiersGrid.tsx
@@ -0,0 +1,191 @@
+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 { Soldier } from "@/lib/soldiers";
+
+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;
+}
+
+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: Soldier): 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 SoldiersGrid({ soldiers }: { soldiers: Soldier[] }) {
+ if (soldiers.length === 0) {
+ return (
+
+ Todavía no hay soldiers.
+
+ );
+ }
+
+ return (
+
+ {soldiers.map((s) => (
+
+ ))}
+
+ );
+}
+
+function SoldierCard({ soldier }: { soldier: Soldier }) {
+ const src = avatarSrc(soldier);
+ const hackathons = uniqHackathons(soldier);
+
+ return (
+
+ {soldier.hasNostr && (
+
+ )}
+
+
+
+ {src ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ {initials(soldier.name)}
+
+ )}
+
+
+
+
+ {soldier.name}
+
+ {soldier.hasNostr && (
+
+
+
+ )}
+
+ {soldier.github && (
+
+
+ {soldier.github}
+
+ )}
+
+
+
+
+ {soldier.projects.length}{" "}
+ {soldier.projects.length === 1 ? "proyecto" : "proyectos"}
+ {hackathons.length > 0 && (
+ <>
+ {" · "}
+ {hackathons.length}{" "}
+ {hackathons.length === 1 ? "hackatón" : "hackatones"}
+ >
+ )}
+
+
+ {hackathons.length > 0 && (
+
+ {hackathons.map((h) => (
+
+
+ {hackathonLabel(h)}
+
+ ))}
+
+ )}
+
+
+ {soldier.projects.slice(0, 4).map((p, i) => {
+ const href = p.hackathonId
+ ? `/hackathons/${p.hackathonId}/${p.projectId}`
+ : p.source === "nostr" && p.authorPubkey
+ ? `/projects/${p.authorPubkey}/${p.projectId}`
+ : `/projects/${p.projectId}`;
+ return (
+
+
+
+ {p.projectName}
+
+ · {p.role}
+
+
+
+ );
+ })}
+ {soldier.projects.length > 4 && (
+
+
+ +{soldier.projects.length - 4} más
+
+
+ )}
+
+
+ );
+}
diff --git a/app/soldados/SoldiersTable.tsx b/app/soldados/SoldiersTable.tsx
new file mode 100644
index 0000000..f97833d
--- /dev/null
+++ b/app/soldados/SoldiersTable.tsx
@@ -0,0 +1,520 @@
+import type { ReactNode } from "react";
+import Link from "next/link";
+import { Trophy, Zap } from "lucide-react";
+import { GithubIcon } from "@/components/BrandIcons";
+import { cn } from "@/lib/cn";
+import type { Soldier } from "@/lib/soldiers";
+
+// Mirrors the scoring constants in lib/soldiers.ts. Kept local to render
+// the per-cell breakdown without re-exporting internals from that module.
+const POINTS_PER_HACKATHON = 3;
+const POINTS_PER_PROJECT = 2;
+const POSITION_POINTS: Record = {
+ 1: 6,
+ 2: 5,
+ 3: 4,
+ 4: 3,
+ 5: 2,
+ 6: 1,
+};
+
+function positionPoints(p: number | null | undefined): number {
+ if (p == null) return 0;
+ return POSITION_POINTS[p] ?? 0;
+}
+
+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;
+}
+
+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: 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: Soldier): 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 "";
+}
+
+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 = [...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 */}
+
+
+
+ #
+ Builder
+ Hackatones
+ Proyectos
+ Medallas
+ Mejor
+ Score
+
+
+
+ {ranked.map((s, i) => {
+ const rank = i + 1;
+ const src = avatarSrc(s);
+ const bp = bestPosition(s);
+ const ths = uniqHackathonsCount(s);
+ return (
+
+
+ 3 && "bg-white/[0.04] text-foreground-muted",
+ )}
+ >
+ {rank}
+
+
+
+
+
+ {src ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ {initials(s.name)}
+
+ )}
+
+
+
+
+ {s.name}
+
+ {s.hasNostr && (
+
+
+
+ )}
+
+ {s.github && (
+
+
+ {s.github}
+
+ )}
+
+
+
+
+
+
+ {ths}
+
+ {ths > 0 && (
+
+ )}
+
+
+
+
+
+ {s.projects.length}
+
+ {s.projects.length > 0 && (
+
+ )}
+
+
+
+ {(() => {
+ const m = medalCounts(s);
+ if (m.total === 0)
+ return (
+
+ —
+
+ );
+ 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.silver > 0 && (
+
+ 🥈
+
+ {m.silver}
+
+
+ )}
+ {m.bronze > 0 && (
+
+ 🥉
+
+ {m.bronze}
+
+
+ )}
+
+
+
+ );
+ })()}
+
+
+ {bp ? (
+
+
+ {medal(bp) || (
+
+ )}
+ {bp}°
+
+
+
+ ) : (
+ —
+ )}
+
+
+
+
+ {s.score}
+
+ puntos
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ {/* 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
+
+ ) : (
+
+ {initials(s.name)}
+
+ )}
+
+
+
+ {s.name}
+
+ {s.hasNostr && (
+
+
+
+ )}
+
+
+ {s.projects.length}p
+ ·
+ {uniqHackathonsCount(s)}h
+ {bp && (
+ <>
+ ·
+ {medal(bp) || `${bp}°`}
+ >
+ )}
+
+
+
+ {s.score}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+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(
+ projects.filter((p) => p.hackathonId).map((p) => p.hackathonId),
+ ).size;
+ return (
+
+
+ Composición del score
+
+
+
+
+ Hackatones · {" "}
+ {hackathonsCount} × 3
+
+
+ +{b.hackathons}{" "}
+ puntos
+
+
+
+
+ Proyectos · {" "}
+ {projects.length} × 2
+
+
+ +{b.projects}{" "}
+ puntos
+
+
+
+ Posiciones
+
+ +{b.positions}{" "}
+ puntos
+
+
+
+
+
+ Total
+
+
+ {b.total}{" "}
+
+ puntos
+
+
+
+
+ );
+}
diff --git a/app/soldados/[slug]/page.tsx b/app/soldados/[slug]/page.tsx
new file mode 100644
index 0000000..67899d7
--- /dev/null
+++ b/app/soldados/[slug]/page.tsx
@@ -0,0 +1,456 @@
+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 {
+ getSoldierBySlug,
+ getSoldiers,
+ type Soldier,
+ type SoldierProjectRef,
+} from "@/lib/soldiers";
+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 getSoldierBySlug(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: 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`;
+ 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 SoldierProfilePage({
+ params,
+}: {
+ params: Promise;
+}) {
+ const { slug } = await params;
+ const soldier = await getSoldierBySlug(slug);
+ if (!soldier) notFound();
+
+ // Live Nostr profile (kind 0) for richer data when we know a pubkey.
+ const profile = soldier.pubkey
+ ? await getCachedNostrProfile(soldier.pubkey)
+ : null;
+
+ const displayName =
+ profile?.display_name || profile?.name || soldier.name;
+ const banner = profile?.banner;
+ const avatar = avatarSrc(soldier, profile?.picture);
+ const npub = safeNpub(soldier.pubkey);
+ const lud16 = profile?.lud16;
+ const website = profile?.website;
+ const nip05 = profile?.nip05 ?? soldier.nip05;
+ const about = profile?.about;
+
+ const hackathons = new Set();
+ for (const p of soldier.projects) if (p.hackathonId) hackathons.add(p.hackathonId);
+
+ const sortedProjects = [...soldier.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
+
+ ) : (
+
+ {initials(displayName)}
+
+ )}
+
+
+ {displayName}
+ {soldier.hasNostr && (
+
+
+ Nostr
+
+ )}
+
+
+
+
+
+ {/* Score banner */}
+
+
+
+
+
+
+
+
+
+ {about && (
+
}>
+
+ {about}
+
+
+ )}
+
+
}
+ >
+
+ {sortedProjects.map((p, i) => (
+
+ ))}
+
+
+
+
+
+ {hackathons.size > 0 && (
+
}
+ >
+
+ {[...hackathons].map((h) => (
+
+
+
+ {hackathonLabel(h)}
+
+
+ ))}
+
+
+ )}
+
+ {npub && (
+
}>
+
+
+ {soldier.pubkey && (
+
+
+ pubkey (hex)
+
+
+ {soldier.pubkey}
+
+
+ )}
+
+
+ )}
+
+ {soldier.roles.length > 0 && (
+
}>
+
+ {soldier.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 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 = projectHref(project);
+ 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..55abfc7
--- /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 { getSoldiers } from "@/lib/soldiers";
+import SoldiersClient from "./SoldiersClient";
+
+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 SoldiersPage() {
+ const soldiers = await getSoldiers();
+ 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/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..65e3b47 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,27 +14,44 @@ 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 (
<>
-
- Inscripción
+
+ Inscribite gratis
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}`}
/>
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..b6b7753 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 y 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 tengan 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..a8e981d 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,13 @@ 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" },
+ ],
+ stats: [
+ { value: 26, label: "Proyectos concursados" },
+ { value: 2, label: "Hackatones" },
],
},
{
@@ -91,8 +95,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 +103,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 +120,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 +209,21 @@ export default function HomeScroll() {
return (
+ {s.id === "hackathons" && }
+ {s.id === "projects" && (
+
+ {s.cta}
+
+
+ )}
@@ -238,38 +238,32 @@ export default function HomeScroll() {
>
-
+
{s.label}
-
- {s.description}
-
+ {s.description && (
+
+ {s.description}
+
+ )}
+ {s.id === "projects" && (
+
+ )}
{s.pillars ? (
-
- {s.pillars.map((p) => (
-
-
-
- {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.techTags && (
+
+ )}
+ {s.stats && (
+
+ )}
+ {s.id === "projects" && (
+ p.name)}
+ className="mb-8"
+ />
+ )}
+ {s.id !== "projects" && (
+
+ {s.cta}
+
+ )}
);
})}
@@ -371,8 +392,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 +441,18 @@ function SectionPanel({
{/* Description */}
-
- {section.description}
-
+ {section.description && (
+
+ {section.description}
+
+ )}
+
+ {section.id === "projects" && (
+
+ )}
{section.pillars ? (
-
- {section.pillars.map((p) => (
-
-
-
- {p.label}
-
-
- ))}
-
+
) : (
{section.features?.map((f, fi) => {
@@ -456,9 +479,11 @@ function SectionPanel({
{f.title}
-
- {f.description}
-
+ {f.description && (
+
+ {f.description}
+
+ )}
);
@@ -466,22 +491,40 @@ function SectionPanel({
)}
- {/* CTA */}
-