diff --git a/.github/ISSUE_TEMPLATE/cfs.yml b/.github/ISSUE_TEMPLATE/cfs.yml new file mode 100644 index 0000000..7605fe9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/cfs.yml @@ -0,0 +1,64 @@ +name: Agregar Call for Speakers +description: Proponer una nueva convocatoria de ponentes (CFS) para la comunidad tech en Perú +title: "[CFS] " +labels: [] +body: + - type: markdown + attributes: + value: | + ¡Gracias por querer compartir un Call for Speakers en peruanos.dev! + Por favor completa la siguiente información sobre la convocatoria. + + - type: input + id: title + attributes: + label: Título de la convocatoria / evento + description: Nombre o título de la convocatoria para speakers + placeholder: 'ej. DevOpsDays Lima 2025 - Call for Speakers' + validations: + required: true + + - type: input + id: community + attributes: + label: Comunidad o evento organizador + description: Nombre de la comunidad u organización que convoca + placeholder: 'ej. React Lima' + validations: + required: true + + - type: input + id: cfs_url + attributes: + label: Link para postular (CFS URL) + description: URL del formulario o plataforma para enviar propuestas (ej. Sessionize, Google Forms) + placeholder: 'ej. https://sessionize.com/... o https://forms.gle/...' + validations: + required: true + + - type: input + id: deadline + attributes: + label: Fecha límite de postulación (Deadline) + description: Fecha límite para enviar propuestas (YYYY-MM-DD) o indicar si es continua/abierta todo el año + placeholder: 'ej. 2025-06-30 o Abierto todo el año' + + - type: textarea + id: description + attributes: + label: Descripción de la convocatoria + description: Resumen de los temas buscados, formato de charlas, etc. + placeholder: 'Buscamos charlas de 30 minutos sobre...' + + - type: checkboxes + id: terms + attributes: + label: Confirmación + description: Por favor confirma lo siguiente + options: + - label: He verificado que la convocatoria no esté duplicada + required: true + - label: La información proporcionada es correcta y está actualizada + required: true + - label: Tengo permiso o represento a la comunidad convocante + required: true diff --git a/app/api/cfs/route.ts b/app/api/cfs/route.ts new file mode 100644 index 0000000..539b377 --- /dev/null +++ b/app/api/cfs/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { CFS_LIST } from '@/app/data/cfs'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const topic = searchParams.get('topic')?.toLowerCase(); + const type = searchParams.get('type')?.toLowerCase(); + const status = searchParams.get('status')?.toLowerCase(); + + let filtered = CFS_LIST; + + if (topic) { + filtered = filtered.filter((item) => + item.topics.some((t) => t.toLowerCase() === topic) + ); + } + + if (type) { + filtered = filtered.filter( + (item) => item.type?.toLowerCase() === type + ); + } + + if (status) { + filtered = filtered.filter( + (item) => item.status?.toLowerCase() === status + ); + } + + filtered = [...filtered].sort((a, b) => { + const aClosed = a.status === 'closed'; + const bClosed = b.status === 'closed'; + if (!aClosed && bClosed) return -1; + if (aClosed && !bClosed) return 1; + + if (a.deadline && b.deadline) { + return new Date(a.deadline).getTime() - new Date(b.deadline).getTime(); + } + if (a.deadline && !b.deadline) return -1; + if (!a.deadline && b.deadline) return 1; + + return 0; + }); + + return NextResponse.json(filtered, { status: 200 }); + } catch (error) { + console.error("Error al obtener los Call for Speakers:", error); + return NextResponse.json( + { error: "Error al obtener los Call for Speakers" }, + { status: 500 } + ); + } +} diff --git a/app/apis/ApiDocsViewer.tsx b/app/apis/ApiDocsViewer.tsx index 9b3707c..71cc5d3 100644 --- a/app/apis/ApiDocsViewer.tsx +++ b/app/apis/ApiDocsViewer.tsx @@ -60,6 +60,27 @@ const ENDPOINTS = [ "stars": 128 }, // ... +]` + }, + { + id: "cfs", + method: "GET", + path: "/api/cfs", + title: "/api/cfs", + description: "Obtiene el directorio de convocatorias para ponentes (Call for Speakers) abiertas y continuas en Perú.", + filters: ["topic=devops", "type=presencial", "status=open"], + fetchCode: "fetch('https://peruanos.dev/api/cfs?topic=devops&status=open')\n .then(res => res.json())\n .then(data => console.log(data))", + jsonSnippet: `data = [ + { + "id": "cfs-devopsdays-lima-2025", + "title": "DevOpsDays Lima 2025 - Call for Speakers", + "community": "DevOpsDays Lima", + "deadline": "2025-06-15", + "cfs_url": "https://sessionize.com/devopsdays-lima-2025", + "type": "Presencial", + "topics": ["DevOps", "SRE", "Cloud Native"] + }, + // ... ]` } ]; diff --git a/app/cfs/page.tsx b/app/cfs/page.tsx new file mode 100644 index 0000000..dc374d4 --- /dev/null +++ b/app/cfs/page.tsx @@ -0,0 +1,58 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; +import CfsClient from '../components/cfs/CfsClient'; +import { addUTMParams } from '../lib/utm'; + +export const metadata: Metadata = { + title: 'Call for Speakers | Convocatorias para Speakers Tech en Perú', + description: 'Directorio centralizado de Call for Speakers (CFS) y convocatorias de ponentes para eventos, conferencias y meetups de la comunidad tech en Perú.', + keywords: ['call for speakers', 'cfs', 'speakers peru', 'conferencias tech', 'meetups peru', 'charlas tecnologia', 'comunidad tech', 'desarrolladores peru'], + authors: [{ name: 'Luis Eduardo', url: 'https://lperezp.dev' }], + openGraph: { + title: 'Call for Speakers | Convocatorias Tech en Perú', + description: 'Encuentra oportunidades para dar charlas y workshops en eventos y comunidades tecnológicas de Perú.', + url: 'https://peruanos.dev/cfs', + images: [ + { + url: 'https://peruanos.dev/images/og-image.png', + } + ], + siteName: 'Peruanos.dev', + locale: 'es_PE', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: 'Call for Speakers | Convocatorias Tech en Perú', + description: 'Encuentra oportunidades para dar charlas y workshops en eventos y comunidades tecnológicas de Perú.', + images: 'https://peruanos.dev/images/og-image.png', + creator: '@lperezp_pe', + }, +}; + +export default function CfsPage() { + return ( +
+
+

