diff --git a/README.md b/README.md
index a1c66eb..4389893 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,13 @@ bun dev
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+The frontend consumes the Python Colombia API from `NEXT_PUBLIC_API_BASE_URL`.
+If the variable is not defined, it defaults to `http://localhost:8000`.
+
+```bash
+NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 npm run dev
+```
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
diff --git a/SPEC.md b/SPEC.md
deleted file mode 100644
index 6dc7581..0000000
--- a/SPEC.md
+++ /dev/null
@@ -1,492 +0,0 @@
-# Arquitectura del sitio web de Python Colombia
-
-## Barra superior (Header)
-
-```
------------------------------------------------------------------------------------------
-Logo | Comunidades | Eventos | Noticias | Blog | Podcasts | Tienda | Iniciar sesión
------------------------------------------------------------------------------------------
-```
-
-* **Logo**
-
- * Redirige siempre al Inicio.
-
-* **Tienda**
-
- * Botón destacado (Call To Action).
- * Puede tener un icono de carrito.
-
-* **Iniciar sesión**
-
- * Si el usuario está autenticado:
-
- * Mi perfil
- * Mis publicaciones
- * Panel Administrativo (según permisos)
- * Cerrar sesión
-
----
-
-# Inicio (Home)
-
-La página principal debe responder inmediatamente:
-
-> ¿Qué es Python Colombia y qué está pasando actualmente?
-
-## 1. Hero
-
-Un componente principal con:
-
-* Imagen o ilustración
-* Slogan
-
-Ejemplo
-
-> La comunidad oficial de Python en Colombia.
->
-> Aprende, comparte y construye con miles de desarrolladores.
-
-Botones:
-
-* Únete a la comunidad
-* Próximos eventos
-
----
-
-## 2. Próximos eventos
-
-Mostrar los próximos eventos.
-
-Información:
-
-* Ciudad
-* Fecha
-* Lugar
-* Cupos
-* Botón "Ver evento"
-
----
-
-## 3. Comunidades
-
-Mapa de Colombia o tarjetas por ciudad.
-
-Ejemplo
-
-```
-Bogotá
-450 miembros
-
-Medellín
-280 miembros
-
-Cali
-180 miembros
-
-Barranquilla
-95 miembros
-```
-
-Cada comunidad tendrá:
-
-* Descripción
-* Organizadores
-* Próximos eventos
-* Miembros
-* Redes sociales
-
----
-
-## 4. Historia
-
-Una sección sobre:
-
-* Nacimiento de Python Colombia
-* Objetivos
-* Misión
-* Organización
-* Cómo participar
-
----
-
-## 5. Newsletter
-
-Formulario simple
-
-```
-Nombre
-
-Correo
-
-[Suscribirme]
-```
-
-Explicar:
-
-* Noticias
-* Eventos
-* Convocatorias
-* Podcast
-* Blogs destacados
-
----
-
-## 6. Últimas noticias
-
-Mostrar las últimas publicaciones.
-
-Cada tarjeta:
-
-* Imagen
-* Categoría
-* Fecha
-* Autor
-* Resumen
-* Leer más
-
----
-
-## 7. Blogs destacados
-
-Últimos artículos escritos por la comunidad.
-
-Mostrar:
-
-* Autor
-* Tiempo de lectura
-* Etiquetas
-* Likes
-* Comentarios
-
----
-
-## 8. Últimos podcasts
-
-Tarjetas con:
-
-* Invitado
-* Duración
-* Plataformas (Spotify, YouTube, etc.)
-* Escuchar
-
----
-
-## 9. Patrocinadores
-
-Empresas que apoyan la comunidad.
-
-Ejemplo:
-
-* Microsoft
-* JetBrains
-* PyCharm
-* AWS
-* Google Cloud
-
----
-
-## 10. Footer
-
-* Acerca de
-* Código de conducta
-* Contacto
-* GitHub
-* Discord
-* LinkedIn
-* YouTube
-* Política de privacidad
-
----
-
-# Comunidades
-
-Listado de comunidades por ciudad.
-
-Cada comunidad tendrá:
-
-* Banner
-* Descripción
-* Organizadores
-* Número de miembros
-* Próximos eventos
-* Historial de eventos
-* Fotos
-* Patrocinadores locales
-
----
-
-# Eventos
-
-Filtros:
-
-* Ciudad
-* Fecha
-* Tipo
-* Virtual
-* Presencial
-
-Cada evento tendrá:
-
-* Agenda
-* Speakers
-* Patrocinadores
-* Registro
-* Materiales
-* Fotografías
-* Grabaciones
-
-Separar:
-
-* Próximos eventos
-* Eventos pasados
-
----
-
-# Noticias
-
-Administradas por la célula de Newsletter.
-
-Cada noticia incluye:
-
-* Autor
-* Fecha
-* Categoría
-* Imagen
-* Contenido
-* Comentarios (opcional)
-
----
-
-# Blog
-
-Contenido generado por la comunidad.
-
-Si el usuario inicia sesión:
-
-```
-[ Publicar artículo ]
-```
-
-Flujo
-
-```
-Borrador
- ↓
-En revisión
- ↓
-Aprobado
- ↓
-Publicado
-```
-
-Características:
-
-* Markdown
-* Resaltado de código
-* Imágenes
-* Etiquetas
-* Series de artículos
-* Perfil del autor
-
----
-
-# Podcasts
-
-Administrados por la célula de Podcast.
-
-Cada episodio:
-
-* Invitados
-* Resumen
-* Recursos
-* Plataformas
-* Video
-* Audio
-
----
-
-# Tienda
-
-Productos oficiales.
-
-Categorías
-
-* Camisetas
-* Hoodies
-* Gorras
-* Mugs
-* Stickers
-* Botellas
-* Cuadernos
-* Mouse pads
-
-Cada producto:
-
-* Variantes
-* Tallas
-* Inventario
-* Precio
-* Galería
-
----
-
-# Autenticación
-
-Registro mediante:
-
-* GitHub
-* Google
-* Microsoft
-* Correo electrónico
-
-Al crear la cuenta:
-
-```
-Pendiente de aprobación
-```
-
-Un moderador aprueba el acceso.
-
-Roles posibles:
-
-* Usuario
-* Autor
-* Moderador
-* Administrador
-* Super Administrador
-
----
-
-# Panel Administrativo
-
-## Dashboard
-
-* Usuarios registrados
-* Suscriptores al newsletter
-* Próximos eventos
-* Publicaciones pendientes
-* Estadísticas del sitio
-
----
-
-## Newsletter
-
-* CRUD de suscriptores
-* Importar/Exportar
-* Segmentación
-* Historial de campañas
-
----
-
-## Comunidades
-
-CRUD de:
-
-* Ciudades
-* Organizadores
-* Redes sociales
-* Información
-* Logos
-
----
-
-## Eventos
-
-CRUD completo.
-
-Además:
-
-* Registro de asistentes
-* Speakers
-* Agenda
-* Patrocinadores
-* Fotografías
-
----
-
-## Noticias
-
-CRUD completo.
-
-Incluye:
-
-* Programar publicación
-* Categorías
-* Destacadas
-
----
-
-## Blogs
-
-Flujo editorial completo.
-
-Estados:
-
-* Borrador
-* En revisión
-* Requiere cambios
-* Aprobado
-* Publicado
-* Archivado
-
----
-
-## Podcasts
-
-CRUD de episodios.
-
-Incluye:
-
-* Plataformas
-* Invitados
-* Recursos
-* Transcripción
-
----
-
-## Tienda
-
-* Productos
-* Categorías
-* Inventario
-* Pedidos
-* Descuentos
-* Cupones
-
----
-
-## Usuarios
-
-Gestión de:
-
-* Usuarios
-* Roles
-* Permisos
-* Solicitudes de acceso
-* Suspensiones
-
----
-
-## Configuración
-
-* Redes sociales
-* Logos
-* Banners
-* SEO
-* Analytics
-* Integraciones (YouTube, Spotify, GitHub, Mailchimp/Brevo, etc.)
-
-## Funcionalidades adicionales recomendadas
-
-Para convertir el sitio en el punto central de la comunidad, incluiría además:
-
-* **Calendario global** con todos los eventos de Python Colombia.
-* **Directorio de speakers**, donde los miembros puedan ofrecer charlas y talleres.
-* **Directorio de empresas patrocinadoras** y organizaciones aliadas.
-* **Página de voluntariado**, para unirse a células de trabajo (Newsletter, Podcast, Eventos, Diseño, Web, etc.).
-* **Sistema de perfiles públicos**, donde cada miembro pueda mostrar su biografía, ciudad, redes sociales y publicaciones.
-* **Página de recursos**, con enlaces a documentación, tutoriales, grabaciones de eventos y material de aprendizaje.
-* **Galería multimedia** con fotografías y videos de eventos anteriores.
-* **SEO optimizado**, con URLs amigables (`/comunidades/bogota`, `/eventos/pyday-medellin-2026`, `/blog/introduccion-a-fastapi`) y metadatos para compartir en redes sociales.
-* **Buscador global**, capaz de encontrar comunidades, eventos, noticias, blogs, podcasts y miembros desde una única barra de búsqueda.
diff --git a/app/globals.css b/app/globals.css
index 3ed8004..0b7a38d 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,4 +1,6 @@
@import "tailwindcss";
+/* Antes de los overrides dark de la sección Leaflet para ganar en el cascade */
+@import "leaflet/dist/leaflet.css";
:root {
--background: #0d0d1a;
@@ -8,104 +10,176 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
+ --font-sans: var(--font-titillium);
--font-mono: var(--font-geist-mono);
}
+/* ── Paleta oficial (extraída de public/images/logo.png) ──
+ Tailwind v4 no usa tailwind.config.ts: los tokens se declaran aquí
+ y generan clases como bg-python-blue o text-accent-orange. */
+@theme {
+ /* Marca */
+ --color-python-blue: #306998;
+ --color-python-blue-light: #4b8bbe;
+ --color-python-yellow: #ffd43b;
+ --color-python-yellow-light: #ffe873;
+
+ /* Acentos del mosaico del logo */
+ --color-accent-red: #e24329;
+ --color-accent-orange: #f58220;
+ --color-accent-green: #2fb350;
+ --color-accent-purple: #5c4ee5;
+
+ /* Neutros (modo oscuro) */
+ --color-night: #0d0d1a;
+ --color-night-soft: #12122a;
+ --color-night-card: #16162e;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ html {
+ scroll-behavior: smooth;
+ }
+}
+
body {
background: var(--background);
color: var(--foreground);
- font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
+ font-family: var(--font-titillium), "Segoe UI", Arial, sans-serif;
}
-/* ── Blob decorativo ──────────────────────────────────────── */
-.blob {
- position: absolute;
- border-radius: 50%;
- filter: blur(100px);
- opacity: 0.13;
- pointer-events: none;
- will-change: transform;
+::selection {
+ background: var(--color-python-yellow);
+ color: var(--color-night);
+}
+
+/* ── Utilidades editoriales de marca ──────────────────── */
+
+/* Esquina inferior derecha cortada: contraste angular frente al mapa orgánico */
+@utility cut-corner {
+ clip-path: polygon(
+ 0 0,
+ 100% 0,
+ 100% calc(100% - 18px),
+ calc(100% - 18px) 100%,
+ 0 100%
+ );
+}
+
+/* Corte diagonal inferior para portadas de eventos */
+@utility clip-diagonal {
+ clip-path: polygon(0 0, 100% 0, 100% calc(100% - 20px), 0 100%);
+}
+
+/* Texto contorneado para marcas de agua */
+@utility text-stroke {
+ -webkit-text-stroke: 1px rgb(255 255 255 / 0.1);
+ color: transparent;
}
-.blob-blue {
- width: 520px;
- height: 520px;
- background: #4584b6;
- top: -160px;
- left: -160px;
- animation: blob-float 11s ease-in-out infinite;
+/* ── Animación de entrada al hacer scroll (componente Reveal) ── */
+.reveal {
+ opacity: 0;
+ transform: translateY(24px);
+ transition:
+ opacity 0.7s ease,
+ transform 0.7s ease;
}
-.blob-yellow {
- width: 420px;
- height: 420px;
- background: #ffd43b;
- top: -120px;
- right: -120px;
- animation: blob-float-reverse 13s ease-in-out infinite;
+.reveal.is-visible {
+ opacity: 1;
+ transform: none;
}
-.blob-red {
- width: 360px;
- height: 360px;
- background: #e63329;
- bottom: -120px;
- left: 8%;
- animation: blob-float 15s ease-in-out infinite 2s;
+@media (prefers-reduced-motion: reduce) {
+ .reveal {
+ opacity: 1;
+ transform: none;
+ transition: none;
+ }
}
-.blob-green {
- width: 300px;
- height: 300px;
- background: #4caf50;
- bottom: -80px;
- right: 6%;
- animation: blob-float-reverse 12s ease-in-out infinite 1s;
+/* ── Leaflet (mapa del hero) ────────────────────────── */
+.leaflet-container {
+ background: var(--color-night);
+ font-family: inherit;
}
-/* ── Animaciones de blob ──────────────────────────────────── */
-@keyframes blob-float {
- 0%, 100% { transform: translateY(0px) scale(1); }
- 50% { transform: translateY(-28px) scale(1.06); }
+/* Marcador cuadrado del mosaico con anillo pulsante */
+.map-marker {
+ background: transparent;
+ border: 0;
+}
+
+.map-marker__dot {
+ position: absolute;
+ inset: 0;
+ border: 2px solid var(--color-night);
+}
+
+.map-marker__ring {
+ position: absolute;
+ inset: 0;
+ opacity: 0.5;
+ animation: marker-pulse 2s ease-out infinite;
+}
+
+@keyframes marker-pulse {
+ 0% {
+ transform: scale(1);
+ opacity: 0.5;
+ }
+ 100% {
+ transform: scale(2.4);
+ opacity: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .map-marker__ring {
+ animation: none;
+ opacity: 0;
+ }
+}
+
+/* Popup acorde al estilo editorial (anguloso, sombra dura) */
+.leaflet-popup-content-wrapper {
+ background: var(--color-night);
+ color: var(--foreground);
+ border: 1px solid rgb(255 255 255 / 0.2);
+ border-radius: 0;
+ box-shadow: 8px 8px 0 0 rgb(0 0 0 / 0.45);
}
-@keyframes blob-float-reverse {
- 0%, 100% { transform: translateY(0px) scale(1); }
- 50% { transform: translateY(28px) scale(0.94); }
+.leaflet-popup-content {
+ margin: 16px;
}
-/* ── Gradiente animado en el texto ───────────────────────── */
-.gradient-text {
- background: linear-gradient(135deg, #ffd43b 0%, #4584b6 40%, #ffd43b 80%);
- background-size: 200% 200%;
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
- animation: gradient-shift 5s ease infinite;
+.leaflet-popup-content p {
+ margin: 0;
}
-@keyframes gradient-shift {
- 0%, 100% { background-position: 0% 50%; }
- 50% { background-position: 100% 50%; }
+.leaflet-popup-tip {
+ background: var(--color-night);
+ border: 1px solid rgb(255 255 255 / 0.2);
+ box-shadow: none;
}
-/* ── Glow suave en el logo ───────────────────────────────── */
-.logo-glow {
- filter: drop-shadow(0 0 32px rgba(69, 132, 182, 0.25));
- transition: filter 0.4s ease;
+.leaflet-container a.leaflet-popup-close-button {
+ color: #a1a1aa;
}
-.logo-glow:hover {
- filter: drop-shadow(0 0 48px rgba(255, 212, 59, 0.3));
+.leaflet-container a.leaflet-popup-close-button:hover {
+ color: #ffffff;
}
-/* ── Badge «Próximamente» ────────────────────────────────── */
-@keyframes badge-ring {
- 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 212, 59, 0.45); }
- 60% { box-shadow: 0 0 0 10px rgba(255, 212, 59, 0); }
+/* Atribución OSM/CARTO discreta sobre fondo oscuro */
+.leaflet-control-attribution {
+ background: rgb(13 13 26 / 0.85) !important;
+ color: #71717a;
+ font-size: 10px;
}
-.badge-glow {
- animation: badge-ring 2.8s ease-out infinite;
+.leaflet-control-attribution a {
+ color: #a1a1aa;
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 01cba3e..40996e8 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,11 +1,13 @@
-import { Geist, Geist_Mono } from "next/font/google";
+import { Geist_Mono, Titillium_Web } from "next/font/google";
import ConsoleInit from "./ConsoleInit";
import "./globals.css";
export { metadata, viewport } from "@/lib/seo";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+// 300 detalles/lead · 400 cuerpo · 600 labels · 700 botones · 900 titulares
+const titillium = Titillium_Web({
+ variable: "--font-titillium",
subsets: ["latin"],
+ weight: ["300", "400", "600", "700", "900"],
});
const geistMono = Geist_Mono({
@@ -21,7 +23,7 @@ export default function RootLayout({
return (
diff --git a/app/page.tsx b/app/page.tsx
index c681a6e..f321abd 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,89 +1,23 @@
-import Image from "next/image";
-import { asset } from "@/lib/assets";
+import Navbar from "@/components/layout/Navbar";
+import Footer from "@/components/layout/Footer";
+import Hero from "@/components/sections/Hero";
+import Communities from "@/components/sections/Communities";
+import Events from "@/components/sections/Events";
+import Sponsors from "@/components/sections/Sponsors";
+import Newsletter from "@/components/sections/Newsletter";
export default function Home() {
return (
-
-
- {/* ── Blobs de color decorativos ────────────────────────── */}
-
-
-
-
-
- {/* ── Grilla sutil de fondo ─────────────────────────────── */}
-
-
- {/* ── Contenido principal ───────────────────────────────── */}
-
-
- {/* Logo */}
-
-
-
-
- {/* Badge «Próximamente» */}
-
-
- Próximamente
-
-
- {/* Encabezado principal */}
-
- El hogar digital de la comunidad{" "}
- Python Colombia {" "}
- se está renovando.
-
-
- {/* Subtexto */}
-
- Estamos construyendo algo increíble para ti y toda la comunidad.
- Vuelve pronto y sé parte de este gran paso.
-
-
- {/* Snippet decorativo tipo código */}
-
- import {" "}
- python_colombia
-
-
- community {" "}
- = {" "}
- python_colombia
- .
- Community
- ()
-
- # coming soon... 🐍
-
-
+ <>
+
+
+
+
+
+
+
-
- {/* ── Footer ────────────────────────────────────────────── */}
-
-
-
+
+ >
);
}
diff --git a/components/layout/Footer.tsx b/components/layout/Footer.tsx
new file mode 100644
index 0000000..0cd28f4
--- /dev/null
+++ b/components/layout/Footer.tsx
@@ -0,0 +1,120 @@
+import Image from "next/image";
+import type { IconType } from "react-icons";
+import {
+ FaDiscord,
+ FaEnvelope,
+ FaFacebookF,
+ FaGithub,
+ FaInstagram,
+ FaMedium,
+ FaSlack,
+ FaTelegram,
+ FaXTwitter,
+} from "react-icons/fa6";
+import { asset } from "@/lib/assets";
+import { MosaicStrip } from "@/components/ui/MosaicBar";
+import { CODE_OF_CONDUCT_URL, CONTACT_EMAIL, NAV_LINKS, SOCIAL_LINKS } from "@/lib/site";
+
+/** Icono y color de hover por red (acentos del mosaico del logo). */
+const SOCIAL_ICONS: Record = {
+ email: { icon: FaEnvelope, hoverClass: "hover:text-python-yellow hover:border-python-yellow/50" },
+ x: { icon: FaXTwitter, hoverClass: "hover:text-white hover:border-white/50" },
+ facebook: { icon: FaFacebookF, hoverClass: "hover:text-python-blue-light hover:border-python-blue-light/50" },
+ instagram: { icon: FaInstagram, hoverClass: "hover:text-accent-red hover:border-accent-red/50" },
+ github: { icon: FaGithub, hoverClass: "hover:text-white hover:border-white/50" },
+ telegram: { icon: FaTelegram, hoverClass: "hover:text-python-blue-light hover:border-python-blue-light/50" },
+ medium: { icon: FaMedium, hoverClass: "hover:text-accent-green hover:border-accent-green/50" },
+ slack: { icon: FaSlack, hoverClass: "hover:text-accent-orange hover:border-accent-orange/50" },
+ discord: { icon: FaDiscord, hoverClass: "hover:text-accent-purple hover:border-accent-purple/50" },
+};
+
+export default function Footer() {
+ return (
+
+ {/* El mosaico del logo remata la página, como en la navbar */}
+
+
+
+
+
+
+
+ La comunidad oficial de Python en Colombia. Aprende, comparte y
+ crece junto a pythonistas de todo el país.
+
+
+ {NAV_LINKS.map((link) => (
+
+ {link.label}
+
+ ))}
+
+
+
+
+
+ Encuéntranos en
+
+
+ {SOCIAL_LINKS.map((social) => {
+ const entry = SOCIAL_ICONS[social.id];
+ if (!entry) return null;
+ const Icon = entry.icon;
+ const external = social.href.startsWith("http");
+ return (
+
+
+
+
+
+ );
+ })}
+
+
+ Escríbenos:{" "}
+
+ {CONTACT_EMAIL}
+
+
+
+
+
+
+
+ © {new Date().getFullYear()} Python Colombia. Todos los derechos
+ reservados.
+
+
+ Código de Conducta
+
+
+
+
+ );
+}
diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx
new file mode 100644
index 0000000..3b36b55
--- /dev/null
+++ b/components/layout/Navbar.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Image from "next/image";
+import { LuMenu, LuX } from "react-icons/lu";
+import { asset } from "@/lib/assets";
+import { NAV_LINKS } from "@/lib/site";
+import { MosaicStrip } from "@/components/ui/MosaicBar";
+
+// TODO: cuando existan páginas propias, reemplazar estos anchors por rutas
+// reales de Next.js ( , etc.).
+
+const linkClass =
+ "px-1 py-2 text-[13px] font-semibold uppercase tracking-[0.18em] text-zinc-300 transition hover:text-python-yellow focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-python-yellow";
+
+export default function Navbar() {
+ const [scrolled, setScrolled] = useState(false);
+ const [open, setOpen] = useState(false);
+
+ useEffect(() => {
+ const onScroll = () => setScrolled(window.scrollY > 8);
+ onScroll();
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpen(false);
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [open]);
+
+ const solid = scrolled || open;
+
+ return (
+
+ );
+}
diff --git a/components/map/ColombiaMap.tsx b/components/map/ColombiaMap.tsx
new file mode 100644
index 0000000..d27ac45
--- /dev/null
+++ b/components/map/ColombiaMap.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { useState } from "react";
+import type { CommunityMarker } from "@/lib/types";
+import { markerColor } from "./marker-colors";
+
+const MAP_HMR_KEY = process.env.NODE_ENV === "development" ? Date.now().toString(36) : "map";
+
+const LeafletMap = dynamic(() => import("./LeafletMap"), {
+ ssr: false,
+ loading: () => ,
+});
+
+function MapSkeleton() {
+ return (
+
+ );
+}
+
+interface ColombiaMapProps {
+ markers: readonly CommunityMarker[];
+}
+
+/**
+ * Mapa real de Colombia (Leaflet + CARTO Dark Matter) + chips de comunidades.
+ * Los chips permiten abrir cada popup con teclado y en táctil, como
+ * alternativa accesible al click directo sobre el marcador.
+ */
+export default function ColombiaMap({ markers }: ColombiaMapProps) {
+ const [activeId, setActiveId] = useState(null);
+ const mapKey = `${MAP_HMR_KEY}:${markers.map((marker) => marker.id).join("|")}`;
+
+ return (
+
+
+ {/* `isolate` encapsula los z-index de Leaflet para no tapar la navbar */}
+
+
+
+
+
+
+ {markers.map((marker, index) => {
+ const isActive = activeId === marker.id;
+ return (
+
+ setActiveId(isActive ? null : marker.id)}
+ className={`inline-flex items-center gap-2 border px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.12em] transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-python-yellow ${
+ isActive
+ ? "border-python-yellow bg-python-yellow text-night"
+ : "border-white/15 bg-transparent text-zinc-300 hover:border-white/40 hover:text-white"
+ }`}
+ >
+
+ {marker.name}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/components/map/LeafletMap.tsx b/components/map/LeafletMap.tsx
new file mode 100644
index 0000000..a74313f
--- /dev/null
+++ b/components/map/LeafletMap.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useEffect, useMemo, useRef } from "react";
+import L from "leaflet";
+import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
+import type { CommunityMarker } from "@/lib/types";
+import { markerColor } from "./marker-colors";
+
+/** Territorio continental de Colombia. */
+const COLOMBIA_BOUNDS = L.latLngBounds([-4.2, -79], [12.5, -66.8]);
+
+interface LeafletMapProps {
+ markers: readonly CommunityMarker[];
+ activeId: string | null;
+ onSelect: React.Dispatch>;
+}
+
+/** Marcador cuadrado (mosaico del logo) con anillo pulsante, vía divIcon. */
+function buildIcon(color: string): L.DivIcon {
+ return L.divIcon({
+ className: "map-marker",
+ html:
+ ` ` +
+ ` `,
+ iconSize: [16, 16],
+ iconAnchor: [8, 8],
+ popupAnchor: [0, -12],
+ });
+}
+
+export default function LeafletMap({ markers, activeId, onSelect }: LeafletMapProps) {
+ const mapRef = useRef(null);
+ const markerRefs = useRef(new Map());
+
+ const icons = useMemo(
+ () => markers.map((_, index) => buildIcon(markerColor(index))),
+ [markers],
+ );
+
+ // Sincroniza los chips de la leyenda con el popup del marcador.
+ useEffect(() => {
+ if (!activeId) {
+ mapRef.current?.closePopup();
+ return;
+ }
+ const marker = markerRefs.current.get(activeId);
+ if (marker && !marker.isPopupOpen()) marker.openPopup();
+ }, [activeId]);
+
+ return (
+
+ {/* CARTO Dark Matter quedó descartado: hoy marca "API KEY REQUIRED" en
+ peticiones anónimas. Esri Dark Gray es gratuito, sin key y del mismo
+ estilo oscuro. URL en orden {z}/{y}/{x}. */}
+
+
+ {markers.map((marker, index) => (
+ {
+ if (instance) markerRefs.current.set(marker.id, instance);
+ else markerRefs.current.delete(marker.id);
+ }}
+ eventHandlers={{
+ popupopen: () => onSelect(marker.id),
+ popupclose: () =>
+ onSelect((current) => (current === marker.id ? null : current)),
+ }}
+ >
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/components/map/marker-colors.ts b/components/map/marker-colors.ts
new file mode 100644
index 0000000..0d12efe
--- /dev/null
+++ b/components/map/marker-colors.ts
@@ -0,0 +1,15 @@
+/**
+ * Acentos del mosaico del logo para los marcadores del mapa.
+ * Duplican los tokens de `@theme` (globals.css) porque Leaflet recibe hex directos.
+ */
+export const MARKER_COLORS = [
+ "#e24329", // accent-red
+ "#f58220", // accent-orange
+ "#2fb350", // accent-green
+ "#5c4ee5", // accent-purple
+ "#ffd43b", // python-yellow
+] as const;
+
+export function markerColor(index: number): string {
+ return MARKER_COLORS[index % MARKER_COLORS.length];
+}
diff --git a/components/sections/Communities.tsx b/components/sections/Communities.tsx
new file mode 100644
index 0000000..c935116
--- /dev/null
+++ b/components/sections/Communities.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import Image from "next/image";
+import { useEffect, useState } from "react";
+import CommunityGrid from "@/components/sections/CommunityGrid";
+import Reveal from "@/components/ui/Reveal";
+import SectionHeading from "@/components/ui/SectionHeading";
+import { getCommunities } from "@/lib/api";
+import type { Community } from "@/lib/types";
+
+/** Portadas de bloque sólido (mosaico del logo) con texto de contraste AA. */
+const COVERS = [
+ { block: "bg-accent-red", text: "text-white/90" },
+ { block: "bg-accent-orange", text: "text-night/80" },
+ { block: "bg-accent-green", text: "text-night/80" },
+ { block: "bg-accent-purple", text: "text-white/90" },
+] as const;
+
+const HOVER_SHADOWS = [
+ "hover:shadow-[8px_8px_0_0_var(--color-accent-red)]",
+ "hover:shadow-[8px_8px_0_0_var(--color-accent-orange)]",
+ "hover:shadow-[8px_8px_0_0_var(--color-accent-green)]",
+ "hover:shadow-[8px_8px_0_0_var(--color-accent-purple)]",
+] as const;
+
+export default function Communities() {
+ const [communities, setCommunities] = useState([]);
+
+ useEffect(() => {
+ void getCommunities().then((data) => setCommunities(data));
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ {communities.map((community, index) => (
+
+
+
+ {community.coverImage ? (
+
+ ) : (
+
+
+ >>> import {community.id.replaceAll("-", "_")}
+
+
+ )}
+
+
+
+
+ {community.name}
+
+
+ {community.city}
+
+
+
+ {community.description}
+
+
+ Ver comunidad
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/sections/CommunityGrid.tsx b/components/sections/CommunityGrid.tsx
new file mode 100644
index 0000000..238c1be
--- /dev/null
+++ b/components/sections/CommunityGrid.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { Children, type ReactNode, useState } from "react";
+
+interface CommunityGridProps {
+ children: ReactNode;
+}
+
+const INITIAL_VISIBLE = 8;
+
+export default function CommunityGrid({ children }: CommunityGridProps) {
+ const [showAll, setShowAll] = useState(false);
+ const cards = Children.toArray(children);
+ const visibleCards = showAll ? cards : cards.slice(0, INITIAL_VISIBLE);
+
+ return (
+ <>
+
+ {!showAll && cards.length > INITIAL_VISIBLE ? (
+ setShowAll(true)}
+ className="mx-auto mt-2 block border border-white/25 px-5 py-3 text-xs font-bold uppercase tracking-[0.12em] text-zinc-200 transition hover:border-python-yellow hover:bg-python-yellow/10 hover:text-python-yellow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-python-yellow"
+ >
+ Ver más
+
+ ) : null}
+ >
+ );
+}
\ No newline at end of file
diff --git a/components/sections/Events.tsx b/components/sections/Events.tsx
new file mode 100644
index 0000000..47a4251
--- /dev/null
+++ b/components/sections/Events.tsx
@@ -0,0 +1,237 @@
+"use client";
+
+import Image from "next/image";
+import { useEffect, useState } from "react";
+import { LuCalendar, LuClock3, LuMapPin, LuArrowUpRight } from "react-icons/lu";
+import Reveal from "@/components/ui/Reveal";
+import SectionHeading from "@/components/ui/SectionHeading";
+import { getEvents } from "@/lib/api";
+import type { CommunityEvent, EventMode } from "@/lib/types";
+
+const MODE_BADGES: Record = {
+ Presencial: "bg-accent-green text-night",
+ Virtual: "bg-accent-purple text-white",
+ Híbrido: "bg-accent-orange text-night",
+};
+
+/** Portadas de bloque sólido con texto de contraste AA. */
+const EVENT_COVERS = [
+ { block: "bg-python-blue", text: "text-white/85" },
+ { block: "bg-accent-purple", text: "text-white/85" },
+ { block: "bg-accent-orange", text: "text-night/75" },
+ { block: "bg-accent-green", text: "text-night/75" },
+] as const;
+
+const dateFormatter = new Intl.DateTimeFormat("es-CO", {
+ weekday: "long",
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ timeZone: "America/Bogota",
+});
+
+const timeFormatter = new Intl.DateTimeFormat("es-CO", {
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ timeZone: "America/Bogota",
+});
+
+const badgeDayFormatter = new Intl.DateTimeFormat("es-CO", {
+ day: "2-digit",
+ timeZone: "America/Bogota",
+});
+
+const badgeMonthFormatter = new Intl.DateTimeFormat("es-CO", {
+ month: "short",
+ timeZone: "America/Bogota",
+});
+
+/** «jueves, 24 de septiembre» -> «Jueves, 24 de septiembre» */
+function capitalizeFirst(text: string): string {
+ return text.charAt(0).toUpperCase() + text.slice(1);
+}
+
+interface EventCardProps {
+ event: CommunityEvent;
+ index: number;
+ isPast?: boolean;
+ /** La tarjeta destacada ocupa dos columnas con portada más alta. */
+ featured?: boolean;
+}
+
+function EventCard({ event, index, isPast = false, featured = false }: EventCardProps) {
+ const startsAt = new Date(event.startsAt);
+ const cover = EVENT_COVERS[index % EVENT_COVERS.length];
+
+ return (
+
+
+ {event.coverImage ? (
+
+ ) : (
+
+
+ python_colombia.events
+
+
+ )}
+
+ {/* Fecha anclada a la esquina superior izquierda */}
+
+
+ {badgeDayFormatter.format(startsAt)}
+
+
+ {badgeMonthFormatter.format(startsAt).replace(".", "")}
+
+
+
+ {isPast && (
+
+ Finalizado
+
+ )}
+
+
+
+
+ {event.mode}
+
+
+ {event.title}
+
+
+
+
Fecha
+
+ {capitalizeFirst(dateFormatter.format(startsAt))}
+
+
+
Hora
+
+ {timeFormatter.format(startsAt)}
+
+
+
Lugar
+
+ {event.location}
+
+
+
+ Ver evento
+
+
+
+
+ );
+}
+
+// Snapshot al momento del build (export estático); se refresca en cada deploy.
+const BUILD_TIME = Date.now();
+
+export default function Events() {
+ const [events, setEvents] = useState([]);
+
+ useEffect(() => {
+ void getEvents().then((data) => setEvents(data));
+ }, []);
+ const upcoming = events
+ .filter((event) => new Date(event.startsAt).getTime() >= BUILD_TIME)
+ .sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime());
+ const past = events
+ .filter((event) => new Date(event.startsAt).getTime() < BUILD_TIME)
+ .sort((a, b) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime());
+
+ return (
+
+
+
+
+
+
+ {upcoming.length > 0 ? (
+
+ {upcoming.map((event, index) => (
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ >>> eventos.proximos(){" "}
+ # []
+
+
+ Pronto anunciaremos nuevos eventos
+
+
+ Síguenos en redes o suscríbete al newsletter para enterarte de
+ primero.
+
+
+
+ )}
+
+ {past.length > 0 && (
+
+
+
+ Eventos anteriores
+
+
+
+
+ {past.map((event, index) => (
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/components/sections/Hero.tsx b/components/sections/Hero.tsx
new file mode 100644
index 0000000..bb9b7dc
--- /dev/null
+++ b/components/sections/Hero.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import ColombiaMap from "@/components/map/ColombiaMap";
+import Reveal from "@/components/ui/Reveal";
+import { MosaicBar } from "@/components/ui/MosaicBar";
+import { getMapMarkers } from "@/lib/api";
+import type { CommunityMarker } from "@/lib/types";
+
+export default function Hero() {
+ const [markers, setMarkers] = useState([]);
+
+ useEffect(() => {
+ void getMapMarkers().then((data) => setMarkers(data));
+ }, []);
+
+ return (
+
+ {/* Marca de agua tipo prompt de Python */}
+
+ >>>
+
+
+
+
+
+
+
+
+ Comunidad oficial
+
+
+
+
+ La comunidad de{" "}
+
+ Python en Colombia
+
+
+
+
+ ¡Aprende, comparte y crece!
+
+
+
+ Conecta con pythonistas de todo el país: meetups, talleres,
+ proyectos open source y una red de comunidades locales que te
+ esperan con los brazos abiertos.
+
+
+
+
+
+
+ {/* El mapa sangra hacia el borde derecho para romper la grilla */}
+
+
+
+
+
+ );
+}
diff --git a/components/sections/Newsletter.tsx b/components/sections/Newsletter.tsx
new file mode 100644
index 0000000..0ef8425
--- /dev/null
+++ b/components/sections/Newsletter.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useState } from "react";
+import { LuLoaderCircle, LuMail } from "react-icons/lu";
+import Reveal from "@/components/ui/Reveal";
+import SectionHeading from "@/components/ui/SectionHeading";
+import { subscribeToNewsletter } from "@/lib/api";
+import type { NewsletterResponse, NewsletterStatus } from "@/lib/types";
+
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+const FEEDBACK_STYLES: Record = {
+ success: "bg-accent-green text-night",
+ already_subscribed: "bg-python-yellow text-night",
+ error: "bg-accent-red text-white",
+};
+
+type FormState =
+ | { kind: "idle" }
+ | { kind: "invalid" }
+ | { kind: "loading" }
+ | { kind: "feedback"; response: NewsletterResponse };
+
+export default function Newsletter() {
+ const [email, setEmail] = useState("");
+ const [state, setState] = useState({ kind: "idle" });
+
+ const loading = state.kind === "loading";
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ if (loading) return;
+
+ if (!EMAIL_PATTERN.test(email.trim())) {
+ setState({ kind: "invalid" });
+ return;
+ }
+
+ setState({ kind: "loading" });
+ // Mismo contrato async que tendrá el fetch a la API real.
+ const response = await subscribeToNewsletter(email);
+ setState({ kind: "feedback", response });
+ if (response.status === "success") setEmail("");
+ }
+
+ return (
+
+
+
+
+ {/* Panel de marca en azul sólido */}
+
+
+ {/* Panel del formulario */}
+
+
+ Recibe en tu correo los próximos eventos, noticias y novedades
+ de la comunidad. Nada de spam, lo prometemos.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/sections/Sponsors.tsx b/components/sections/Sponsors.tsx
new file mode 100644
index 0000000..0069197
--- /dev/null
+++ b/components/sections/Sponsors.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import Image from "next/image";
+import { useEffect, useState } from "react";
+import { LuHandshake } from "react-icons/lu";
+import Reveal from "@/components/ui/Reveal";
+import SectionHeading from "@/components/ui/SectionHeading";
+import { getSponsors } from "@/lib/api";
+import { CONTACT_EMAIL } from "@/lib/site";
+import type { Sponsor } from "@/lib/types";
+
+/** Cuadros de iniciales en bloques sólidos del mosaico (contraste AA). */
+const TILE_ACCENTS = [
+ "bg-accent-red text-white",
+ "bg-accent-orange text-night",
+ "bg-accent-green text-night",
+ "bg-accent-purple text-white",
+] as const;
+
+function initials(name: string): string {
+ return name
+ .split(/\s+/)
+ .slice(0, 2)
+ .map((word) => word[0]?.toUpperCase() ?? "")
+ .join("");
+}
+
+export default function Sponsors() {
+ const [sponsors, setSponsors] = useState([]);
+
+ useEffect(() => {
+ void getSponsors().then((data) => setSponsors(data));
+ }, []);
+ const mailto = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(
+ "Quiero patrocinar a Python Colombia",
+ )}`;
+
+ return (
+
+
+ {/* Cabecera asimétrica: título a la izquierda, CTA alineado abajo-derecha */}
+
+
+ {sponsors.length > 0 ? (
+
+ ) : (
+
+
+
+ Sé el primero en apoyarnos
+
+
+ Este espacio está reservado para las marcas que impulsan a la
+ comunidad Python en Colombia.
+
+
+
+ )}
+
+
+ );
+}
diff --git a/components/ui/MosaicBar.tsx b/components/ui/MosaicBar.tsx
new file mode 100644
index 0000000..f62c0ef
--- /dev/null
+++ b/components/ui/MosaicBar.tsx
@@ -0,0 +1,30 @@
+const MOSAIC_CLASSES = [
+ "bg-accent-red",
+ "bg-accent-orange",
+ "bg-python-yellow",
+ "bg-accent-green",
+ "bg-python-blue-light",
+ "bg-accent-purple",
+] as const;
+
+/** Fila de bloques sólidos del mosaico del logo (elemento gráfico recurrente). */
+export function MosaicBar({ className = "" }: { className?: string }) {
+ return (
+
+ {MOSAIC_CLASSES.map((color) => (
+
+ ))}
+
+ );
+}
+
+/** Franja de mosaico de borde a borde (remate de navbar y footer). */
+export function MosaicStrip({ className = "" }: { className?: string }) {
+ return (
+
+ {MOSAIC_CLASSES.map((color) => (
+
+ ))}
+
+ );
+}
diff --git a/components/ui/Reveal.tsx b/components/ui/Reveal.tsx
new file mode 100644
index 0000000..b28772f
--- /dev/null
+++ b/components/ui/Reveal.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+interface RevealProps {
+ children: React.ReactNode;
+ className?: string;
+ /** Retardo en ms para escalonar elementos de una misma sección. */
+ delay?: number;
+}
+
+/** Animación sutil de entrada al hacer scroll (IntersectionObserver). */
+export default function Reveal({ children, className = "", delay = 0 }: RevealProps) {
+ const ref = useRef(null);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ const node = ref.current;
+ if (!node) return;
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setVisible(true);
+ observer.disconnect();
+ }
+ },
+ { threshold: 0.15, rootMargin: "0px 0px -40px 0px" },
+ );
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/ui/SectionHeading.tsx b/components/ui/SectionHeading.tsx
new file mode 100644
index 0000000..513ada3
--- /dev/null
+++ b/components/ui/SectionHeading.tsx
@@ -0,0 +1,36 @@
+import { MosaicBar } from "@/components/ui/MosaicBar";
+
+interface SectionHeadingProps {
+ eyebrow: string;
+ title: string;
+ description?: string;
+ /** Color del kicker (número + eyebrow). */
+ accentClass?: string;
+}
+
+export default function SectionHeading({
+ eyebrow,
+ title,
+ description,
+ accentClass = "text-python-yellow",
+}: SectionHeadingProps) {
+ return (
+
+
+
+ {eyebrow}
+
+
+ {title}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ );
+}
diff --git a/lib/api.ts b/lib/api.ts
new file mode 100644
index 0000000..9abfb28
--- /dev/null
+++ b/lib/api.ts
@@ -0,0 +1,199 @@
+import type {
+ Community,
+ CommunityEvent,
+ CommunityMarker,
+ EventMode,
+ NewsletterResponse,
+ Sponsor,
+} from "@/lib/types";
+
+const DEFAULT_API_BASE_URL = "http://localhost:8000";
+
+export const API_BASE_URL = (
+ process.env.NEXT_PUBLIC_API_BASE_URL ?? DEFAULT_API_BASE_URL
+).replace(/\/$/, "");
+
+type ApiRecord = Record;
+
+function textValue(value: unknown, fallback = ""): string {
+ return typeof value === "string" && value.trim() ? value : fallback;
+}
+
+function nullableTextValue(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value : null;
+}
+
+function numberValue(value: unknown, fallback = 0): number {
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (typeof value === "string" && value.trim()) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return fallback;
+}
+
+function field(record: ApiRecord, ...names: string[]): unknown {
+ for (const name of names) {
+ if (name in record) return record[name];
+ }
+ return undefined;
+}
+
+function slugify(text: string): string {
+ return text
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "");
+}
+
+function recordList(value: unknown): ApiRecord[] {
+ return Array.isArray(value)
+ ? value.filter((item): item is ApiRecord => item !== null && typeof item === "object")
+ : [];
+}
+
+async function apiFetch(path: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}${path}`, { cache: "no-store" });
+ if (!response.ok) return null;
+ return (await response.json()) as T;
+ } catch (error) {
+ console.error(`API request failed: ${path}`, error);
+ return null;
+ }
+}
+
+async function apiSend(path: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}${path}`, { method: "POST" });
+ if (!response.ok) return null;
+ return (await response.json()) as T;
+ } catch (error) {
+ console.error(`API request failed: ${path}`, error);
+ return null;
+ }
+}
+
+async function apiFetchFresh(path: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}${path}`, { cache: "no-store" });
+ if (!response.ok) return null;
+ return (await response.json()) as T;
+ } catch (error) {
+ console.error(`API request failed: ${path}`, error);
+ return null;
+ }
+}
+
+function normalizeCommunity(record: ApiRecord): Community {
+ const name = textValue(field(record, "name", "nombre"), "Comunidad Python");
+ return {
+ id: textValue(field(record, "id", "slug"), slugify(name)),
+ name,
+ city: textValue(field(record, "city", "ciudad"), "Colombia"),
+ description: textValue(field(record, "description", "descripcion")),
+ coverImage: nullableTextValue(field(record, "coverImage", "cover_image", "cover_url", "image_url")),
+ communityUrl: textValue(field(record, "communityUrl", "community_url", "websiteUrl", "website_url", "url"), "#"),
+ };
+}
+
+function normalizeMode(value: unknown): EventMode {
+ const mode = textValue(value, "Presencial").toLowerCase();
+ if (mode.includes("virtual")) return "Virtual";
+ if (mode.includes("hibrido") || mode.includes("híbrido")) return "Híbrido";
+ return "Presencial";
+}
+
+function normalizeEvent(record: ApiRecord): CommunityEvent {
+ const title = textValue(field(record, "title", "name", "titulo", "nombre"), "Evento Python Colombia");
+ return {
+ id: textValue(field(record, "id", "slug"), slugify(title)),
+ title,
+ startsAt: textValue(field(record, "startsAt", "starts_at", "startDate", "start_date", "date", "fecha"), new Date().toISOString()),
+ location: textValue(field(record, "location", "venue", "place", "ubicacion", "lugar"), "Por anunciar"),
+ mode: normalizeMode(field(record, "mode", "modalidad")),
+ coverImage: nullableTextValue(field(record, "coverImage", "cover_image", "cover_url", "image_url")),
+ eventUrl: textValue(field(record, "eventUrl", "event_url", "registrationUrl", "registration_url", "url"), "#"),
+ };
+}
+
+function normalizeMarker(record: ApiRecord): CommunityMarker {
+ const name = textValue(field(record, "name", "nombre"), "Python Colombia");
+ const coordinates = field(record, "coordinates", "coordenadas");
+ const coordinateRecord = coordinates !== null && typeof coordinates === "object" ? coordinates as ApiRecord : {};
+
+ return {
+ id: textValue(field(record, "id", "slug"), slugify(name)),
+ name,
+ description: textValue(field(record, "description", "descripcion")),
+ city: textValue(field(record, "city", "ciudad"), "Colombia"),
+ coordinates: {
+ lat: numberValue(field(record, "lat", "latitude", "latitud") ?? field(coordinateRecord, "lat", "latitude", "latitud")),
+ lng: numberValue(field(record, "lng", "lon", "long", "longitude", "longitud") ?? field(coordinateRecord, "lng", "lon", "long", "longitude", "longitud")),
+ },
+ communityUrl: textValue(field(record, "communityUrl", "community_url", "websiteUrl", "website_url", "url"), "#"),
+ };
+}
+
+function normalizeSponsor(record: ApiRecord): Sponsor {
+ const name = textValue(field(record, "name", "nombre"), "Patrocinador");
+ return {
+ id: textValue(field(record, "id", "slug"), slugify(name)),
+ name,
+ websiteUrl: textValue(field(record, "websiteUrl", "website_url", "url"), "#"),
+ logoImage: nullableTextValue(field(record, "logoImage", "logo_image", "logo_url", "image_url")),
+ };
+}
+
+function isActiveSubscription(value: unknown): boolean {
+ const record = value !== null && typeof value === "object" ? value as ApiRecord : {};
+ return field(record, "isActive") === true;
+}
+
+export async function getCommunities(): Promise {
+ const data = await apiFetch("/api/communities");
+ return recordList(data).map(normalizeCommunity);
+}
+
+export async function getEvents(): Promise {
+ const data = await apiFetch("/api/events");
+ return recordList(data).map(normalizeEvent);
+}
+
+export async function getMapMarkers(): Promise {
+ const data = await apiFetch("/api/map-markers");
+ return recordList(data).map(normalizeMarker);
+}
+
+export async function getSponsors(): Promise {
+ const data = await apiFetch("/api/sponsors");
+ return recordList(data).map(normalizeSponsor);
+}
+
+export async function subscribeToNewsletter(email: string): Promise {
+ const normalizedEmail = email.trim().toLowerCase();
+ const searchParams = new URLSearchParams({ email: normalizedEmail });
+ const subscription = await apiFetchFresh(`/api/newsletter/subscription?${searchParams.toString()}`);
+
+ if (isActiveSubscription(subscription)) {
+ return {
+ status: "already_subscribed",
+ message: "Este correo ya está registrado en el newsletter.",
+ };
+ }
+
+ const data = await apiSend(`/api/newsletter/subscribe?${searchParams.toString()}`);
+ if (!isActiveSubscription(data)) {
+ return {
+ status: "error",
+ message: "No pudimos procesar tu suscripción. Intenta de nuevo.",
+ };
+ }
+
+ return {
+ status: "success",
+ message: "Te has suscrito al newsletter.",
+ };
+}
diff --git a/lib/console.ts b/lib/console.ts
index 43520d7..928a64f 100644
--- a/lib/console.ts
+++ b/lib/console.ts
@@ -1,3 +1,4 @@
+/** Mensaje de consola personalizado para Python Colombia */
export function ConsoleMessage(): void {
if (typeof window === "undefined") return;
diff --git a/lib/seo.ts b/lib/seo.ts
index 4c2f1fa..616ff8e 100644
--- a/lib/seo.ts
+++ b/lib/seo.ts
@@ -1,13 +1,7 @@
import type { Metadata, Viewport } from "next";
import { asset } from "@/lib/assets";
-function resolveMetadataBase(): URL {
- if (process.env.GITHUB_ACTIONS && process.env.GITHUB_REPOSITORY) {
- const owner = process.env.GITHUB_REPOSITORY.split("/")[0];
- return new URL(`https://${owner}.github.io`);
- }
- return new URL("https://python.org.co");
-}
+const METADATA_BASE = new URL("https://python.org.co");
export const viewport: Viewport = {
width: "device-width",
@@ -17,7 +11,7 @@ export const viewport: Viewport = {
};
export const metadata: Metadata = {
- metadataBase: resolveMetadataBase(),
+ metadataBase: METADATA_BASE,
title: {
default: "Python Colombia | Comunidad Oficial de Python en Colombia",
template: "%s | Python Colombia",
diff --git a/lib/site.ts b/lib/site.ts
new file mode 100644
index 0000000..b0de740
--- /dev/null
+++ b/lib/site.ts
@@ -0,0 +1,32 @@
+/** Configuración estática del sitio: navegación, contacto y redes. */
+
+export const CONTACT_EMAIL = "colombiapython@gmail.com";
+export const CODE_OF_CONDUCT_URL = "https://github.com/ColombiaPython/codigo-de-conducta";
+
+/** Menú de navegación del sitio */
+export const NAV_LINKS = [
+ { label: "Comunidades", href: "#comunidades" },
+ { label: "Eventos", href: "#eventos" },
+ { label: "Patrocinadores", href: "#patrocinadores" },
+ { label: "Newsletter", href: "#newsletter" },
+] as const;
+
+/** Enlaces a redes sociales del sitio */
+export interface SocialLink {
+ id: string;
+ label: string;
+ href: string;
+}
+
+/** Lista de enlaces a redes sociales del sitio */
+export const SOCIAL_LINKS: readonly SocialLink[] = [
+ { id: "email", label: "Email", href: `mailto:${CONTACT_EMAIL}` },
+ { id: "x", label: "X (Twitter)", href: "https://x.com/ColombiaPython" },
+ { id: "facebook", label: "Facebook", href: "https://www.facebook.com/ColombiaPython" },
+ { id: "instagram", label: "Instagram", href: "https://www.instagram.com/colombiapython/" },
+ { id: "github", label: "GitHub", href: "https://github.com/ColombiaPython" },
+ { id: "telegram", label: "Telegram", href: "https://t.me/pythoncolombia" },
+ { id: "medium", label: "Medium", href: "https://medium.com/@pythoncolombia" },
+ { id: "slack", label: "Slack", href: "https://python-colombia.slack.com/" },
+ { id: "discord", label: "Discord", href: "https://discord.gg/zhNTvcrffP" },
+] as const;
diff --git a/lib/types.ts b/lib/types.ts
new file mode 100644
index 0000000..2a38ade
--- /dev/null
+++ b/lib/types.ts
@@ -0,0 +1,58 @@
+/**
+ * Contratos de datos de la landing.
+ *
+ * La API real puede responder con campos en snake_case o camelCase; el cliente
+ * HTTP normaliza esos datos a estos contratos antes de llegar a los componentes.
+ */
+
+/** Marcador de comunidad sobre el mapa 3D de Colombia. */
+export interface CommunityMarker {
+ id: string;
+ name: string;
+ description: string;
+ city: string;
+ /** Coordenadas geográficas reales (WGS84); el mapa las proyecta a su plano. */
+ coordinates: { lat: number; lng: number };
+ communityUrl: string;
+}
+
+/** Comunidad mostrada en la grilla de tarjetas. */
+export interface Community {
+ id: string;
+ name: string;
+ city: string;
+ description: string;
+ /** URL de portada; con `null` se usa un placeholder generado. */
+ coverImage: string | null;
+ communityUrl: string;
+}
+
+export type EventMode = "Presencial" | "Virtual" | "Híbrido";
+
+/** Evento estilo Meetup/Luma. */
+export interface CommunityEvent {
+ id: string;
+ title: string;
+ /** Fecha y hora de inicio en ISO 8601 (con offset de Colombia). */
+ startsAt: string;
+ location: string;
+ mode: EventMode;
+ /** URL de portada; con `null` se usa un placeholder generado. */
+ coverImage: string | null;
+ eventUrl: string;
+}
+
+export interface Sponsor {
+ id: string;
+ name: string;
+ websiteUrl: string;
+ /** URL del logo; con `null` se usa un placeholder con iniciales. */
+ logoImage: string | null;
+}
+
+export type NewsletterStatus = "success" | "already_subscribed" | "error";
+
+export interface NewsletterResponse {
+ status: NewsletterStatus;
+ message: string;
+}
diff --git a/next.config.ts b/next.config.ts
index b57c48f..c4be9da 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,19 +1,24 @@
import type { NextConfig } from "next";
-const repoName = process.env.GITHUB_REPOSITORY?.split("/")[1];
-const basePath = process.env.GITHUB_ACTIONS && repoName ? `/${repoName}` : "";
+const basePath = "";
+const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "https://pythonco.orionis-framework.com";
const nextConfig: NextConfig = {
output: "export",
distDir: "dist",
images: {
unoptimized: true,
+ remotePatterns: [
+ { protocol: "http", hostname: "**" },
+ { protocol: "https", hostname: "**" },
+ ],
},
basePath,
assetPrefix: basePath,
trailingSlash: true,
env: {
NEXT_PUBLIC_BASE_PATH: basePath,
+ NEXT_PUBLIC_API_BASE_URL: apiBaseUrl,
},
};
diff --git a/package-lock.json b/package-lock.json
index 3e7f357..f10c896 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,12 +8,16 @@
"name": "python-colombia-web",
"version": "0.1.0",
"dependencies": {
+ "leaflet": "^1.9.4",
"next": "16.2.10",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "react-icons": "^5.7.0",
+ "react-leaflet": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/leaflet": "^1.9.22",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1306,6 +1310,17 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@react-leaflet/core": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
+ "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1623,6 +1638,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1637,6 +1659,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/leaflet": {
+ "version": "1.9.22",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
+ "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -4751,6 +4783,12 @@
"node": ">=0.10"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5672,6 +5710,15 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-icons": {
+ "version": "5.7.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
+ "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -5679,6 +5726,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-leaflet": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
+ "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^3.0.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
diff --git a/package.json b/package.json
index c010a90..547b163 100644
--- a/package.json
+++ b/package.json
@@ -9,12 +9,16 @@
"lint": "eslint"
},
"dependencies": {
+ "leaflet": "^1.9.4",
"next": "16.2.10",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "react-icons": "^5.7.0",
+ "react-leaflet": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/leaflet": "^1.9.22",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",