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 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 ( + + + Falla crítica · La Crypta Dev + + +
+
+
+
+
+ +
+ + +
+ + FALLA CRÍTICA +
+ +

+ Se cayó todo +

+ +

+ Hubo una falla profunda en el sistema. Estamos al tanto. Probá + recargar en un momento. +

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

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

+ )} +
+
+ + + ); +} 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/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.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/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 ( + + ); +} 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 + {soldier.name} + ) : ( + + {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 */} + + + + + + + + + + + + + + {ranked.map((s, i) => { + const rank = i + 1; + const src = avatarSrc(s); + const bp = bestPosition(s); + const ths = uniqHackathonsCount(s); + return ( + + + + + + + + + + ); + })} + +
    #BuilderHackatonesProyectosMedallasMejorScore
    + 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} + + {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 + {s.name} + ) : ( + + {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 + {displayName} + ) : ( + + {initials(displayName)} + + )} +
    +

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

    +
    + {nip05 && ( + + + {nip05} + + )} + {soldier.github && ( + + + {soldier.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} + + +
    + {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 ( <> 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} - - {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} - - {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 */} - - - {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 +643,824 @@ 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 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, + ease: "easeInOut", + repeat: Infinity, + repeatDelay: 4 + (delay % 3), + }); + return () => controls.stop(); + }, [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 ( + + ); +} + +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. import("lottie-react"), { + ssr: false, + loading: () => ( +