+ Call for Speakers +

+

+ Directorio centralizado de convocatorias para speakers en el Perú. Si deseas compartir tus conocimientos, experiencias o proyectos con la comunidad tech, explora las convocatorias abiertas y postula a tu próximo evento o meetup. +

+
+ + Publicar un Call for Speakers + +
+ + +
+
+ ); +} diff --git a/app/components/cfs/CfsCard.tsx b/app/components/cfs/CfsCard.tsx new file mode 100644 index 0000000..7c35223 --- /dev/null +++ b/app/components/cfs/CfsCard.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { + AlarmClock, + Calendar, + Clock, + ExternalLink, + Globe, + MapPin, + Users, + Video, +} from 'lucide-react'; +import { ICFS } from '../../models/cfs.model'; +import Badge from '../ui/Badge'; +import ShareButton from '../ui/ShareButton'; +import TrackedLink from '../ui/TrackedLink'; +import { addUTMParams } from '../../lib/utm'; + +interface Props { + cfs: ICFS; +} + +export default function CfsCard({ cfs }: Props) { + // Format event date (e.g. "Sáb, 14 Nov 2026") + let formattedEventDate = ''; + if (cfs.event_date) { + const eDate = new Date(cfs.event_date); + if (!isNaN(eDate.getTime())) { + const weekday = eDate.toLocaleDateString('es-PE', { weekday: 'short', timeZone: 'UTC' }); + const day = eDate.getUTCDate(); + const month = eDate.toLocaleDateString('es-PE', { month: 'short', timeZone: 'UTC' }); + const year = eDate.getUTCFullYear(); + const capWeekday = weekday.charAt(0).toUpperCase() + weekday.slice(1).replace('.', ''); + const capMonth = month.charAt(0).toUpperCase() + month.slice(1).replace('.', ''); + formattedEventDate = `${capWeekday}, ${day} ${capMonth} ${year}`; + } + } + + // Format deadline text + let deadlineText = 'Convocatoria continua'; + if (cfs.deadline) { + const deadlineDate = new Date(cfs.deadline); + if (!isNaN(deadlineDate.getTime())) { + const day = deadlineDate.getUTCDate(); + const month = deadlineDate.toLocaleString('es-PE', { month: 'long', timeZone: 'UTC' }); + const year = deadlineDate.getUTCFullYear(); + deadlineText = `${day} de ${month}, ${year}`; + } else { + deadlineText = cfs.deadline; + } + } + + // Format clean website URL and display hostname + const rawWebsite = cfs.website_url || cfs.cfs_url; + let displayWebsite = ''; + let cleanWebsiteUrl = rawWebsite; + if (rawWebsite) { + try { + const parsed = new URL(rawWebsite); + displayWebsite = + parsed.hostname.replace(/^www\./, '') + + (parsed.pathname !== '/' && !cfs.website_url ? parsed.pathname : ''); + cleanWebsiteUrl = rawWebsite; + } catch { + displayWebsite = rawWebsite.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, ''); + } + } + + const isClosed = cfs.status === 'closed'; + + return ( +
+
+ {/* Top Badges Row (Sessionize layout styled strictly with app tokens) */} +
+ {/* Status badge: Cierra pronto / Abierto / Cerrado */} + {cfs.status === 'closing_soon' && ( + + + Cierra pronto + + )} + {cfs.status === 'open' && ( + + + Abierto + + )} + {cfs.status === 'always_open' && ( + + + Todo el año + + )} + {cfs.status === 'closed' && ( + + + Cerrado + + )} + + {/* Type Badge: Presencial / Virtual / Híbrido */} + {cfs.type === 'Presencial' && ( + + + Presencial + + )} + {cfs.type === 'Virtual' && ( + + + )} + {cfs.type === 'Híbrido' && ( + + + Híbrido + + )} + +
+ + {/* Title (Direct link with app tokens, NO MODAL) */} +

+ {cfs.title} +

+ + {/* Metadata Details (Date, Location, Website, Deadline) */} +
+ {formattedEventDate && ( +
+ + {formattedEventDate} +
+ )} + {cfs.location && ( +
+ + {cfs.location} +
+ )} + {displayWebsite && ( +
+ + + {displayWebsite} + +
+ )} + {cfs.deadline && ( +
+ + + Cierre de propuestas: {deadlineText} + +
+ )} +
+ + {/* Description */} + {cfs.description && ( +

+ {cfs.description} +

+ )} + + {/* Topics / Tags */} + {cfs.topics.length > 0 && ( +
+ {cfs.topics.map((topic) => ( + + {topic} + + ))} +
+ )} +
+ + {/* Actions (Direct application link, share, NO MODAL) */} +
+ + Postular como Speaker + + + + +
+
+ ); +} diff --git a/app/components/cfs/CfsClient.tsx b/app/components/cfs/CfsClient.tsx new file mode 100644 index 0000000..8364ca6 --- /dev/null +++ b/app/components/cfs/CfsClient.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Search, Filter, Mic, X } from 'lucide-react'; +import { CFS_LIST } from '../../data/cfs'; +import CfsCard from './CfsCard'; + +export default function CfsClient() { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedTopic, setSelectedTopic] = useState('todos'); + const [selectedType, setSelectedType] = useState('todos'); + + // Extract all unique topics + const allTopics = useMemo(() => { + const topicsSet = new Set(); + CFS_LIST.forEach((item) => { + item.topics.forEach((topic) => topicsSet.add(topic)); + }); + return Array.from(topicsSet).sort(); + }, []); + + // Filter CFS list + const filteredCfs = useMemo(() => { + return CFS_LIST.filter((item) => { + // Search term + const matchesSearch = + searchTerm === '' || + item.title.toLowerCase().includes(searchTerm.toLowerCase()) || + item.community.toLowerCase().includes(searchTerm.toLowerCase()) || + item.description.toLowerCase().includes(searchTerm.toLowerCase()) || + item.topics.some((t) => t.toLowerCase().includes(searchTerm.toLowerCase())); + + // Topic filter + const matchesTopic = + selectedTopic === 'todos' || + item.topics.some((t) => t.toLowerCase() === selectedTopic.toLowerCase()); + + // Type filter + const matchesType = + selectedType === 'todos' || + item.type.toLowerCase() === selectedType.toLowerCase(); + + return matchesSearch && matchesTopic && matchesType; + }) + .sort((a, b) => { + // Convocatorias activas primero, cerradas al final + const aClosed = a.status === 'closed'; + const bClosed = b.status === 'closed'; + if (!aClosed && bClosed) return -1; + if (aClosed && !bClosed) return 1; + + // Si ambas tienen deadline, ordenar por la fecha más próxima (ascendente) + if (a.deadline && b.deadline) { + return new Date(a.deadline).getTime() - new Date(b.deadline).getTime(); + } + // Convocatorias con deadline específico van antes que las continuas sin fecha límite + if (a.deadline && !b.deadline) return -1; + if (!a.deadline && b.deadline) return 1; + + return 0; + }); + }, [searchTerm, selectedTopic, selectedType]); + + const clearFilters = () => { + setSearchTerm(''); + setSelectedTopic('todos'); + setSelectedType('todos'); + }; + + const hasActiveFilters = + searchTerm !== '' || + selectedTopic !== 'todos' || + selectedType !== 'todos'; + + return ( +
+ {/* Search and Filters Bar */} +
+
+ {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary transition-all" + /> + {searchTerm && ( + + )} +
+ + {/* Type Dropdown */} +
+ +
+
+ + {/* Topic Filters */} +
+ + + Tópicos: + + + + + {allTopics.map((topic) => ( + + ))} + + {hasActiveFilters && ( + + )} +
+
+ + {/* Results Header */} +
+

+ Mostrando {filteredCfs.length} {filteredCfs.length === 1 ? 'convocatoria' : 'convocatorias'} +

+
+ + {/* List of CFS Cards */} + {filteredCfs.length > 0 ? ( +
+ {filteredCfs.map((cfs) => ( + + ))} +
+ ) : ( +
+ +

No se encontraron convocatorias

+

+ No encontramos ninguna convocatoria que coincida con los filtros seleccionados. Intenta ajustar tu búsqueda o limpiar los filtros. +

+ +
+ )} +
+ ); +} diff --git a/app/components/layout/Header.tsx b/app/components/layout/Header.tsx index 3b2bc27..9296ba7 100644 --- a/app/components/layout/Header.tsx +++ b/app/components/layout/Header.tsx @@ -32,6 +32,7 @@ export default function Header() { @@ -103,6 +104,15 @@ export default function Header() { > Comunidades + setIsMenuOpen(false)} + eventName="navigate_menu" + eventParams={{ event_name: '/cfs', section: 'Header' }} + > + Call for Speakers +