diff --git a/SEO_REPORT.md b/SEO_REPORT.md new file mode 100644 index 0000000..fe73944 --- /dev/null +++ b/SEO_REPORT.md @@ -0,0 +1,91 @@ +# SEO Audit Report — JavaScript Chile +Generated: 2026-06-20 + +> **Nota:** Este es un proyecto Next.js App Router. Las correcciones se aplican en `src/app/layout.tsx`, `public/robots.txt` y `public/sitemap.xml` directamente (no existe carpeta `seo-optimized/` porque no son HTMLs estáticos). + +--- + +## Summary +- Páginas analizadas: 1 (SPA — single page) +- Issues críticos: 4 +- Warnings: 3 +- Checks pasados: 7 + +--- + +## Resultados + +### `src/app/layout.tsx` (metadata global) + +#### ✅ Pasando +- `` presente y correcto +- `` bien formado: "JavaScript Chile | Comunidad de Desarrolladores" (52 chars) +- `<meta name="description">` presente y relevante (~200 chars — ver warning) +- `og:title` presente +- `og:description` presente +- `og:url` presente (`https://jschile.org/`) +- `og:siteName` presente +- `og:locale` presente (`es_CL`) +- `og:type` presente (`website`) + +#### ⚠️ Warnings +- **Description demasiado larga**: 218 chars (recomendado: 150–160). Google la trunca en SERP. +- **`metadataBase` es string**: debe ser `new URL('https://jschile.org')` para que Next.js resuelva URLs relativas correctamente. +- **Sin `twitter:card`**: falta por completo. Afecta previews en X/Twitter. + +#### ❌ Issues Críticos +- **`og:image` apunta a `/icon.png` que NO EXISTE** (fue eliminado al convertir imágenes a webp). Las previews sociales (LinkedIn, WhatsApp, Twitter) están rotas. +- **Sin `<link rel="canonical">`**: sin esto Google puede indexar duplicados (www vs non-www, http vs https). +- **Sin `robots.txt`**: Google no tiene instrucciones de crawl. +- **Sin `sitemap.xml`**: Google no conoce la estructura del sitio. + +--- + +### Imágenes + +#### ✅ Pasando +- `group-crowd.webp` → alt: "Asistentes en evento JavaScript Chile" ✅ +- `group-selfie.webp` → alt: "JSConf Chile — foto grupal" ✅ +- `group-stage.webp` → alt: "Escenario JSConf Chile" ✅ +- Logo en Navbar → alt: "JavaScript Chile" ✅ +- Logo en Footer → alt: "JavaScript Chile" ✅ +- Mapa Chile → alt: "Mapa de Chile" ✅ +- Fotos de galería → alt descriptivo ✅ + +#### ⚠️ Warning +- **Imágenes de staff sin alt en StaffCard**: usan `background-image` CSS en lugar de `<img>`. Google no indexa imágenes de fondo CSS. Las fotos de staff son invisibles para buscadores. + +--- + +## Issues Globales +- `robots.txt` ausente +- `sitemap.xml` ausente +- OG image rota (`/icon.png` eliminado) +- Twitter Card inexistente +- `metadataBase` es string, no URL +- `canonical` ausente + +--- + +## Quick Wins (fix estos primero) +1. ✅ **APLICADO** — Crear `robots.txt` en `/public/` +2. ✅ **APLICADO** — Crear `sitemap.xml` en `/public/` +3. ✅ **APLICADO** — Corregir `metadataBase` a `new URL(...)` +4. ✅ **APLICADO** — Añadir Twitter Card tags +5. ✅ **APLICADO** — Añadir canonical +6. ✅ **APLICADO** — Acortar description a 155 chars +7. ⚠️ **PENDIENTE MANUAL** — Crear imagen OG 1200×630px y reemplazar `/icon.png` en metadata (ver abajo) +8. ⚠️ **PENDIENTE MANUAL** — StaffCard: cambiar background-image CSS por `<img>` con alt text + +--- + +## Imagen OG — Acción requerida + +Se necesita una imagen de **1200×630 px** en formato JPG o PNG para las previews sociales. + +**Opciones:** +- Usar `hero-bg.webp` redimensionada a 1200×630 (conversión manual o con sharp) +- Diseñar una imagen de marca con el logo y claim +- Usar Next.js [opengraph-image.tsx](https://nextjs.org/docs/app/api-reference/file-conventions/opengraph-image) para generarla dinámicamente + +Mientras tanto se ha configurado la URL a `/assets/og-image.jpg` — **debes crear ese archivo**. diff --git a/package-lock.json b/package-lock.json index a214c97..5c232e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", "@mui/material-nextjs": "^7.3.8", + "@phosphor-icons/react": "^2.1.10", "framer-motion": "^12.34.3", "next": "16.1.6", "react": "19.2.3", @@ -29,6 +30,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.6", + "node-ical": "^0.26.1", "typescript": "^5" }, "engines": { @@ -1189,6 +1191,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.3.9", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.9.tgz", @@ -1662,6 +1677,19 @@ "node": ">=12.4.0" } }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -4739,6 +4767,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5093,6 +5128,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-ical": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.26.1.tgz", + "integrity": "sha512-KoYLpsz7Ga9lPDpt9vy0iKcgcb/9Ix7ICRZd0csLXMl2lZOSONGj7HrcktJFR7Jid1l44Zu1H4k/1nB04rWPgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "rrule-temporal": "^1.5.3", + "temporal-polyfill": "^0.3.2" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -5650,6 +5699,16 @@ "node": ">=0.10.0" } }, + "node_modules/rrule-temporal": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.5.3.tgz", + "integrity": "sha512-qALnXyu4MKNUeykkkO0r6Xxl5or3rM8Cf6ibKIe/29sgmq3tGm1oNq4G1Ddp8Ku3mnKmvC3+3yFAJ3OgOu6OJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6200,6 +6259,23 @@ "uuid": "^10.0.0" } }, + "node_modules/temporal-polyfill": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.2.tgz", + "integrity": "sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.1" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.1.tgz", + "integrity": "sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==", + "dev": true, + "license": "ISC" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/package.json b/package.json index 670c14f..9e65f07 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,12 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "fetch-events": "node scripts/fetch-events.mjs", + "dev": "npm run fetch-events && next dev", + "build": "npm run fetch-events && next build", "start": "next start", - "generate": "next build", + "generate": "npm run build", "prestart": "npm run generate", - "predev": "npm run generate", "typecheck": "tsc", "lint": "npx eslint --cache src", "lint:fix": "npx eslint --cache src --fix", @@ -26,6 +26,7 @@ "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", "@mui/material-nextjs": "^7.3.8", + "@phosphor-icons/react": "^2.1.10", "framer-motion": "^12.34.3", "next": "16.1.6", "react": "19.2.3", @@ -40,6 +41,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.6", + "node-ical": "^0.26.1", "typescript": "^5" } } diff --git a/public/assets/Bg-footer.webp b/public/assets/Bg-footer.webp new file mode 100644 index 0000000..408a6e5 Binary files /dev/null and b/public/assets/Bg-footer.webp differ diff --git a/public/assets/OG.png b/public/assets/OG.png new file mode 100644 index 0000000..f160b75 Binary files /dev/null and b/public/assets/OG.png differ diff --git a/public/assets/banner-inivitation-whatsapp.webp b/public/assets/banner-inivitation-whatsapp.webp new file mode 100644 index 0000000..d23e596 Binary files /dev/null and b/public/assets/banner-inivitation-whatsapp.webp differ diff --git a/public/assets/bg-line-time.webp b/public/assets/bg-line-time.webp new file mode 100644 index 0000000..4dc87a5 Binary files /dev/null and b/public/assets/bg-line-time.webp differ diff --git a/public/assets/default.webp b/public/assets/default.webp new file mode 100644 index 0000000..a1b541b Binary files /dev/null and b/public/assets/default.webp differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.01.jpeg b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.01.jpeg deleted file mode 100644 index 8af861c..0000000 Binary files a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.01.jpeg and /dev/null differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.01.webp b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.01.webp new file mode 100644 index 0000000..80f5d92 Binary files /dev/null and b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.01.webp differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.12.jpeg b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.12.jpeg deleted file mode 100644 index 3ac10e6..0000000 Binary files a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.12.jpeg and /dev/null differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.12.webp b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.12.webp new file mode 100644 index 0000000..de932c4 Binary files /dev/null and b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.12.webp differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.15.jpeg b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.15.jpeg deleted file mode 100644 index cb3eb05..0000000 Binary files a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.15.jpeg and /dev/null differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.15.webp b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.15.webp new file mode 100644 index 0000000..df973d6 Binary files /dev/null and b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.15.webp differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.16.jpeg b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.16.jpeg deleted file mode 100644 index 14901c3..0000000 Binary files a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.16.jpeg and /dev/null differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.16.webp b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.16.webp new file mode 100644 index 0000000..203f2ed Binary files /dev/null and b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.16.webp differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.18.jpeg b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.18.jpeg deleted file mode 100644 index 3273a9e..0000000 Binary files a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.18.jpeg and /dev/null differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.18.webp b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.18.webp new file mode 100644 index 0000000..bf68a3c Binary files /dev/null and b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.18.webp differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-07-08.34.59.jpg b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-07-08.34.59.jpg deleted file mode 100644 index 8156deb..0000000 Binary files a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-07-08.34.59.jpg and /dev/null differ diff --git a/public/assets/events/2026/1ra-junta/junta-casual-2026-02-07-08.34.59.webp b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-07-08.34.59.webp new file mode 100644 index 0000000..342571e Binary files /dev/null and b/public/assets/events/2026/1ra-junta/junta-casual-2026-02-07-08.34.59.webp differ diff --git a/public/assets/events/group-crowd.webp b/public/assets/events/group-crowd.webp new file mode 100644 index 0000000..419dbd5 Binary files /dev/null and b/public/assets/events/group-crowd.webp differ diff --git a/public/assets/events/group-selfie.webp b/public/assets/events/group-selfie.webp new file mode 100644 index 0000000..91e7830 Binary files /dev/null and b/public/assets/events/group-selfie.webp differ diff --git a/public/assets/events/group-stage.webp b/public/assets/events/group-stage.webp new file mode 100644 index 0000000..d55d6ef Binary files /dev/null and b/public/assets/events/group-stage.webp differ diff --git a/public/assets/hero-bg.webp b/public/assets/hero-bg.webp new file mode 100644 index 0000000..9d03936 Binary files /dev/null and b/public/assets/hero-bg.webp differ diff --git a/public/assets/javascript-chile-logo.png b/public/assets/javascript-chile-logo.png deleted file mode 100644 index a6a5f2f..0000000 Binary files a/public/assets/javascript-chile-logo.png and /dev/null differ diff --git a/public/assets/js-chile-mapa.svg b/public/assets/js-chile-mapa.svg new file mode 100644 index 0000000..79256ae --- /dev/null +++ b/public/assets/js-chile-mapa.svg @@ -0,0 +1,15 @@ +<svg width="194" height="863" viewBox="0 0 194 863" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_83_660)"> +<path d="M167.266 849.404H115.801V823.665H64.333V810.795H38.5996V772.187H12.8672V759.316H25.7334V746.446H12.8672V733.576H25.7334V694.968H0V669.228H12.8672V656.358H77.2002V669.228H64.333V682.098H51.4668V720.707H77.2002V759.316H141.534V772.187H141.532V785.056H154.399V810.795H154.4V836.534H193.001V849.404H180.133V862.274H167.266V849.404ZM77.2002 656.357H0V630.618H12.8672V617.749H25.7334V604.879H12.8672V592.009H0V579.14H77.2002V656.357ZM77.2002 424.702H64.333V463.312H64.334V514.791H64.333V527.66H51.4668V540.529H64.333V566.27H64.334V579.139H12.8672V566.27H38.5996V553.399H25.7334V540.53H12.8672V527.66H0V514.79H12.8672V463.312H0V424.702H12.8672V411.833H77.2002V424.702ZM77.2002 411.832H12.8672V386.093H25.7334V347.484H77.2002V411.832ZM64.333 321.744H77.2002V347.483H25.7334V308.875H64.333V321.744ZM90.0664 218.786H77.2002V244.525H64.333V270.265H77.2002V283.135H64.333V308.874H25.7334V193.047H90.0664V218.786ZM64.333 25.7393H77.2002V64.3486H90.0654V77.2188H102.933V102.958H128.666V128.698H115.799V141.567H90.0664V180.177H102.934V193.046H25.7334V141.567H38.5996V64.3486H25.7334V12.8701H38.5996V0H64.333V25.7393Z" fill="#1C1C1C"/> +<path d="M167.266 849.404H115.801V823.665H64.333V810.795H38.5996V772.187H12.8672V759.316H25.7334V746.446H12.8672V733.576H25.7334V694.968H0V669.228H12.8672V656.358H77.2002V669.228H64.333V682.098H51.4668V720.707H77.2002V759.316H141.534V772.187H141.532V785.056H154.399V810.795H154.4V836.534H193.001V849.404H180.133V862.274H167.266V849.404ZM77.2002 656.357H0V630.618H12.8672V617.749H25.7334V604.879H12.8672V592.009H0V579.14H77.2002V656.357ZM77.2002 424.702H64.333V463.312H64.334V514.791H64.333V527.66H51.4668V540.529H64.333V566.27H64.334V579.139H12.8672V566.27H38.5996V553.399H25.7334V540.53H12.8672V527.66H0V514.79H12.8672V463.312H0V424.702H12.8672V411.833H77.2002V424.702ZM77.2002 411.832H12.8672V386.093H25.7334V347.484H77.2002V411.832ZM64.333 321.744H77.2002V347.483H25.7334V308.875H64.333V321.744ZM90.0664 218.786H77.2002V244.525H64.333V270.265H77.2002V283.135H64.333V308.874H25.7334V193.047H90.0664V218.786ZM64.333 25.7393H77.2002V64.3486H90.0654V77.2188H102.933V102.958H128.666V128.698H115.799V141.567H90.0664V180.177H102.934V193.046H25.7334V141.567H38.5996V64.3486H25.7334V12.8701H38.5996V0H64.333V25.7393Z" fill="url(#paint0_linear_83_660)"/> +</g> +<defs> +<linearGradient id="paint0_linear_83_660" x1="96.5005" y1="862.274" x2="96.5005" y2="0" gradientUnits="userSpaceOnUse"> +<stop stop-color="#767249"/> +<stop offset="1" stop-color="#C8A108"/> +</linearGradient> +<clipPath id="clip0_83_660"> +<rect width="193.046" height="862.274" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/public/assets/logo-cljs.svg b/public/assets/logo-cljs.svg new file mode 100644 index 0000000..703d72a --- /dev/null +++ b/public/assets/logo-cljs.svg @@ -0,0 +1,14 @@ +<svg width="588" height="635" viewBox="0 0 588 635" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_85_1247)"> +<path d="M569.066 624.8H531.2V605.867H559.601V615.333H588V624.8H578.533V634.267H569.066V624.8ZM483.867 397.6H493.334V407.066H493.333V426H502.8V492.267H493.333V501.733H483.866V511.2H483.867V530.133H502.8V558.533H550.134V568H550.133V577.467H559.6V605.866H493.333V596.399H474.399V568H455.467V558.533H464.934V549.066H455.467V539.6H464.934V511.2H446V492.267H455.467V482.8H446V463.866H455.467V454.4H464.934V444.934H455.467V435.467H446V426H455.467V416.533H474.399V407.066H464.934V397.6H455.467V388.134H483.867V397.6ZM493.333 388.133H446V378.667H455.467V340.8H446V331.334H493.333V388.133ZM493.333 331.333H446V321.867H493.333V331.333ZM502.8 312.4H493.333V321.866H446V312.4H455.467V284H464.934V246.134H502.8V312.4ZM512.267 132.533H521.733V142H512.267V160.934H502.8V179.866H493.334V198.8H502.8V208.267H493.334V236.667H502.8V246.133H464.934V104.134H512.267V132.533ZM502.8 47.333H512.266V56.7998H521.732V75.7334H540.666V94.667H531.199V104.133H474.399V47.333H464.934V37.8672H502.8V47.333ZM493.333 9.4668H493.334V18.9336H502.8V37.8662H464.934V9.4668H474.399V0H493.333V9.4668Z" fill="#1C1C1C"/> +</g> +<path d="M0 531.977H56.0414V545.988H70.0518V559.998H98.0725V545.988H112.083V335.832H168.124V574.008H154.114V602.029H126.093V616.039H42.0311V602.029H14.0104V574.008H0V531.977Z" fill="#1C1C1C"/> +<path d="M210.265 531.977H266.306V545.988H280.317V559.998H336.358V545.988H350.368V517.967H336.358V503.957H252.296V489.946H224.275V461.926H210.265V377.863H224.275V349.843H252.296V335.832H364.379V349.843H392.399V377.863H406.41V419.895H350.368V405.884H336.358V391.874H280.317V405.884H266.306V433.905H280.317V447.915H364.379V461.926H392.399V489.946H406.41V574.008H392.399V602.029H364.379V616.039H252.296V602.029H224.275V574.008H210.265V531.977Z" fill="#1C1C1C"/> +<path d="M105.117 242.067H147.148V228.057H161.159V200.036H217.2V256.078H203.19V284.098H175.169V298.109H77.0965V284.098H49.0758V256.078H35.0654V59.9327H49.0758V31.912H77.0965V17.9017H175.169V31.912H203.19V59.9327H217.2V115.974H161.159V87.9534H147.148V73.9431H105.117V87.9534H91.1068V228.057H105.117V242.067Z" fill="#1C1C1C"/> +<path d="M315.327 242.067H399.389V298.109H259.286V17.9017H315.327V242.067Z" fill="#1C1C1C"/> +<defs> +<clipPath id="clip0_85_1247"> +<rect width="142" height="634.267" fill="white" transform="translate(446)"/> +</clipPath> +</defs> +</svg> diff --git a/public/assets/square-logo-cljs.svg b/public/assets/square-logo-cljs.svg new file mode 100644 index 0000000..b812cf9 --- /dev/null +++ b/public/assets/square-logo-cljs.svg @@ -0,0 +1,81 @@ +<svg width="793" height="793" viewBox="0 0 793 793" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="793" height="793" rx="50" fill="#EBCE17"/> +<g clip-path="url(#clip0_84_1100)"> +<path d="M596.333 79H577.4V88.4667H596.333V79Z" fill="#1C1C1C"/> +<path d="M596.333 88.4667H567.933V97.9333H596.333V88.4667Z" fill="#1C1C1C"/> +<path d="M605.8 97.9332H567.933V107.4H605.8V97.9332Z" fill="#1C1C1C"/> +<path d="M605.8 107.4H567.933V116.867H605.8V107.4Z" fill="#1C1C1C"/> +<path d="M605.8 116.867H567.933V126.333H605.8V116.867Z" fill="#1C1C1C"/> +<path d="M615.267 126.333H577.4V135.8H615.267V126.333Z" fill="#1C1C1C"/> +<path d="M624.733 135.8H577.4V145.267H624.733V135.8Z" fill="#1C1C1C"/> +<path d="M624.733 145.267H577.4V154.733H624.733V145.267Z" fill="#1C1C1C"/> +<path d="M643.667 154.733H577.4V164.2H643.667V154.733Z" fill="#1C1C1C"/> +<path d="M643.667 164.2H577.4V173.667H643.667V164.2Z" fill="#1C1C1C"/> +<path d="M634.2 173.667H577.4V183.133H634.2V173.667Z" fill="#1C1C1C"/> +<path d="M615.266 183.133H567.933V192.6H615.266V183.133Z" fill="#1C1C1C"/> +<path d="M615.266 192.6H567.933V202.067H615.266V192.6Z" fill="#1C1C1C"/> +<path d="M615.266 202.067H567.933V211.533H615.266V202.067Z" fill="#1C1C1C"/> +<path d="M624.733 211.533H567.933V221H624.733V211.533Z" fill="#1C1C1C"/> +<path d="M615.266 221H567.933V230.467H615.266V221Z" fill="#1C1C1C"/> +<path d="M615.266 230.467H567.933V239.933H615.266V230.467Z" fill="#1C1C1C"/> +<path d="M605.8 239.933H567.933V249.4H605.8V239.933Z" fill="#1C1C1C"/> +<path d="M605.8 249.4H567.933V258.867H605.8V249.4Z" fill="#1C1C1C"/> +<path d="M596.333 258.867H567.933V268.333H596.333V258.867Z" fill="#1C1C1C"/> +<path d="M596.333 268.333H567.933V277.8H596.333V268.333Z" fill="#1C1C1C"/> +<path d="M605.8 277.8H567.933V287.266H605.8V277.8Z" fill="#1C1C1C"/> +<path d="M596.333 287.267H567.933V296.733H596.333V287.267Z" fill="#1C1C1C"/> +<path d="M596.333 296.733H567.933V306.2H596.333V296.733Z" fill="#1C1C1C"/> +<path d="M596.333 306.2H567.933V315.667H596.333V306.2Z" fill="#1C1C1C"/> +<path d="M605.8 315.667H567.933V325.133H605.8V315.667Z" fill="#1C1C1C"/> +<path d="M605.8 325.133H567.933V334.6H605.8V325.133Z" fill="#1C1C1C"/> +<path d="M605.8 334.6H567.933V344.067H605.8V334.6Z" fill="#1C1C1C"/> +<path d="M605.8 344.067H567.933V353.533H605.8V344.067Z" fill="#1C1C1C"/> +<path d="M605.8 353.533H567.933V363H605.8V353.533Z" fill="#1C1C1C"/> +<path d="M605.8 363H558.467V372.467H605.8V363Z" fill="#1C1C1C"/> +<path d="M605.8 372.467H558.467V381.933H605.8V372.467Z" fill="#1C1C1C"/> +<path d="M605.8 381.933H558.467V391.4H605.8V381.933Z" fill="#1C1C1C"/> +<path d="M596.333 391.4H549V400.867H596.333V391.4Z" fill="#1C1C1C"/> +<path d="M596.333 400.867H549V410.334H596.333V400.867Z" fill="#1C1C1C"/> +<path d="M596.333 410.333H549V419.8H596.333V410.333Z" fill="#1C1C1C"/> +<path d="M596.333 419.8H558.467V429.266H596.333V419.8Z" fill="#1C1C1C"/> +<path d="M596.333 429.267H558.467V438.733H596.333V429.267Z" fill="#1C1C1C"/> +<path d="M596.333 438.733H558.467V448.2H596.333V438.733Z" fill="#1C1C1C"/> +<path d="M596.333 448.2H558.467V457.667H596.333V448.2Z" fill="#1C1C1C"/> +<path d="M596.333 457.667H549V467.133H596.333V457.667Z" fill="#1C1C1C"/> +<path d="M586.867 467.134H558.467V476.6H586.867V467.134Z" fill="#1C1C1C"/> +<path d="M596.333 476.6H567.933V486.067H596.333V476.6Z" fill="#1C1C1C"/> +<path d="M596.333 486.067H577.4V495.533H596.333V486.067Z" fill="#1C1C1C"/> +<path d="M596.333 495.533H558.467V505H596.333V495.533Z" fill="#1C1C1C"/> +<path d="M605.8 505H549V514.467H605.8V505Z" fill="#1C1C1C"/> +<path d="M605.8 514.467H558.467V523.933H605.8V514.467Z" fill="#1C1C1C"/> +<path d="M605.8 523.933H567.933V533.4H605.8V523.933Z" fill="#1C1C1C"/> +<path d="M605.8 533.4H558.467V542.867H605.8V533.4Z" fill="#1C1C1C"/> +<path d="M605.8 542.867H549V552.333H605.8V542.867Z" fill="#1C1C1C"/> +<path d="M605.8 552.333H549V561.8H605.8V552.333Z" fill="#1C1C1C"/> +<path d="M605.8 561.8H558.467V571.267H605.8V561.8Z" fill="#1C1C1C"/> +<path d="M596.333 571.267H549V580.733H596.333V571.267Z" fill="#1C1C1C"/> +<path d="M586.867 580.733H549V590.2H586.867V580.733Z" fill="#1C1C1C"/> +<path d="M586.866 590.2H567.933V599.667H586.866V590.2Z" fill="#1C1C1C"/> +<path d="M586.866 599.667H567.933V609.133H586.866V599.667Z" fill="#1C1C1C"/> +<path d="M605.8 609.133H567.933V618.6H605.8V609.133Z" fill="#1C1C1C"/> +<path d="M605.8 618.6H558.467V628.067H605.8V618.6Z" fill="#1C1C1C"/> +<path d="M605.8 628.067H567.933V637.533H605.8V628.067Z" fill="#1C1C1C"/> +<path d="M653.133 637.533H558.467V647H653.133V637.533Z" fill="#1C1C1C"/> +<path d="M653.133 647H577.4V656.467H653.133V647Z" fill="#1C1C1C"/> +<path d="M662.6 656.467H577.4V665.933H662.6V656.467Z" fill="#1C1C1C"/> +<path d="M662.6 665.933H577.4V675.4H662.6V665.933Z" fill="#1C1C1C"/> +<path d="M662.6 675.4H596.333V684.867H662.6V675.4Z" fill="#1C1C1C"/> +<path d="M662.6 684.867H634.2V694.334H662.6V684.867Z" fill="#1C1C1C"/> +<path d="M691 694.333H634.2V703.8H691V694.333Z" fill="#1C1C1C"/> +<path d="M681.533 703.8H672.066V713.267H681.533V703.8Z" fill="#1C1C1C"/> +</g> +<path d="M103 610.977H159.041V624.988H173.052V638.998H201.072V624.988H215.083V414.832H271.124V653.008H257.114V681.029H229.093V695.039H145.031V681.029H117.01V653.008H103V610.977Z" fill="#1C1C1C"/> +<path d="M313.265 610.977H369.306V624.988H383.317V638.998H439.358V624.988H453.368V596.967H439.358V582.957H355.296V568.946H327.275V540.926H313.265V456.863H327.275V428.843H355.296V414.832H467.379V428.843H495.399V456.863H509.41V498.895H453.368V484.884H439.358V470.874H383.317V484.884H369.306V512.905H383.317V526.915H467.379V540.926H495.399V568.946H509.41V653.008H495.399V681.029H467.379V695.039H355.296V681.029H327.275V653.008H313.265V610.977Z" fill="#1C1C1C"/> +<path d="M208.117 321.067H250.148V307.057H264.158V279.036H320.2V335.078H306.19V363.098H278.169V377.109H180.096V363.098H152.076V335.078H138.065V138.933H152.076V110.912H180.096V96.9016H278.169V110.912H306.19V138.933H320.2V194.974H264.158V166.953H250.148V152.943H208.117V166.953H194.107V307.057H208.117V321.067Z" fill="#1C1C1C"/> +<path d="M418.327 321.067H502.389V377.109H362.286V96.9016H418.327V321.067Z" fill="#1C1C1C"/> +<defs> +<clipPath id="clip0_84_1100"> +<rect width="142" height="634.267" fill="white" transform="translate(549 79)"/> +</clipPath> +</defs> +</svg> diff --git a/public/assets/staff/staff-armando-rivas-dev.jpg b/public/assets/staff/staff-armando-rivas-dev.jpg deleted file mode 100644 index e396314..0000000 Binary files a/public/assets/staff/staff-armando-rivas-dev.jpg and /dev/null differ diff --git a/public/assets/staff/staff-armando-rivas-dev.webp b/public/assets/staff/staff-armando-rivas-dev.webp new file mode 100644 index 0000000..11a2bab Binary files /dev/null and b/public/assets/staff/staff-armando-rivas-dev.webp differ diff --git a/public/assets/staff/staff-carlos-romero.jpeg b/public/assets/staff/staff-carlos-romero.jpeg deleted file mode 100644 index 80c0513..0000000 Binary files a/public/assets/staff/staff-carlos-romero.jpeg and /dev/null differ diff --git a/public/assets/staff/staff-carlos-romero.webp b/public/assets/staff/staff-carlos-romero.webp new file mode 100644 index 0000000..7875fcf Binary files /dev/null and b/public/assets/staff/staff-carlos-romero.webp differ diff --git a/public/assets/staff/staff-cecilia-geraldo.jpeg b/public/assets/staff/staff-cecilia-geraldo.jpeg deleted file mode 100644 index 7c958f2..0000000 Binary files a/public/assets/staff/staff-cecilia-geraldo.jpeg and /dev/null differ diff --git a/public/assets/staff/staff-cecilia-geraldo.webp b/public/assets/staff/staff-cecilia-geraldo.webp new file mode 100644 index 0000000..c44090c Binary files /dev/null and b/public/assets/staff/staff-cecilia-geraldo.webp differ diff --git a/public/assets/staff/staff-erika-pinedo.webp b/public/assets/staff/staff-erika-pinedo.webp new file mode 100644 index 0000000..a440a01 Binary files /dev/null and b/public/assets/staff/staff-erika-pinedo.webp differ diff --git a/public/assets/staff/staff-javier-vargas.png b/public/assets/staff/staff-javier-vargas.png deleted file mode 100644 index f77ca2d..0000000 Binary files a/public/assets/staff/staff-javier-vargas.png and /dev/null differ diff --git a/public/assets/staff/staff-javier-vargas.webp b/public/assets/staff/staff-javier-vargas.webp new file mode 100644 index 0000000..115e594 Binary files /dev/null and b/public/assets/staff/staff-javier-vargas.webp differ diff --git a/public/assets/staff/staff-mapachito.jpeg b/public/assets/staff/staff-mapachito.jpeg deleted file mode 100644 index 5194f78..0000000 Binary files a/public/assets/staff/staff-mapachito.jpeg and /dev/null differ diff --git a/public/assets/staff/staff-mapachito.webp b/public/assets/staff/staff-mapachito.webp new file mode 100644 index 0000000..882a9ac Binary files /dev/null and b/public/assets/staff/staff-mapachito.webp differ diff --git a/public/assets/staff/staff-samuel-angulo.jpeg b/public/assets/staff/staff-samuel-angulo.jpeg deleted file mode 100644 index 477f61d..0000000 Binary files a/public/assets/staff/staff-samuel-angulo.jpeg and /dev/null differ diff --git a/public/assets/staff/staff-samuel-angulo.webp b/public/assets/staff/staff-samuel-angulo.webp new file mode 100644 index 0000000..0e36828 Binary files /dev/null and b/public/assets/staff/staff-samuel-angulo.webp differ diff --git a/public/events.json b/public/events.json new file mode 100644 index 0000000..856818c --- /dev/null +++ b/public/events.json @@ -0,0 +1,236 @@ +{ + "generated_at": "2026-07-08T01:04:43.838Z", + "upcoming": [], + "past": [ + { + "uid": "evt-i6BhFdLiCz08KXx@events.lu.ma", + "title": "Meetup Invierno JSCHILE + Frontend USACH", + "start": "2026-07-04T13:00:00.000Z", + "end": "2026-07-04T17:00:00.000Z", + "location": null, + "url": "https://luma.com/kf94jzi8", + "cover_url": "https://images.lumacdn.com/uploads/g5/54714ab0-b3d0-4e6b-801d-76913a16b575.png", + "status": "past" + }, + { + "uid": "evt-Nk9yR6T7rdnx5a1@events.lu.ma", + "title": "3era Meetup JS Chile @ Buk", + "start": "2026-05-27T22:30:00.000Z", + "end": "2026-05-28T01:00:00.000Z", + "location": null, + "url": "https://luma.com/d5fh7j0c", + "cover_url": "https://images.lumacdn.com/uploads/1e/fcaefb4b-b5f2-4ca8-88d2-d494f5aa93de.jpg", + "status": "past" + }, + { + "uid": "evt-QODSqbmej6hUZxZ@events.lu.ma", + "title": "2da Meetup 2026", + "start": "2026-04-30T22:30:00.000Z", + "end": "2026-05-01T01:30:00.000Z", + "location": "Los Jesuitas 881, 7500000 Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/bd3njvma", + "cover_url": "https://images.lumacdn.com/event-covers/4q/93e8c36d-a069-4856-981c-6e0c6f566165.jpg", + "status": "past" + }, + { + "uid": "evt-4frzlb8wNTXH4Cf@events.lu.ma", + "title": "1er Meetup 2026", + "start": "2026-03-11T21:30:00.000Z", + "end": "2026-03-12T00:30:00.000Z", + "location": null, + "url": "https://luma.com/rxjqdqdn", + "cover_url": "https://images.lumacdn.com/event-covers/cc/e3a68441-b8f1-4d24-be27-ed392ab23e2e.jpg", + "status": "past" + }, + { + "uid": "evt-NTvDUhExvxoN4Vi@events.lu.ma", + "title": "5to Meetup 2025 JSChile + Seelie", + "start": "2025-07-15T23:00:00.000Z", + "end": "2025-07-16T02:00:00.000Z", + "location": "Seelie Cafeteria, Padre Mariano 205, 7500015 Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/v6rhzit1", + "cover_url": "https://images.lumacdn.com/event-covers/7m/68297e23-c3b5-470f-8197-649660538c97.jpg", + "status": "past" + }, + { + "uid": "evt-ZEMRCvM6FpCFSP0@events.lu.ma", + "title": "3er Meetup JSChile x Fintual", + "start": "2025-03-21T22:00:00.000Z", + "end": "2025-03-22T00:30:00.000Z", + "location": "Av. Providencia 229, 7500768 Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/i01aiz8v", + "cover_url": "https://images.lumacdn.com/event-covers/my/66a52de4-84a2-4476-b8f7-9573f7c2f897.png", + "status": "past" + }, + { + "uid": "evt-pCGUrxcBIigvZKO@events.lu.ma", + "title": "2do Meetup 2025 JSChile + Meli", + "start": "2025-02-20T21:30:00.000Z", + "end": "2025-02-21T00:00:00.000Z", + "location": "Av. Apoquindo 4820, 7560969 Las Condes, Región Metropolitana, Chile", + "url": "https://luma.com/a346a3gd", + "cover_url": "https://images.lumacdn.com/event-covers/j0/2cddaa02-d529-441a-b2a6-5c53075af8a7.png", + "status": "past" + }, + { + "uid": "evt-Bm94xUNx8bGTKJp@events.lu.ma", + "title": "1er Meetup 2025 JSChile + Mach", + "start": "2025-01-15T21:00:00.000Z", + "end": "2025-01-15T23:00:00.000Z", + "location": "Av. Apoquindo 3885, 7550177 Las Condes, Región Metropolitana, Chile", + "url": "https://luma.com/enlyl72s", + "cover_url": "https://images.lumacdn.com/event-covers/li/6a472ee3-2b97-4513-841a-7dfea05dc407", + "status": "past" + }, + { + "uid": "evt-KAvBz5ZwA0Y5t4X@events.lu.ma", + "title": "Watch Party de LA PREVIA JSCONF CL 2024", + "start": "2024-10-12T13:00:00.000Z", + "end": "2024-10-12T21:00:00.000Z", + "location": "Hub Providencia, Los Jesuitas 881, 7501300 Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/6tstu8yu", + "cover_url": "https://images.lumacdn.com/event-covers/3g/55d1b297-fd3e-4ca3-be73-f66158285481", + "status": "past" + }, + { + "uid": "evt-eQXgFVhg6wH9Q53@events.lu.ma", + "title": "Hablemos de liderazgo", + "start": "2024-05-07T22:30:00.000Z", + "end": "2024-05-08T02:00:00.000Z", + "location": "Av. Apoquindo 4800, Las Condes, Región Metropolitana, Chile", + "url": "https://luma.com/hablemos-de-liderazgo", + "cover_url": "https://images.lumacdn.com/event-covers/d0/14e43869-a1a1-479e-a63a-cd697e615eac", + "status": "past" + }, + { + "uid": "evt-fSQuqP1FHNq5oDA@events.lu.ma", + "title": "Meetup Marzo", + "start": "2024-03-26T21:30:00.000Z", + "end": "2024-03-27T01:00:00.000Z", + "location": "El Canelo 2715, Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/marzo-jschile", + "cover_url": "https://images.lumacdn.com/event-covers/jz/c4cab306-efe4-423b-9c91-d830f93b52e4", + "status": "past" + }, + { + "uid": "evt-nQnHAtoSarF7cum@events.lu.ma", + "title": "1er Meetup Concepción", + "start": "2024-01-19T22:00:00.000Z", + "end": "2024-01-20T00:00:00.000Z", + "location": "DUOC UC, Paicaví 3280, Concepción, Bío Bío, Chile", + "url": "https://luma.com/conce-jschile", + "cover_url": "https://images.lumacdn.com/event-covers/79/61cdeffe-dbef-4bd0-966a-c4380b21f109", + "status": "past" + }, + { + "uid": "evt-PTPvFP4jilTBmnI@events.lu.ma", + "title": "TechSchool V2.0.0 2023", + "start": "2023-12-16T13:00:00.000Z", + "end": "2023-12-16T19:30:00.000Z", + "location": "Hub Providencia, Los Jesuitas 881, 7501300 Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/techschool-v2", + "cover_url": "https://images.lumacdn.com/event-covers/in/4c170257-feda-4648-89de-c5b26e7753a3", + "status": "past" + }, + { + "uid": "evt-XLBVcuBakwBMN9d@events.lu.ma", + "title": "Meetup Noviembre", + "start": "2023-11-21T22:00:00.000Z", + "end": "2023-11-22T01:00:00.000Z", + "location": "Mercado Urbano Tobalaba, Av. Apoquindo 2730, 7550000 Las Condes, Región Metropolitana, Chile", + "url": "https://luma.com/meetup-noviembre", + "cover_url": "https://images.lumacdn.com/event-covers/ee/203edf20-e27b-4cc0-b362-5ffca0ee8428", + "status": "past" + }, + { + "uid": "evt-hS7S16zgCMIgtxB@events.lu.ma", + "title": "Sesión de Inglés: Lectura de Noticias", + "start": "2023-11-10T23:00:00.000Z", + "end": "2023-11-11T01:00:00.000Z", + "location": null, + "url": "https://luma.com/noticias-en-ingles", + "cover_url": "https://images.lumacdn.com/event-covers/ra/3007a70d-994d-48e8-ab8c-50cad635e7d7", + "status": "past" + }, + { + "uid": "evt-qJByauqbyaV4nwn@events.lu.ma", + "title": "TechSchool V1.0.0 2023", + "start": "2023-10-28T13:00:00.000Z", + "end": "2023-10-28T19:30:00.000Z", + "location": "Hub Providencia, Los Jesuitas 881, 7501300 Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/TechSchoolv1.0.0-2023", + "cover_url": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/8m/f9704bcf-03a7-430a-bf90-9b1ce7f7fa88", + "status": "past" + }, + { + "uid": "evt-S7pzB8Mo2GKumSb@events.lu.ma", + "title": "Oktober Meetup", + "start": "2023-10-25T22:00:00.000Z", + "end": "2023-10-26T01:30:00.000Z", + "location": "Av. Apoquindo 4800, Las Condes, Región Metropolitana, Chile", + "url": "https://luma.com/jfjvsheu", + "cover_url": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/2d/c2dfe8f1-d70b-41fe-bd8a-942ecb98304e", + "status": "past" + }, + { + "uid": "evt-trHhNbkVxzgoQW1@events.lu.ma", + "title": "TikiTikiTi!!! La Fondita Tech", + "start": "2023-09-07T22:30:00.000Z", + "end": "2023-09-08T01:30:00.000Z", + "location": "Mercado Urbano Tobalaba, Av. Apoquindo 2730, 7550000 Las Condes, Región Metropolitana, Chile", + "url": "https://luma.com/fondita-tech", + "cover_url": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/2d/4d39ad5e-1de5-49ba-80b3-1820ffc4f3b9", + "status": "past" + }, + { + "uid": "evt-Fnhdu6W0HXYVvND@events.lu.ma", + "title": "NodeSchool Santiago: Agosto 2023", + "start": "2023-08-12T14:00:00.000Z", + "end": "2023-08-12T22:00:00.000Z", + "location": "El Canelo 2715, Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/nodeschool-santiago-agosto-2023", + "cover_url": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/80/c04aa548-1f0b-40ba-951e-7541eb194546", + "status": "past" + }, + { + "uid": "evt-okboIKx8c42NlJO@events.lu.ma", + "title": "Meetup M8 + Javascript Chile", + "start": "2023-07-19T23:00:00.000Z", + "end": "2023-07-20T02:00:00.000Z", + "location": "El Canelo 2715, Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/M8-JavaScriptChile-Julio2023", + "cover_url": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/qb/1388d1f1-1339-430f-a5f4-bef811f8373e", + "status": "past" + }, + { + "uid": "evt-Nh36XmbP1r3BpNn@events.lu.ma", + "title": "Status 418: I'm a Teapot", + "start": "2023-07-19T00:30:00.000Z", + "end": "2023-07-19T02:30:00.000Z", + "location": null, + "url": "https://luma.com/status-418-julio-2023", + "cover_url": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/3b/d9b492d5-9a5f-42e8-a5ea-c4572921e7bd", + "status": "past" + }, + { + "uid": "evt-UZpob2DeZj28pQc@events.lu.ma", + "title": "MeetUp Javascript Chile", + "start": "2023-05-25T23:00:00.000Z", + "end": "2023-05-26T02:00:00.000Z", + "location": "Hub Providencia - Infante 1415, José Manuel Infante 1415, 7501300 Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/javascript-chile-mayo-2023", + "cover_url": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/r7/04a11821-dc73-499a-a933-ea519db6d4aa", + "status": "past" + }, + { + "uid": "evt-0iJTh3sbU3PWy49@events.lu.ma", + "title": "NodeSchool Santiago: Abril 2023", + "start": "2023-04-22T14:00:00.000Z", + "end": "2023-04-22T21:30:00.000Z", + "location": "Hub Providencia - Infante 1415, José Manuel Infante 1415, 7501300 Providencia, Región Metropolitana, Chile", + "url": "https://luma.com/nodeschool-santiago-abril-2023", + "cover_url": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/tn/64f1f053-6eda-4d46-8a78-12f36dfedf42", + "status": "past" + } + ] +} \ No newline at end of file diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..946b376 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://jschile.org/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..bbe8e9a --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <loc>https://jschile.org/</loc> + <lastmod>2026-07-08</lastmod> + <changefreq>monthly</changefreq> + <priority>1.0</priority> + </url> +</urlset> diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> \ No newline at end of file diff --git a/scripts/fetch-events.mjs b/scripts/fetch-events.mjs new file mode 100644 index 0000000..5a7071d --- /dev/null +++ b/scripts/fetch-events.mjs @@ -0,0 +1,212 @@ +/** + * Build script: fetches the Luma iCal feed, scrapes og:image covers, + * and writes public/events.json ready for the Next.js static export. + * + * Run: node scripts/fetch-events.mjs + * Env: none required — uses the public iCal feed only. + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import ical from 'node-ical'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const CACHE_FILE = join(__dirname, 'og-cache.json'); +const OUTPUT_FILE = join(ROOT, 'public', 'events.json'); +const ICAL_URL = 'https://api.lu.ma/ics/get?entity=calendar&id=cal-k2TNsuCIp2uuGla'; + +// ────────────────────────────────────────────── +// Cache (persists og:image lookups across runs) +// ────────────────────────────────────────────── + +async function loadCache() { + if (existsSync(CACHE_FILE)) { + return JSON.parse(await readFile(CACHE_FILE, 'utf-8')); + } + return {}; +} + +async function saveCache(cache) { + await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2)); +} + +// ────────────────────────────────────────────── +// og:image scraping +// ────────────────────────────────────────────── + +async function fetchOgImage(url, cache) { + if (url in cache) return cache[url]; + + try { + const res = await fetch(url, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; JSChile-EventBot/1.0)' }, + signal: AbortSignal.timeout(12_000), + }); + const html = await res.text(); + + // og:image may appear as `content="..."` or `content='...'` + const match = html.match(/property=["']og:image["'][^>]+content=["']([^"']+)["']/); + if (!match) { + cache[url] = null; + return null; + } + + const raw = match[1].replace(/&/g, '&'); + + // Luma CDN OG image: extract the real event cover from the `img=` param + if (raw.includes('og.luma.com')) { + try { + const parsed = new URL(raw); + const imgParam = parsed.searchParams.get('img'); + if (imgParam) { + cache[url] = imgParam; + return imgParam; + } + } catch { /* fall through to raw */ } + } + + // Generic default social image → no cover + if (raw.includes('/social-images/')) { + cache[url] = null; + return null; + } + + cache[url] = raw; + return raw; + } catch (err) { + console.warn(` ⚠ og:image fetch failed for ${url}: ${err.message}`); + cache[url] = null; + return null; + } +} + +// ────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────── + +function extractUrlFromDescription(description) { + if (!description) return null; + // description may have literal '\n' or real newlines depending on node-ical version + const clean = description.replace(/\\n/g, '\n'); + const match = clean.match(/https:\/\/luma\.com\/([^\s\n\\]+)/); + if (!match) return null; + return `https://luma.com/${match[1]}`; +} + +function classifyEvent(start, end, now) { + if (end < now) return 'past'; + if (start <= now && now <= end) return 'ongoing'; + return 'upcoming'; +} + +function isCancelled(summary) { + return /\[CANCELADO\]/i.test(summary ?? ''); +} + +// ────────────────────────────────────────────── +// Main +// ────────────────────────────────────────────── + +async function main() { + console.log('📅 Fetching Luma iCal feed…'); + const res = await fetch(ICAL_URL, { signal: AbortSignal.timeout(20_000) }); + const text = await res.text(); + + const parsed = ical.parseICS(text); + const cache = await loadCache(); + const now = new Date(); + + const events = []; + const vevents = Object.values(parsed).filter(e => e.type === 'VEVENT'); + + console.log(` Found ${vevents.length} VEVENTs. Scraping og:image covers…`); + + for (const ev of vevents) { + const summary = (ev.summary ?? '').trim(); + + if (isCancelled(summary)) continue; + + const start = ev.start instanceof Date ? ev.start : new Date(ev.start); + const end = ev.end instanceof Date ? ev.end : new Date(ev.end); + const status = classifyEvent(start, end, now); + + const url = extractUrlFromDescription(ev.description); + + // Skip events with no real URL (shouldn't happen in practice) + const eventUrl = url ?? `https://lu.ma/jschile`; + + let cover_url = null; + if (url) { + process.stdout.write(` → ${summary.slice(0, 50)}`); + cover_url = await fetchOgImage(url, cache); + console.log(cover_url ? ' ✓' : ' (no cover)'); + } + + // Don't treat location as address when Luma fills it with their own URL + const location = + ev.location && !ev.location.startsWith('https://luma.com') + ? ev.location + : null; + + events.push({ + uid: ev.uid ?? `${summary}-${start.toISOString()}`, + title: summary, + start: start.toISOString(), + end: end.toISOString(), + location, + url: eventUrl, + cover_url, + status, + }); + } + + await saveCache(cache); + + const upcoming = events + .filter(e => e.status === 'upcoming' || e.status === 'ongoing') + .sort((a, b) => new Date(a.start) - new Date(b.start)); + + const past = events + .filter(e => e.status === 'past') + .sort((a, b) => new Date(b.start) - new Date(a.start)); + + // Only write if events actually changed (ignore generated_at drift) + const newHash = createHash('sha256') + .update(JSON.stringify({ upcoming, past })) + .digest('hex'); + + let existingHash = ''; + if (existsSync(OUTPUT_FILE)) { + try { + const existing = JSON.parse(await readFile(OUTPUT_FILE, 'utf-8')); + existingHash = createHash('sha256') + .update(JSON.stringify({ upcoming: existing.upcoming, past: existing.past })) + .digest('hex'); + } catch { /* corrupt file — will overwrite */ } + } + + if (newHash === existingHash) { + console.log(`\n✅ No changes detected — events.json unchanged.`); + console.log(` ${upcoming.length} próximos / ${past.length} pasados`); + return; + } + + await writeFile(OUTPUT_FILE, JSON.stringify({ + generated_at: now.toISOString(), + upcoming, + past, + }, null, 2)); + + console.log(`\n✅ events.json updated:`); + console.log(` ${upcoming.length} próximos / ${past.length} pasados`); + console.log(` → ${OUTPUT_FILE}`); +} + +main().catch(err => { + console.error('❌ fetch-events failed:', err); + process.exit(1); +}); diff --git a/scripts/og-cache.json b/scripts/og-cache.json new file mode 100644 index 0000000..717b943 --- /dev/null +++ b/scripts/og-cache.json @@ -0,0 +1,25 @@ +{ + "https://luma.com/nodeschool-santiago-abril-2023": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/tn/64f1f053-6eda-4d46-8a78-12f36dfedf42", + "https://luma.com/javascript-chile-mayo-2023": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/r7/04a11821-dc73-499a-a933-ea519db6d4aa", + "https://luma.com/status-418-julio-2023": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/3b/d9b492d5-9a5f-42e8-a5ea-c4572921e7bd", + "https://luma.com/M8-JavaScriptChile-Julio2023": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/qb/1388d1f1-1339-430f-a5f4-bef811f8373e", + "https://luma.com/nodeschool-santiago-agosto-2023": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/80/c04aa548-1f0b-40ba-951e-7541eb194546", + "https://luma.com/fondita-tech": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/2d/4d39ad5e-1de5-49ba-80b3-1820ffc4f3b9", + "https://luma.com/jfjvsheu": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/2d/c2dfe8f1-d70b-41fe-bd8a-942ecb98304e", + "https://luma.com/TechSchoolv1.0.0-2023": "https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=1,anim=false,background=white,quality=75,width=1000,height=500/event-covers/8m/f9704bcf-03a7-430a-bf90-9b1ce7f7fa88", + "https://luma.com/noticias-en-ingles": "https://images.lumacdn.com/event-covers/ra/3007a70d-994d-48e8-ab8c-50cad635e7d7", + "https://luma.com/meetup-noviembre": "https://images.lumacdn.com/event-covers/ee/203edf20-e27b-4cc0-b362-5ffca0ee8428", + "https://luma.com/techschool-v2": "https://images.lumacdn.com/event-covers/in/4c170257-feda-4648-89de-c5b26e7753a3", + "https://luma.com/conce-jschile": "https://images.lumacdn.com/event-covers/79/61cdeffe-dbef-4bd0-966a-c4380b21f109", + "https://luma.com/marzo-jschile": "https://images.lumacdn.com/event-covers/jz/c4cab306-efe4-423b-9c91-d830f93b52e4", + "https://luma.com/hablemos-de-liderazgo": "https://images.lumacdn.com/event-covers/d0/14e43869-a1a1-479e-a63a-cd697e615eac", + "https://luma.com/6tstu8yu": "https://images.lumacdn.com/event-covers/3g/55d1b297-fd3e-4ca3-be73-f66158285481", + "https://luma.com/enlyl72s": "https://images.lumacdn.com/event-covers/li/6a472ee3-2b97-4513-841a-7dfea05dc407", + "https://luma.com/a346a3gd": "https://images.lumacdn.com/event-covers/j0/2cddaa02-d529-441a-b2a6-5c53075af8a7.png", + "https://luma.com/i01aiz8v": "https://images.lumacdn.com/event-covers/my/66a52de4-84a2-4476-b8f7-9573f7c2f897.png", + "https://luma.com/v6rhzit1": "https://images.lumacdn.com/event-covers/7m/68297e23-c3b5-470f-8197-649660538c97.jpg", + "https://luma.com/rxjqdqdn": "https://images.lumacdn.com/event-covers/cc/e3a68441-b8f1-4d24-be27-ed392ab23e2e.jpg", + "https://luma.com/bd3njvma": "https://images.lumacdn.com/event-covers/4q/93e8c36d-a069-4856-981c-6e0c6f566165.jpg", + "https://luma.com/d5fh7j0c": "https://images.lumacdn.com/uploads/1e/fcaefb4b-b5f2-4ca8-88d2-d494f5aa93de.jpg", + "https://luma.com/kf94jzi8": "https://images.lumacdn.com/uploads/g5/54714ab0-b3d0-4e6b-801d-76913a16b575.png" +} \ No newline at end of file diff --git a/src/app/android-chrome-192x192.png b/src/app/android-chrome-192x192.png new file mode 100644 index 0000000..1086a8a Binary files /dev/null and b/src/app/android-chrome-192x192.png differ diff --git a/src/app/android-chrome-512x512.png b/src/app/android-chrome-512x512.png new file mode 100644 index 0000000..0fd0ddb Binary files /dev/null and b/src/app/android-chrome-512x512.png differ diff --git a/src/app/apple-touch-icon.png b/src/app/apple-touch-icon.png new file mode 100644 index 0000000..9d72953 Binary files /dev/null and b/src/app/apple-touch-icon.png differ diff --git a/src/app/favicon-16x16.png b/src/app/favicon-16x16.png new file mode 100644 index 0000000..7580858 Binary files /dev/null and b/src/app/favicon-16x16.png differ diff --git a/src/app/favicon-32x32.png b/src/app/favicon-32x32.png new file mode 100644 index 0000000..a37d453 Binary files /dev/null and b/src/app/favicon-32x32.png differ diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..2c1eabe Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/icon.png b/src/app/icon.png deleted file mode 100644 index a6a5f2f..0000000 Binary files a/src/app/icon.png and /dev/null differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e07ef0e..2b2f5d0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,30 +5,73 @@ import MuiRootProvider from '@/providers/MuiRootProvider'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { - title: 'JavaScript Chile | Comunidad de Desarrolladores', + title: 'JSChile | Comunidad de Desarrolladores JavaScript en Chile', description: - 'Somos una comunidad de desarrolladores apasionados por la tecnología. Organizamos meetups, workshops e iniciativas para compartir conocimiento, conectar profesionales y construir el futuro del desarrollo en Chile.', - metadataBase: 'https://jschile.org/', + 'JSChile: comunidad de devs en Chile desde 2013. Meetups, workshops y conferencias para conectar, aprender y crecer. +120 eventos, +4.800 asistentes, +200 speakers. ¡Únete gratis!', + applicationName: 'JSChile', + keywords: [ + 'javascript chile', + 'comunidad desarrolladores chile', + 'meetup javascript santiago', + 'jsconf chile', + 'programación chile', + 'frontend chile', + 'nodejs chile', + 'react chile', + 'developers community', + ], + metadataBase: new URL('https://jschile.org'), + alternates: { + canonical: 'https://jschile.org/', + }, icons: { - icon: '/icon.png', + icon: '/favicon.ico', }, openGraph: { - title: 'JavaScript Chile | Comunidad de Desarrolladores', + title: 'JSChile | Comunidad de Desarrolladores JavaScript en Chile', description: - 'Somos una comunidad de desarrolladores apasionados por la tecnología. Organizamos meetups, workshops e iniciativas para compartir conocimiento, conectar profesionales y construir el futuro del desarrollo en Chile.', + 'La comunidad de devs JavaScript más grande de Chile. Desde 2013: +120 eventos, +4.800 asistentes, +200 speakers. ¡Únete gratis!', url: 'https://jschile.org/', - siteName: 'JavaScript Chile', + siteName: 'JSChile', locale: 'es_CL', type: 'website', images: [ { - url: '/icon.png', + url: '/assets/OG.png', width: 1200, height: 630, - alt: 'JavaScript Chile', + alt: 'JSChile — Comunidad de desarrolladores JavaScript desde 2013', }, ], }, + twitter: { + card: 'summary_large_image', + title: 'JSChile | Comunidad de Desarrolladores JavaScript en Chile', + description: + 'La comunidad de devs JavaScript más grande de Chile. Desde 2013: +120 eventos, +4.800 asistentes, +200 speakers. ¡Únete gratis!', + images: ['/assets/OG.png'], + }, +}; + +const jsonLd = { + '@context': 'https://schema.org', + '@type': 'Organization', + name: 'JSChile', + url: 'https://jschile.org', + logo: 'https://jschile.org/assets/logo-cljs.svg', + foundingDate: '2013', + description: + 'Comunidad de desarrolladores JavaScript en Chile. Organizamos meetups, workshops y conferencias desde 2013.', + sameAs: [ + 'https://www.instagram.com/javascriptchile/', + 'https://www.linkedin.com/company/jschile/', + 'https://github.com/jschile', + ], + location: { + '@type': 'Place', + name: 'Chile', + address: { '@type': 'PostalAddress', addressCountry: 'CL' }, + }, }; export default function RootLayout({ @@ -38,7 +81,23 @@ export default function RootLayout({ }>) { return ( <html lang='es'> + <head> + <link rel='preconnect' href='https://fonts.googleapis.com' /> + <link rel='preconnect' href='https://fonts.gstatic.com' crossOrigin='anonymous' /> + <script + type='application/ld+json' + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + /> + </head> <body className={inter.className}> + {/* WCAG AA: skip link — visible solo con foco de teclado via CSS */} + <style>{` + .skip-link { position:absolute; top:-40px; left:0; background:#1D1D1D; color:#FFFFFF; padding:8px 16px; z-index:9999; font-weight:700; text-decoration:none; border-radius:0 0 4px 0; } + .skip-link:focus { top:0; } + `}</style> + <a href='#main-content' className='skip-link'> + Saltar al contenido principal + </a> <MuiRootProvider>{children}</MuiRootProvider> </body> </html> diff --git a/src/app/page.tsx b/src/app/page.tsx index 04f80fd..7a09f9d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,13 +1,50 @@ +import { COLORS } from '@/lib/tokens'; import StaffContainer from '@/sections/staff/StaffContainer'; +import { JoinCTA } from '@/sections/JoinCTA'; import { HeroContainer } from '@/sections/hero/HeroContainer'; -import { CardsContainer } from '@/sections/cards/CardsContainer'; +import { StatsSection } from '@/sections/StatsSection'; +import { WhatsAppCTA } from '@/sections/WhatsAppCTA'; +import { NextEvent } from '@/sections/NextEvent'; +import { PastEventsCarousel } from '@/sections/PastEventsCarousel'; +import { HistoriaTimeline } from '@/sections/HistoriaTimeline'; +import { NuestrosValores } from '@/sections/NuestrosValores'; +import { BrandBanner } from '@/sections/BrandBanner'; +import { SpeakersSectionWrapper } from '@/sections/SpeakersSectionWrapper'; +import Box from '@mui/material/Box'; export default function Home() { return ( - <main> - <HeroContainer /> - <CardsContainer /> - <StaffContainer /> + <main id='main-content'> + <Box + sx={{ + backgroundImage: { + xs: `linear-gradient(to bottom, transparent 45%, ${COLORS.black} 65%), url(/assets/hero-bg.webp)`, + md: `linear-gradient(to bottom, transparent 75%, ${COLORS.black} 75%), url(/assets/hero-bg.webp)`, + }, + backgroundSize: 'cover', + backgroundPosition: 'center top', + backgroundRepeat: 'no-repeat', + }} + > + <HeroContainer /> + <StatsSection /> + </Box> + <WhatsAppCTA /> + <div id='eventos'> + <NextEvent /> + <PastEventsCarousel /> + </div> + <HistoriaTimeline /> + <NuestrosValores /> + <BrandBanner /> + {/* Sections below scroll over the sticky BrandBanner */} + <div style={{ position: 'relative', zIndex: 1 }}> + <SpeakersSectionWrapper /> + <div style={{ backgroundColor: COLORS.darkOlive }}> + <StaffContainer /> + </div> + <JoinCTA /> + </div> </main> ); } diff --git a/src/components/AnimatedButton.tsx b/src/components/AnimatedButton.tsx index f8a6c66..0b87476 100644 --- a/src/components/AnimatedButton.tsx +++ b/src/components/AnimatedButton.tsx @@ -1,4 +1,5 @@ 'use client'; +import { COLORS } from '@/lib/tokens'; import * as React from 'react'; import Button, { ButtonProps } from '@mui/material/Button'; @@ -6,72 +7,80 @@ import { motion } from 'framer-motion'; type AnimatedButtonProps = ButtonProps & { hoverColor?: string; + hoverTextColor?: string; target?: string; rel?: string; }; export const AnimatedButton = ({ children, - hoverColor = '#F0DB4F', + hoverColor = COLORS.yellow, + hoverTextColor, sx, onMouseEnter, onMouseLeave, onFocus, onBlur, disabled, + fullWidth, ...props }: AnimatedButtonProps) => { const [isActive, setIsActive] = React.useState(false); return ( - <Button - {...props} - disabled={disabled} - onMouseEnter={(event) => { - if (!disabled) setIsActive(true); - onMouseEnter?.(event); - }} - onMouseLeave={(event) => { - setIsActive(false); - onMouseLeave?.(event); - }} - onFocus={(event) => { - if (!disabled) setIsActive(true); - onFocus?.(event); - }} - onBlur={(event) => { - setIsActive(false); - onBlur?.(event); - }} - sx={[ - { - position: 'relative', - overflow: 'hidden', - isolation: 'isolate', - transition: 'color 0.25s ease', - border: '1px solid transparent', - '&:hover': { - color: '#000000', - border: '1px solid #000000', - }, - }, - ...(Array.isArray(sx) ? sx : [sx]), - ]} + <motion.div + whileHover={!disabled ? { scale: 1.04 } : undefined} + whileTap={!disabled ? { scale: 0.97 } : undefined} + style={fullWidth ? { display: 'flex', width: '100%' } : { display: 'inline-flex' }} > - <motion.span - aria-hidden - animate={{ scaleX: isActive ? 1 : 0 }} - transition={{ duration: 0.35, ease: 'easeOut' }} - style={{ - position: 'absolute', - inset: 0, - backgroundColor: hoverColor, - transformOrigin: 'left center', - zIndex: 0, - pointerEvents: 'none', + <Button + {...props} + disabled={disabled} + fullWidth={fullWidth} + onMouseEnter={(event) => { + if (!disabled) setIsActive(true); + onMouseEnter?.(event); + }} + onMouseLeave={(event) => { + setIsActive(false); + onMouseLeave?.(event); + }} + onFocus={(event) => { + if (!disabled) setIsActive(true); + onFocus?.(event); }} - /> - <span style={{ position: 'relative', zIndex: 1 }}>{children}</span> - </Button> + onBlur={(event) => { + setIsActive(false); + onBlur?.(event); + }} + sx={[ + { + position: 'relative', + overflow: 'hidden', + isolation: 'isolate', + transition: 'color 0.25s ease', + ...(hoverTextColor && { + '&:hover': { color: hoverTextColor }, + }), + }, + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + <motion.span + aria-hidden + animate={{ scaleX: isActive ? 1 : 0 }} + transition={{ duration: 0.35, ease: 'easeOut' }} + style={{ + position: 'absolute', + inset: 0, + backgroundColor: hoverColor, + transformOrigin: 'left center', + zIndex: 0, + pointerEvents: 'none', + }} + /> + <span style={{ position: 'relative', zIndex: 1 }}>{children}</span> + </Button> + </motion.div> ); }; diff --git a/src/components/CommunityJoinModal.tsx b/src/components/CommunityJoinModal.tsx index 11eb39d..84315eb 100644 --- a/src/components/CommunityJoinModal.tsx +++ b/src/components/CommunityJoinModal.tsx @@ -1,15 +1,16 @@ 'use client'; +import { jersey10 } from '@/lib/fonts'; +import { COLORS } from '@/lib/tokens'; import React, { useState } from 'react'; import Modal from '@mui/material/Modal'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; import FormControlLabel from '@mui/material/FormControlLabel'; import Checkbox from '@mui/material/Checkbox'; import { AnimatedButton } from './AnimatedButton'; -import WhatsAppIcon from '@mui/icons-material/WhatsApp'; +import { WhatsappLogo, X } from '@phosphor-icons/react'; import { Stack } from '@mui/material'; interface CommunityJoinModalProps { @@ -17,27 +18,7 @@ interface CommunityJoinModalProps { onClose: () => void; } -const modalStyle = { - position: 'absolute' as const, - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: { xs: '95%', sm: 500, md: 650 }, - maxHeight: '90vh', - overflowY: 'auto', - bgcolor: 'background.paper', - borderRadius: 4, - boxShadow: 24, - p: { xs: 4, sm: 6 }, - display: 'flex', - flexDirection: 'column', - outline: 'none', -}; - -export const CommunityJoinModal: React.FC<CommunityJoinModalProps> = ({ - open, - onClose, -}) => { +export const CommunityJoinModal: React.FC<CommunityJoinModalProps> = ({ open, onClose }) => { const [accepted, setAccepted] = useState(false); return ( @@ -48,97 +29,104 @@ export const CommunityJoinModal: React.FC<CommunityJoinModalProps> = ({ aria-describedby='community-join-modal-description' disableScrollLock > - <Box sx={modalStyle}> + <Box + sx={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: { xs: '95%', sm: 500, md: 600 }, + maxHeight: '90vh', + overflowY: 'auto', + bgcolor: COLORS.darkOlive, + borderRadius: '24px', + boxShadow: 24, + p: { xs: 3, md: 5 }, + display: 'flex', + flexDirection: 'column', + outline: 'none', + }} + > <IconButton onClick={onClose} - sx={{ position: 'absolute', right: 16, top: 16 }} aria-label='Cerrar modal' + sx={{ + position: 'absolute', + right: 16, + top: 16, + color: '#fff', + bgcolor: 'rgba(255,255,255,0.1)', + '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, + }} > - <CloseIcon /> + <X size={20} weight='fill' /> </IconButton> - <Stack - direction='row' - alignItems='center' - justifyContent='flex-start' - spacing={1.5} - sx={{ mb: 2 }} - > - <WhatsAppIcon sx={{ color: 'primary.main', fontSize: '2.5rem' }} /> - <Typography variant='h5' component='h2' sx={{ pr: 3 }}> - Únete a nuestra Comunidad + <Stack direction='row' alignItems='center' spacing={1.5} sx={{ mb: 1, pr: 4 }}> + <WhatsappLogo size={36} weight='fill' color={COLORS.yellow} aria-hidden /> + <Typography + id='community-join-modal-title' + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '1.8rem', md: '2.4rem' }, + color: COLORS.yellow, + lineHeight: 1, + }} + > + Únete a la Comunidad </Typography> </Stack> <Typography - variant='body2' - color='text.secondary' - sx={{ mb: 4, fontWeight: 500 }} + sx={{ + fontFamily: 'Inter', + fontSize: '0.95rem', + color: 'rgba(255,255,255,0.6)', + mb: 3, + }} > - Bienvenido al grupo de WhatsApp de JavaScript Chile + Bienvenido al grupo de WhatsApp de JSChile </Typography> + {/* Resumen código de conducta */} <Box id='community-join-modal-description' sx={{ - backgroundColor: '#F5F5F5', - borderRadius: 2, + bgcolor: 'rgba(255,255,255,0.06)', + borderRadius: '16px', p: 3, - mb: 4, + mb: 3, }} > - <Typography variant='h6' fontWeight='bold' sx={{ fontSize: '1rem' }}> - Resumen del Código de Conducta - </Typography> - - <Box + <Typography sx={{ - display: 'flex', - flexDirection: 'column', - gap: 2, - mt: 2, + fontFamily: 'Inter', + fontWeight: 700, + fontSize: '0.95rem', + color: '#fff', + mb: 2, }} > - <Typography variant='body2' sx={{ lineHeight: 1.6 }}> - <strong>Nuestra Comunidad es Inclusiva </strong> - <br /> - Todos son bienvenidos sin importar género, raza, orientación - sexual, capacidades o creencias. - </Typography> - - <Typography variant='body2' sx={{ lineHeight: 1.6 }}> - <strong>Cero Tolerancia al Acoso </strong> - <br /> - No toleramos abuso físico/verbal, intimidación, acecho, ni - contenido inapropiado de ninguna forma o contexto. - </Typography> - - <Typography variant='body2' sx={{ lineHeight: 1.6 }}> - <strong>Respeto y Profesionalismo </strong> - <br /> - Mantén un tono respetuoso. Cualquier violación resultará en - expulsión del grupo y futuros eventos. - </Typography> + Resumen del Código de Conducta + </Typography> - <Typography variant='body2' sx={{ lineHeight: 1.6 }}> - <strong>Reporta Problemas </strong> - <br /> - Si eres víctima o testigo de acoso, contacta inmediatamente a los - moderadores. - </Typography> + <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> + {[ + { title: 'Comunidad Inclusiva', body: 'Todos son bienvenidos sin importar género, raza, orientación sexual, capacidades o creencias.' }, + { title: 'Cero Tolerancia al Acoso', body: 'No toleramos abuso físico/verbal, intimidación, acecho, ni contenido inapropiado.' }, + { title: 'Respeto y Profesionalismo', body: 'Mantén un tono respetuoso. Cualquier violación resultará en expulsión del grupo y futuros eventos.' }, + { title: 'Reporta Problemas', body: 'Si eres víctima o testigo de acoso, contacta inmediatamente a los moderadores.' }, + ].map(({ title, body }) => ( + <Typography key={title} sx={{ fontFamily: 'Inter', fontSize: '0.85rem', color: 'rgba(255,255,255,0.75)', lineHeight: 1.6 }}> + <Box component='strong' sx={{ color: '#fff' }}>{title} </Box> + <br />{body} + </Typography> + ))} - <Typography - variant='body2' - sx={{ - lineHeight: 1.6, - fontStyle: 'italic', - color: '#666', - mt: 2, - }} - > - Somos una comunidad inclusiva que valora la diversidad. Si no - estás de acuerdo con estos valores, probablemente no sea el lugar - para ti + <Typography sx={{ fontFamily: 'Inter', fontSize: '0.82rem', fontStyle: 'italic', color: 'rgba(255,255,255,0.4)', mt: 1 }}> + Somos una comunidad inclusiva que valora la diversidad. Si no estás de acuerdo con estos valores, probablemente no sea el lugar para ti. </Typography> </Box> </Box> @@ -148,12 +136,15 @@ export const CommunityJoinModal: React.FC<CommunityJoinModalProps> = ({ <Checkbox checked={accepted} onChange={(e) => setAccepted(e.target.checked)} - color='primary' + sx={{ + color: 'rgba(255,255,255,0.4)', + '&.Mui-checked': { color: COLORS.yellow }, + }} /> } label={ - <Typography variant='body2' sx={{ fontWeight: 600 }}> - Acepto el Código de Conducta de JavaScript Chile + <Typography sx={{ fontFamily: 'Inter', fontWeight: 600, fontSize: '0.9rem', color: '#fff' }}> + Acepto el Código de Conducta de JSChile </Typography> } sx={{ mb: 3, ml: 0 }} @@ -161,22 +152,24 @@ export const CommunityJoinModal: React.FC<CommunityJoinModalProps> = ({ <AnimatedButton variant='contained' - color='primary' href='https://chat.whatsapp.com/GXBnfGrTbfvBo8KxOtMOZL?mode=gi_t' target='_blank' rel='noopener noreferrer' fullWidth disabled={!accepted} - hoverColor='#FFE970' + hoverColor={COLORS.yellowHover} sx={{ - py: 2, - opacity: accepted ? 1 : 0.5, + py: 1.8, + bgcolor: COLORS.yellow, + color: COLORS.textPrimary, + fontWeight: 700, + opacity: accepted ? 1 : 0.4, cursor: accepted ? 'pointer' : 'not-allowed', }} > - <Stack display='flex' flexDirection='row' alignItems='center'> - <WhatsAppIcon sx={{ mr: 1 }} /> - Ir al Grupo de WhatsApp + <Stack direction='row' alignItems='center' spacing={1}> + <WhatsappLogo size={20} weight='fill' aria-hidden /> + <span>Ir al Grupo de WhatsApp</span> </Stack> </AnimatedButton> </Box> diff --git a/src/components/ScrollReveal.tsx b/src/components/ScrollReveal.tsx new file mode 100644 index 0000000..54f8eae --- /dev/null +++ b/src/components/ScrollReveal.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { motion, Variants } from 'framer-motion'; +import { ReactNode } from 'react'; +import { EASE } from '@/lib/tokens'; + +const EASE_OUT_EXPO = EASE; + +/* ── Fade + slide hacia arriba ── */ +export const FadeUp = ({ + children, + delay = 0, + duration = 0.7, +}: { + children: ReactNode; + delay?: number; + duration?: number; +}) => ( + <motion.div + initial={{ opacity: 0, y: 48 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: '-80px' }} + transition={{ duration, delay, ease: EASE_OUT_EXPO }} + > + {children} + </motion.div> +); + +/* ── Slide desde la izquierda ── */ +export const SlideLeft = ({ + children, + delay = 0, +}: { + children: ReactNode; + delay?: number; +}) => ( + <motion.div + initial={{ opacity: 0, x: -80 }} + whileInView={{ opacity: 1, x: 0 }} + viewport={{ once: true, margin: '-80px' }} + transition={{ duration: 0.75, delay, ease: EASE_OUT_EXPO }} + > + {children} + </motion.div> +); + +/* ── Slide desde la derecha ── */ +export const SlideRight = ({ + children, + delay = 0, +}: { + children: ReactNode; + delay?: number; +}) => ( + <motion.div + initial={{ opacity: 0, x: 80 }} + whileInView={{ opacity: 1, x: 0 }} + viewport={{ once: true, margin: '-80px' }} + transition={{ duration: 0.75, delay, ease: EASE_OUT_EXPO }} + > + {children} + </motion.div> +); + +/* ── Pop con spring (escala desde 0) ── */ +export const PopIn = ({ + children, + delay = 0, +}: { + children: ReactNode; + delay?: number; +}) => ( + <motion.div + initial={{ opacity: 0, scale: 0.6 }} + whileInView={{ opacity: 1, scale: 1 }} + viewport={{ once: true, margin: '-60px' }} + transition={{ type: 'spring', damping: 14, stiffness: 120, delay }} + > + {children} + </motion.div> +); + +/* ── Cae desde arriba con spring + rotación inicial ── */ +export const DropIn = ({ + children, + delay = 0, + fromRotate = 0, +}: { + children: ReactNode; + delay?: number; + fromRotate?: number; +}) => ( + <motion.div + initial={{ opacity: 0, y: -120, rotate: fromRotate }} + whileInView={{ opacity: 1, y: 0, rotate: 0 }} + viewport={{ once: true, margin: '-60px' }} + transition={{ type: 'spring', damping: 13, stiffness: 90, delay }} + > + {children} + </motion.div> +); + +/* ── Contenedor con stagger para hijos ── */ +const staggerContainer: Variants = { + hidden: {}, + show: { transition: { staggerChildren: 0.12 } }, +}; +const staggerChild: Variants = { + hidden: { opacity: 0, y: 36 }, + show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: EASE_OUT_EXPO } }, +}; + +export const StaggerContainer = ({ children }: { children: ReactNode }) => ( + <motion.div + variants={staggerContainer} + initial='hidden' + whileInView='show' + viewport={{ once: true, margin: '-80px' }} + > + {children} + </motion.div> +); + +export const StaggerItem = ({ children }: { children: ReactNode }) => ( + <motion.div variants={staggerChild}>{children}</motion.div> +); + +/* ── Ícono que oscila (wobble) al entrar ── */ +export const WobbleOnEnter = ({ children }: { children: ReactNode }) => ( + <motion.div + initial={{ rotate: -15, scale: 0, opacity: 0 }} + whileInView={{ rotate: [null, 15, -10, 8, -5, 0], scale: 1, opacity: 1 }} + viewport={{ once: true }} + transition={{ duration: 0.8, ease: 'easeOut' }} + > + {children} + </motion.div> +); diff --git a/src/components/WhatsappFab.tsx b/src/components/WhatsappFab.tsx index e65428b..de75f2b 100644 --- a/src/components/WhatsappFab.tsx +++ b/src/components/WhatsappFab.tsx @@ -1,6 +1,7 @@ +import { COLORS } from '@/lib/tokens'; import { CommunityJoinModal } from './CommunityJoinModal'; import Fab from '@mui/material/Fab'; -import WhatsAppIcon from '@mui/icons-material/WhatsApp'; +import { WhatsappLogo } from '@phosphor-icons/react'; import { useState } from 'react'; export const WhatsappFab = () => { @@ -20,16 +21,16 @@ export const WhatsappFab = () => { right: { xs: 16, md: 32 }, zIndex: 2000, - bgcolor: '#F0DB4F', + bgcolor: COLORS.yellow, color: 'black', '&:hover': { - bgcolor: '#FFE970', + bgcolor: COLORS.yellowHover, transform: 'scale(1.1)', }, transition: 'all 0.3s ease-in-out', }} > - <WhatsAppIcon /> + <WhatsappLogo size={24} weight='fill' /> </Fab> <CommunityJoinModal open={isModalOpen} onClose={handleCloseModal} /> </> diff --git a/src/data/data_staff.ts b/src/data/data_staff.ts index 6726031..cd8fe6b 100644 --- a/src/data/data_staff.ts +++ b/src/data/data_staff.ts @@ -69,7 +69,7 @@ export const DATA_STAFF: StaffMember[] = [ linkedin: 'https://www.linkedin.com/in/ceciliageraldo/', date: '2023 - 2025', role: 'Staff', - imageUrl: '/assets/staff/staff-cecilia-geraldo.jpeg', + imageUrl: '/assets/staff/staff-cecilia-geraldo.webp', isExStaff: true, }, // Actuales Staff @@ -77,11 +77,11 @@ export const DATA_STAFF: StaffMember[] = [ id: '7', fullName: 'Ricardo Castillo', linkedin: '#', - date: '2023 - Presente', + date: '2023 al 2025', role: 'Staff', imageUrl: 'https://images.ctfassets.net/1kfhsqlc8ewi/19Z63BSGRvkB44joLGM0Ph/a6ab11ef6f5f8552b97a90db01ac389e/bio.JPG?fm=webp', - isExStaff: false, + isExStaff: true, }, { id: '8', @@ -104,7 +104,7 @@ export const DATA_STAFF: StaffMember[] = [ email: 'armandor@jschile.org', date: '2026 - Presente', role: 'Staff', - imageUrl: '/assets/staff/staff-armando-rivas-dev.jpg', + imageUrl: '/assets/staff/staff-armando-rivas-dev.webp', isExStaff: false, }, { @@ -114,7 +114,7 @@ export const DATA_STAFF: StaffMember[] = [ email: 'samuel@jschile.org', date: '2026 - Presente', role: 'Staff', - imageUrl: '/assets/staff/staff-samuel-angulo.jpeg', + imageUrl: '/assets/staff/staff-samuel-angulo.webp', isExStaff: false, }, { @@ -123,25 +123,29 @@ export const DATA_STAFF: StaffMember[] = [ linkedin: 'https://cl.linkedin.com/in/konstanzabosch', date: '2026 - Presente', role: 'Staff', - imageUrl: '/assets/staff/staff-mapachito.jpeg', + imageUrl: '/assets/staff/staff-mapachito.webp', isExStaff: false, }, { id: '12', fullName: 'Carlos Romero', - linkedin: 'https://www.instagram.com/cxrlosarmando/', + linkedin: 'https://www.linkedin.com/in/carlos-romero-025364238/', + instagram: 'https://www.instagram.com/cxrlosarmando/', date: '2026 - Presente', role: 'Staff', - imageUrl: '/assets/staff/staff-carlos-romero.jpeg', + imageUrl: '/assets/staff/staff-carlos-romero.webp', isExStaff: false, }, { id: '13', - fullName: 'Javier Vargas', - linkedin: 'https://www.linkedin.com/in/javierdvt/', + fullName: 'Erika Pinedo', + linkedin: 'https://www.linkedin.com/in/erika-pinedo-camacho/?locale=es-ES', + instagram: 'https://www.instagram.com/otroanguloweb/', + website: 'https://otroanguloweb.cl/', + email: 'pinedoerika.dg@gmail.com', date: '2026 - Presente', role: 'Staff', - imageUrl: '/assets/staff/staff-javier-vargas.png', + imageUrl: '/assets/staff/staff-erika-pinedo.webp', isExStaff: false, }, ]; diff --git a/src/layout/Footer.tsx b/src/layout/Footer.tsx index 8e2424a..faa5ceb 100644 --- a/src/layout/Footer.tsx +++ b/src/layout/Footer.tsx @@ -1,122 +1,206 @@ 'use client'; +import { jersey10 } from '@/lib/fonts'; +import { EASE, COLORS } from '@/lib/tokens'; -import * as React from 'react'; import Box from '@mui/material/Box'; +import { useCommunityModal } from '@/providers/CommunityModalProvider'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; -import LinkedInIcon from '@mui/icons-material/LinkedIn'; -import TwitterIcon from '@mui/icons-material/Twitter'; -import GitHubIcon from '@mui/icons-material/GitHub'; -import MailIcon from '@mui/icons-material/Mail'; -import Link from '@mui/material/Link'; +import Container from '@mui/material/Container'; +import Grid from '@mui/material/Grid'; +import { GithubLogo, InstagramLogo, WhatsappLogo, LinkedinLogo, XLogo } from '@phosphor-icons/react'; +import Link from 'next/link'; + + +const NAV_LINKS = [ + { label: 'Eventos', href: '#eventos' }, + { label: 'Nuestra historia', href: '#historia' }, + { label: 'Nuestros valores', href: '#valores' }, + { label: 'Speakers', href: '#speakers' }, + { label: 'Staff', href: '#staff' }, +]; + +const ACCIONES_LINKS = [ + { label: 'Enviar mi propuesta', href: 'mailto:contacto@jschile.org' }, +]; + +const SOCIAL = [ + { Icon: GithubLogo, href: 'https://github.com/JSConfCL/org-landing', label: 'GitHub' }, + { Icon: InstagramLogo, href: 'https://www.instagram.com/javascriptchile/', label: 'Instagram' }, + { Icon: WhatsappLogo, href: null, label: 'WhatsApp' }, + { Icon: LinkedinLogo, href: 'https://www.linkedin.com/company/javascriptchile/posts/?feedView=all', label: 'LinkedIn' }, + { Icon: XLogo, href: 'https://x.com/javascriptchile', label: 'X' }, +]; + +const linkSx = { + fontFamily: 'Inter', + fontWeight: 600, + fontSize: '0.95rem', + color: COLORS.textPrimary, + textDecoration: 'none', + display: 'block', + mb: 1.5, + '&:hover': { textDecoration: 'underline' }, +}; + +const colTitle = (text: string) => ( + <Typography + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: '1.35rem', + color: COLORS.textPrimary, + mb: 1, + }} + > + {text} + </Typography> +); + +const buttonLinkSx = { + ...linkSx, + background: 'none', + border: 'none', + padding: 0, + cursor: 'pointer', + textAlign: 'left' as const, +}; const Footer = () => { + const { openModal } = useCommunityModal(); + return ( <Box id='contacto' component='footer' sx={{ - bgcolor: '#FFFFFF', - color: 'black', - py: { xs: 1.5, md: 2 }, - px: { xs: 2, md: 4 }, - display: 'flex', - flexDirection: { xs: 'column', md: 'row' }, - alignItems: 'center', - justifyContent: 'space-between', - gap: { xs: 1, md: 2 }, + backgroundImage: "url(/assets/Bg-footer.webp)", + backgroundSize: 'cover', + backgroundPosition: 'center top', + backgroundRepeat: 'no-repeat', + pt: { xs: '75%', sm: '35%' }, }} > - <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> - <Typography - sx={{ - fontWeight: 950, - color: '#F0DB4F', - fontSize: { xs: '1rem', md: '1.2rem' }, - letterSpacing: '-0.04em', - }} - > - JavaScript - </Typography> - <Typography - sx={{ - fontWeight: 950, - color: 'black', - fontSize: { xs: '1rem', md: '1.2rem' }, - letterSpacing: '-0.04em', - }} - > - Chile - </Typography> - </Box> + {/* ── Contenido principal ── */} + <Container maxWidth={false} sx={{ px: { xs: 4, md: 8, lg: 10 }, pt: { xs: 6, md: 8 }, pb: { xs: 4, md: 6 } }}> + <Grid container spacing={4} alignItems='flex-start'> + + {/* Logo */} + <Grid size={{ xs: 12, md: 4 }}> + <Link href='/' aria-label='Ir al inicio' onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}> + <Box + component='img' + src='/assets/logo-cljs.svg' + alt='JSChile' + sx={{ width: { xs: 120, md: 160 }, height: 'auto', objectFit: 'contain', display: 'block' }} + /> + </Link> + </Grid> + + {/* Navegación */} + <Grid size={{ xs: 6, md: 2 }}> + {colTitle('Navegación')} + {/* WCAG AA: nav landmark para los enlaces del footer */} + <Box component='nav' aria-label='Secciones del sitio'> + {NAV_LINKS.map((l) => ( + <Box key={l.label} component='a' href={l.href} sx={linkSx}> + {l.label} + </Box> + ))} + </Box> + </Grid> + + {/* Acciones */} + <Grid size={{ xs: 6, md: 2 }}> + {colTitle('Acciones')} + <Box component='button' onClick={openModal} sx={buttonLinkSx}> + Unirme al Whatsapp + </Box> + {ACCIONES_LINKS.map((l) => ( + <Box + key={l.label} + component='a' + href={l.href} + target={l.href.startsWith('http') ? '_blank' : undefined} + rel={l.href.startsWith('http') ? 'noopener noreferrer' : undefined} + sx={linkSx} + > + {l.label} + </Box> + ))} + </Grid> + + {/* Contáctanos */} + <Grid size={{ xs: 6, md: 2 }}> + {colTitle('Contáctanos')} + <Box + component='a' + href='mailto:contacto@jschile.org' + sx={linkSx} + > + contacto@jschile.org + </Box> + </Grid> + + {/* Síguenos */} + <Grid size={{ xs: 6, md: 2 }}> + {colTitle('Síguenos')} + {/* WCAG AA: aria-label descriptivo incluye "(abre en nueva pestaña)" */} + <Box component='nav' aria-label='Redes sociales' sx={{ display: 'flex', gap: 0.5 }}> + {SOCIAL.map(({ Icon, href, label }) => ( + <IconButton + key={label} + {...(href + ? { component: 'a', href, target: '_blank', rel: 'noopener noreferrer' } + : { onClick: openModal } + )} + aria-label={href ? `${label} (abre en nueva pestaña)` : `Unirse al ${label}`} + size='small' + sx={{ + color: COLORS.textPrimary, + bgcolor: 'rgba(0,0,0,0.08)', + borderRadius: '8px', + '&:hover': { bgcolor: 'rgba(0,0,0,0.15)' }, + }} + > + <Icon size={20} weight='fill' aria-hidden /> + </IconButton> + ))} + </Box> + </Grid> - <Typography - variant='body2' + </Grid> + </Container> + + {/* ── Barra inferior ── */} + <Box sx={{ - fontWeight: 500, - color: '#666', - fontSize: '0.75rem', + borderTop: '1px solid rgba(0,0,0,0.15)', + px: { xs: 4, md: 8, lg: 10 }, + py: { xs: 2, md: 2.5 }, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: 1, }} > - Comunidad de JavaScript • Desde 2013 - </Typography> - - <Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}> - <IconButton - size='small' - href='https://github.com/jschile' - target='_blank' - rel='noopener noreferrer' - component='a' - sx={{ color: '#000' }} - > - <GitHubIcon sx={{ fontSize: 18 }} /> - </IconButton> - <IconButton - size='small' - href='https://linkedin.com/company/jscriptchile' - target='_blank' - rel='noopener noreferrer' - component='a' - sx={{ color: '#000' }} - > - <LinkedInIcon sx={{ fontSize: 18 }} /> - </IconButton> - <IconButton - size='small' - href='https://twitter.com/jscriptchile' - target='_blank' - rel='noopener noreferrer' - component='a' - sx={{ color: '#000' }} - > - <TwitterIcon sx={{ fontSize: 18 }} /> - </IconButton> - <IconButton - size='small' - href='mailto:contacto@jschile.org' - component='a' - sx={{ color: '#000' }} - > - <MailIcon sx={{ fontSize: 18 }} /> - </IconButton> + <Typography sx={{ fontFamily: 'Inter', fontWeight: 700, fontSize: '0.9rem', color: COLORS.textPrimary }}> + Comunidad JSChile • Desde 2013 + </Typography> + <Typography sx={{ fontFamily: 'Inter', fontWeight: 700, fontSize: '0.9rem', color: COLORS.textPrimary }}> + © 2026 JSChile — Hecho con 🖤 por{' '} + <Box + component='a' + href='https://otroanguloweb.cl/' + target='_blank' + rel='noopener noreferrer' + sx={{ color: COLORS.textPrimary, textDecoration: 'underline', '&:hover': { opacity: 0.7 } }} + > + Erika Pinedo + </Box> + </Typography> </Box> - - <Typography - variant='caption' - sx={{ fontWeight: 500, color: '#999', fontSize: '0.65rem' }} - > - © 2026 JavaScript Chile • Desarrollado por{' '} - <Link - href='https://www.rivascode.dev/' - target='_blank' - rel='noopener noreferrer' - color='inherit' - underline='hover' - > - RivasCode - </Link> - </Typography> </Box> ); }; diff --git a/src/layout/Navbar.tsx b/src/layout/Navbar.tsx index f2a48fc..3af16fe 100644 --- a/src/layout/Navbar.tsx +++ b/src/layout/Navbar.tsx @@ -1,6 +1,6 @@ 'use client'; +import { COLORS } from '@/lib/tokens'; -import * as React from 'react'; import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; import Toolbar from '@mui/material/Toolbar'; @@ -9,88 +9,135 @@ import Container from '@mui/material/Container'; import Link from 'next/link'; import { AnimatedButton } from '@/components/AnimatedButton'; -import { CommunityJoinModal } from '@/components/CommunityJoinModal'; +import { useCommunityModal } from '@/providers/CommunityModalProvider'; import { Stack } from '@mui/material'; -import WhatsAppIcon from '@mui/icons-material/WhatsApp'; +import { WhatsappLogo } from '@phosphor-icons/react'; +import { useState, useEffect } from 'react'; const Navbar = () => { - const [isCommunityModalOpen, setIsCommunityModalOpen] = React.useState(false); + const { openModal } = useCommunityModal(); + const [pastHero, setPastHero] = useState(false); + + useEffect(() => { + const onScroll = () => setPastHero(window.scrollY >= window.innerHeight * 0.8); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); return ( <> <AppBar - position='sticky' + position='fixed' color='transparent' elevation={0} sx={{ top: 0, zIndex: 1100, - borderBottom: '2px solid rgba(255,255,255,0.15)', - bgcolor: 'rgba(25, 25, 25, 0.6)', + borderBottom: '1px solid rgba(255, 255, 255, 0.4)', + bgcolor: 'rgba(255, 255, 255, 0.1)', backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - py: 1, - boxShadow: '0 4px 30px rgba(0, 0, 0, 0.5)', + py: 0, + minHeight: '45px', + boxShadow: 'none', }} > + {/* WCAG AA: landmark de navegación principal */} <Container maxWidth={false}> - <Toolbar disableGutters sx={{ justifyContent: 'space-between' }}> - <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> + <Toolbar component='nav' aria-label='Navegación principal' disableGutters sx={{ justifyContent: 'space-between' }}> + <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Link href='/' + onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} style={{ display: 'flex', alignItems: 'center', textDecoration: 'none', }} > - <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> - <Typography - sx={{ - fontWeight: 950, - color: '#F0DB4F', - fontSize: { xs: '1rem', md: '1.2rem' }, - letterSpacing: '-0.04em', - }} - > - JavaScript - </Typography> - <Typography + <Box + component='img' + src='/assets/square-logo-cljs.svg' + alt='JSChile' + sx={{ height: { xs: 32, md: 38 }, width: 'auto' }} + /> + </Link> + + {/* Nav links — hidden on mobile, visible after hero */} + <Box + component='ul' + sx={{ + display: { xs: 'none', md: 'flex' }, + alignItems: 'center', + gap: 0.5, + listStyle: 'none', + m: 0, + p: 0, + opacity: pastHero ? 1 : 0, + pointerEvents: pastHero ? 'auto' : 'none', + transform: pastHero ? 'translateY(0)' : 'translateY(-6px)', + transition: 'opacity 0.3s ease, transform 0.3s ease', + }} + > + {[ + { label: 'Eventos', href: '#eventos' }, + { label: 'Nuestra historia', href: '#historia' }, + { label: 'Nuestros valores', href: '#valores' }, + { label: 'Speakers', href: '#speakers' }, + { label: 'Staff', href: '#staff' }, + ].map(({ label, href }) => ( + <Box component='li' key={label}> + <Box + component='a' + href={href} sx={{ - fontWeight: 950, - color: 'white', - fontSize: { xs: '1rem', md: '1.2rem' }, - letterSpacing: '-0.04em', + display: 'block', + px: 1.5, + py: 0.75, + fontFamily: 'Inter', + fontWeight: 600, + fontSize: '0.82rem', + letterSpacing: '0.01em', + color: '#fff', + textDecoration: 'none', + borderRadius: '8px', + bgcolor: 'rgba(0,0,0,0.35)', + transition: 'background 0.18s, color 0.18s', + '&:hover': { + bgcolor: 'rgba(0,0,0,0.55)', + color: COLORS.yellow, + }, }} > - Chile - </Typography> + {label} + </Box> </Box> - </Link> + ))} + </Box> </Box> - {/* Action Buttons & Mobile Menu Toggle */} + {/* Action button */} <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <AnimatedButton - onClick={() => setIsCommunityModalOpen(true)} + onClick={openModal} variant='contained' color='primary' - hoverColor='#FFE970' + hoverColor={COLORS.yellowHover} disableElevation sx={{ display: 'flex', - fontWeight: 950, - px: { xs: 1, sm: 4 }, - py: { xs: 1, sm: 1.5 }, + fontWeight: 700, + px: { xs: 2, sm: 2.5 }, + py: { xs: 0.9, sm: 0.8 }, borderRadius: 100, - fontSize: { xs: '0.7rem', sm: '1rem' }, - bgcolor: '#F0DB4F', - color: 'black', + fontSize: { xs: '0.85rem', sm: '0.85rem' }, + bgcolor: COLORS.yellow, + color: COLORS.black, }} > <Stack display='flex' flexDirection='row' alignItems='center'> - <WhatsAppIcon sx={{ mr: 1 }} /> - Unirse a la Comunidad + <WhatsappLogo size={20} weight='fill' style={{ marginRight: 8 }} /> + Unirme al WhatsApp </Stack> </AnimatedButton> </Box> @@ -98,10 +145,6 @@ const Navbar = () => { </Container> </AppBar> - <CommunityJoinModal - open={isCommunityModalOpen} - onClose={() => setIsCommunityModalOpen(false)} - /> </> ); }; diff --git a/src/lib/fetchSpeakers.ts b/src/lib/fetchSpeakers.ts new file mode 100644 index 0000000..689e83b --- /dev/null +++ b/src/lib/fetchSpeakers.ts @@ -0,0 +1,68 @@ +export interface Speaker { + id: string; + name: string; + avatar?: string; + showInSection: boolean; + linkedin?: string; + instagram?: string; + github?: string; + website?: string; +} + +const SHEET_URL = + 'https://docs.google.com/spreadsheets/d/1cqTsGP4EvlwEo4ByIRwnu_PAmRx6uZHYL_skx02Osps/export?format=csv&gid=0'; + +function parseCSVRow(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + + for (const ch of line) { + if (ch === '"') { + inQuotes = !inQuotes; + } else if (ch === ',' && !inQuotes) { + result.push(current); + current = ''; + } else { + current += ch; + } + } + result.push(current); + return result; +} + +function clean(s: string): string { + // strip zero-width spaces that Google Sheets sometimes injects + return s.replace(/[​‌‍]/g, '').trim(); +} + +export async function fetchSpeakers(): Promise<Speaker[]> { + try { + const res = await fetch(SHEET_URL, { next: { revalidate: 300 } }); + if (!res.ok) return []; + + const text = await res.text(); + const rows = text.trim().split('\n').slice(1); // skip header row + + const speakers: Speaker[] = []; + rows.forEach((line, i) => { + const cols = parseCSVRow(line); + const name = clean(cols[1] ?? ''); + if (!name) return; + + speakers.push({ + id: String(i + 1), + name, + avatar: clean(cols[0] ?? '') || undefined, + showInSection: clean(cols[2] ?? '').toUpperCase() === 'TRUE', + linkedin: clean(cols[3] ?? '') || undefined, + instagram: clean(cols[4] ?? '') || undefined, + github: clean(cols[5] ?? '') || undefined, + website: clean(cols[6] ?? '') || undefined, + }); + }); + return speakers; + } catch { + return []; + } +} diff --git a/src/lib/fonts.ts b/src/lib/fonts.ts new file mode 100644 index 0000000..34efd65 --- /dev/null +++ b/src/lib/fonts.ts @@ -0,0 +1,3 @@ +import { Jersey_10 } from 'next/font/google'; + +export const jersey10 = Jersey_10({ weight: '400', subsets: ['latin'], display: 'swap' }); diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts new file mode 100644 index 0000000..c361230 --- /dev/null +++ b/src/lib/tokens.ts @@ -0,0 +1,20 @@ +export const COLORS = { + yellow: '#EBCE17', + yellowHover: '#FAE672', + yellowMid: '#F5C40F', + yellowDark: '#C8A108', + black: '#1D1D1D', + charcoal: '#2B2B2B', + gray: '#636363', + graySubtle: 'rgba(99,99,99,0.08)', + olive: '#70650C', + oliveDark: '#444028', + white: '#FFFFFF', + // aliases para compatibilidad con código existente + darkBg: '#1D1D1D', + darkOlive: '#2B2B2B', + darkOliveCard:'#444028', + textPrimary: '#1D1D1D', +} as const; + +export const EASE = [0.16, 1, 0.3, 1] as const; diff --git a/src/providers/CommunityModalProvider.tsx b/src/providers/CommunityModalProvider.tsx new file mode 100644 index 0000000..165b89f --- /dev/null +++ b/src/providers/CommunityModalProvider.tsx @@ -0,0 +1,21 @@ +'use client'; + +import React, { createContext, useContext, useState } from 'react'; +import { CommunityJoinModal } from '@/components/CommunityJoinModal'; + +const CommunityModalContext = createContext<{ openModal: () => void }>({ + openModal: () => {}, +}); + +export const useCommunityModal = () => useContext(CommunityModalContext); + +export const CommunityModalProvider = ({ children }: { children: React.ReactNode }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <CommunityModalContext.Provider value={{ openModal: () => setIsOpen(true) }}> + {children} + <CommunityJoinModal open={isOpen} onClose={() => setIsOpen(false)} /> + </CommunityModalContext.Provider> + ); +}; diff --git a/src/providers/MuiRootProvider.tsx b/src/providers/MuiRootProvider.tsx index 984af75..e3dfd1d 100644 --- a/src/providers/MuiRootProvider.tsx +++ b/src/providers/MuiRootProvider.tsx @@ -1,15 +1,16 @@ 'use client'; +import { COLORS } from '@/lib/tokens'; import { AppRouterCacheProvider } from '@mui/material-nextjs/v16-appRouter'; import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import theme from '@/theme/theme'; -import Navbar from '@/layout/Navbar'; import Footer from '@/layout/Footer'; import Box from '@mui/material/Box'; -import { StarsBackground } from '@/components/StarsBackground'; import { WhatsappFab } from '@/components/WhatsappFab'; +import { CommunityModalProvider } from '@/providers/CommunityModalProvider'; +import Navbar from '@/layout/Navbar'; export default function MuiRootProvider({ children, @@ -20,28 +21,27 @@ export default function MuiRootProvider({ <AppRouterCacheProvider> <ThemeProvider theme={theme}> <CssBaseline /> - <Box - sx={{ - display: 'flex', - flexDirection: 'column', - minHeight: '100vh', - position: 'relative', - bgcolor: '#0B0B0B', - zIndex: 0, - }} - > - <StarsBackground /> - + <CommunityModalProvider> <Navbar /> + <Box + sx={{ + display: 'flex', + flexDirection: 'column', + minHeight: '100vh', + position: 'relative', + bgcolor: COLORS.black, + zIndex: 0, + }} + > + <Box sx={{ flexGrow: 1, position: 'relative', zIndex: 1 }}> + {children} + </Box> - <Box sx={{ flexGrow: 1, py: 4, position: 'relative', zIndex: 1 }}> - {children} - </Box> - - <Footer /> + <Footer /> - <WhatsappFab /> - </Box> + <WhatsappFab /> + </Box> + </CommunityModalProvider> </ThemeProvider> </AppRouterCacheProvider> ); diff --git a/src/sections/BrandBanner.tsx b/src/sections/BrandBanner.tsx new file mode 100644 index 0000000..fb9b75b --- /dev/null +++ b/src/sections/BrandBanner.tsx @@ -0,0 +1,82 @@ +'use client'; +import { COLORS } from '@/lib/tokens'; +import { jersey10 } from '@/lib/fonts'; + +import { useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useInView } from 'framer-motion'; + + +const WHITE = 'Comunidad '; +const YELLOW = 'JSCHILE'; +const FULL = WHITE + YELLOW; +const CHAR_DELAY = 90; + +export const BrandBanner = () => { + const ref = useRef<HTMLElement>(null); + const inView = useInView(ref, { once: true, margin: '-10%' }); + const [count, setCount] = useState(0); + + useEffect(() => { + if (!inView || count >= FULL.length) return; + const t = setTimeout(() => setCount((c) => c + 1), CHAR_DELAY); + return () => clearTimeout(t); + }, [inView, count]); + + const whiteText = FULL.slice(0, Math.min(count, WHITE.length)); + const yellowText = count > WHITE.length ? FULL.slice(WHITE.length, count) : ''; + const done = count >= FULL.length; + + return ( + <Box + ref={ref} + component='section' + sx={{ + bgcolor: COLORS.darkBg, + minHeight: { xs: '40vh', md: '80vh' }, + position: 'sticky', + top: 0, + zIndex: 0, + display: 'flex', + justifyContent: 'center', + alignItems: { xs: 'center', md: 'flex-start' }, + overflow: 'hidden', + px: 2, + pt: { xs: 6, md: 22 }, + pb: { xs: 4, md: 6 }, + }} + > + <Typography + component='h2' + aria-label='Comunidad JSChile' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '3.5rem', sm: '5.5rem', md: '9rem', lg: '11rem' }, + lineHeight: 1, + textAlign: 'center', + userSelect: 'none', + }} + > + <Box component='span' sx={{ color: '#fff' }}>{whiteText}</Box> + <Box component='span' sx={{ color: COLORS.yellow }}>{yellowText}</Box> + {!done && ( + <Box + component='span' + sx={{ + color: COLORS.yellow, + '@keyframes blink': { + '0%, 100%': { opacity: 1 }, + '50%': { opacity: 0 }, + }, + animation: 'blink 0.65s step-end infinite', + }} + > + _ + </Box> + )} + </Typography> + </Box> + ); +}; diff --git a/src/sections/HistoriaTimeline.tsx b/src/sections/HistoriaTimeline.tsx new file mode 100644 index 0000000..c82f97b --- /dev/null +++ b/src/sections/HistoriaTimeline.tsx @@ -0,0 +1,268 @@ +'use client'; +import { jersey10 } from '@/lib/fonts'; +import { EASE, COLORS } from '@/lib/tokens'; + +import { useRef } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Container from '@mui/material/Container'; +import Grid from '@mui/material/Grid'; +import { motion, useScroll, useTransform, MotionValue } from 'framer-motion'; +import { Star, RocketLaunch } from '@phosphor-icons/react'; + + +const MILESTONES = [ + { + year: '2013', + title: 'El Inicio', + description: + 'Nace la comunidad de JSChile como un pequeño grupo de entusiastas buscando compartir conocimientos y experiencias con esta tecnología web emergente.', + isLast: false, + // Scroll threshold at which this node activates (0 → 1) + t: 0, + }, + { + year: '2016', + title: 'Los Primeros Meetups', + description: + 'Se realizan los primeros meetups regulares en Santiago, conectando a la comunidad developer con charlas técnicas de alto nivel y networking.', + isLast: false, + t: 0.2, + }, + { + year: '2018', + title: 'La Expansión', + description: + 'Speakers internacionales se suman a los eventos y la comunidad crece exponencialmente, llegando a cientos de asistentes por encuentro.', + isLast: false, + t: 0.38, + }, + { + year: '2023', + title: 'La Consolidación', + description: + 'JSChile se consolida como la comunidad tech más activa del país, con eventos de clase mundial y miles de miembros activos.', + isLast: false, + t: 0.58, + }, + { + year: '2026', + title: 'El Presente', + description: + 'Seguimos creciendo, aprendiendo y conectando a la comunidad developer de Chile. El mejor capítulo aún está por escribirse.', + isLast: true, + t: 0.76, + }, +]; + +const NODE_SIZE = 32; +const NODE_HALF = NODE_SIZE / 2; + +function ChileMap() { + return ( + <Box + component='img' + src='/assets/js-chile-mapa.svg' + alt='Mapa de Chile' + sx={{ width: 'auto', height: '100%', maxHeight: 1200, objectFit: 'contain', opacity: 0.9 }} + /> + ); +} + +function MilestoneItem({ + milestone, + scrollProgress, +}: { + milestone: (typeof MILESTONES)[0]; + scrollProgress: MotionValue<number>; +}) { + const { t } = milestone; + const appear = [Math.max(0, t - 0.08), Math.min(1, t + 0.07)]; + + const nodeScale = useTransform(scrollProgress, [Math.max(0, t - 0.07), t], [0.55, 1]); + const nodeBg = useTransform(scrollProgress, (v) => v >= t ? COLORS.yellow : 'rgba(240,219,79,0.15)'); + const iconColor = useTransform(scrollProgress, (v) => v >= t ? COLORS.textPrimary : COLORS.yellow); + + const contentOpacity = useTransform(scrollProgress, appear, [0.25, 1]); + const contentX = useTransform(scrollProgress, appear, [20, 0]); + + return ( + <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3 }}> + {/* Node (layered on top of the continuous line) */} + <Box sx={{ flexShrink: 0, width: NODE_SIZE, display: 'flex', justifyContent: 'center', position: 'relative', zIndex: 1 }}> + <motion.div style={{ scale: nodeScale }}> + <motion.div + style={{ + width: NODE_SIZE, + height: NODE_SIZE, + borderRadius: '50%', + backgroundColor: nodeBg, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + <motion.div style={{ color: iconColor, display: 'flex' }}> + {milestone.isLast ? ( + <RocketLaunch size={18} weight='fill' /> + ) : ( + <Star size={18} weight='fill' /> + )} + </motion.div> + </motion.div> + </motion.div> + </Box> + + {/* Content */} + <motion.div style={{ opacity: contentOpacity, x: contentX, flex: 1 }}> + <Typography + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '1.6rem', md: '2.8rem' }, + color: COLORS.yellow, + lineHeight: 1, + mb: 0.5, + }} + > + {milestone.year} + </Typography> + <Typography sx={{ fontFamily: 'Inter', fontWeight: 700, fontSize: '0.95rem', color: '#fff', mb: 0.75 }}> + {milestone.title} + </Typography> + <Typography sx={{ fontFamily: 'Inter', fontWeight: 400, fontSize: '0.9rem', color: 'rgba(255,255,255,0.6)', lineHeight: 1.65, maxWidth: 500 }}> + {milestone.description} + </Typography> + </motion.div> + </Box> + ); +} + +export const HistoriaTimeline = () => { + const sectionRef = useRef<HTMLElement>(null); + + const { scrollYProgress } = useScroll({ + target: sectionRef, + offset: ['start 0.3', 'end 0.4'], + }); + + // Map scroll progress to the portion of line covered (0 → last milestone threshold) + const lineScaleY = useTransform(scrollYProgress, [0, 1], [0, 1]); + + return ( + <Box + id='historia' + component='section' + ref={sectionRef} + sx={{ + backgroundImage: 'url(/assets/bg-line-time.webp)', + backgroundSize: '100% 100%', + backgroundRepeat: 'no-repeat', + py: { xs: 8, md: 12 }, + overflow: 'hidden', + }} + > + <Container maxWidth='lg'> + {/* Header */} + <motion.div + initial={{ opacity: 0, y: 40 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true }} + transition={{ duration: 0.7, ease: EASE }} + > + <Box sx={{ textAlign: 'center', mb: { xs: 8, md: 10 } }}> + <Typography + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '2.8rem', sm: '3.5rem', md: '4.375rem' }, + lineHeight: 1, + color: COLORS.yellow, + mb: 2, + }} + > + Pero... ¿cómo empezó todo? + </Typography> + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 400, + fontSize: { xs: '1rem', md: '1.05rem' }, + color: 'rgba(255,255,255,0.75)', + maxWidth: 500, + mx: 'auto', + lineHeight: 1.65, + }} + > + Conoce cómo evolucionó la comunidad JSChile{' '} + <Box component='strong' sx={{ color: '#fff', fontWeight: 700 }}> + más activa de Chile a través de los años + </Box> + </Typography> + </Box> + </motion.div> + + {/* Map + Timeline */} + <Box sx={{ maxWidth: 860, mx: 'auto' }}> + <Grid container spacing={{ xs: 6, md: 8 }} alignItems='stretch'> + {/* Chile map */} + <Grid size={{ xs: 12, md: 5 }} sx={{ display: { xs: 'none', md: 'flex' } }}> + <motion.div + initial={{ opacity: 0, x: -60 }} + whileInView={{ opacity: 1, x: 0 }} + viewport={{ once: true, margin: '-60px' }} + transition={{ duration: 0.9, ease: EASE }} + style={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 16, width: '100%', height: '100%' }} + > + <ChileMap /> + </motion.div> + </Grid> + + {/* Timeline */} + <Grid size={{ xs: 12, md: 7 }} sx={{ display: 'flex' }}> + <Box sx={{ position: 'relative', height: '100%', width: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}> + {/* ── Track (always visible, fades to transparent at bottom) ── */} + <Box + sx={{ + position: 'absolute', + left: NODE_HALF - 1, + top: NODE_HALF, + bottom: NODE_HALF, + width: 2, + background: 'linear-gradient(to bottom, rgba(240,219,79,0.4) 0%, rgba(240,219,79,0.04) 100%)', + zIndex: 0, + }} + /> + + {/* ── Animated yellow fill ── */} + <motion.div + style={{ + position: 'absolute', + left: NODE_HALF - 1, + top: NODE_HALF, + width: 2, + height: `calc(100% - ${NODE_SIZE}px)`, + backgroundColor: COLORS.yellow, + scaleY: lineScaleY, + transformOrigin: 'top', + zIndex: 0, + }} + /> + + {/* ── Milestones ── */} + {MILESTONES.map((milestone) => ( + <MilestoneItem + key={milestone.year} + milestone={milestone} + scrollProgress={scrollYProgress} + /> + ))} + </Box> + </Grid> + </Grid> + </Box> + </Container> + </Box> + ); +}; diff --git a/src/sections/HomeHallOfFame.tsx b/src/sections/HomeHallOfFame.tsx index 402aa83..8ccc0f9 100644 --- a/src/sections/HomeHallOfFame.tsx +++ b/src/sections/HomeHallOfFame.tsx @@ -1,4 +1,5 @@ 'use client'; +import { COLORS } from '@/lib/tokens'; import Box from '@mui/material/Box'; import Container from '@mui/material/Container'; @@ -126,7 +127,7 @@ const MarqueeRow = ({ px: 3, py: 1.5, mx: 1, // Use mx for consistent spacing instead of gap - bgcolor: member.relevant ? '#F0DB4F' : '#F5F5F5', + bgcolor: member.relevant ? COLORS.yellow : COLORS.graySubtle, borderRadius: 100, fontWeight: 800, fontSize: '0.9rem', @@ -173,7 +174,7 @@ const HomeHallOfFame = () => { </Typography> <Typography sx={{ - color: '#666', + color: COLORS.gray, fontWeight: 600, fontSize: '1.2rem', mb: 8, @@ -192,7 +193,7 @@ const HomeHallOfFame = () => { sx={{ textAlign: 'center', mt: 8, - color: '#AAA', + color: COLORS.gray, fontWeight: 700, fontSize: '0.9rem', display: 'flex', diff --git a/src/sections/JoinCTA.tsx b/src/sections/JoinCTA.tsx new file mode 100644 index 0000000..a8592ce --- /dev/null +++ b/src/sections/JoinCTA.tsx @@ -0,0 +1,119 @@ +'use client'; +import { jersey10 } from '@/lib/fonts'; +import { EASE, COLORS } from '@/lib/tokens'; + +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Stack from '@mui/material/Stack'; +import { motion } from 'framer-motion'; +import { AnimatedButton } from '@/components/AnimatedButton'; +import { useCommunityModal } from '@/providers/CommunityModalProvider'; +import { PaperPlaneTilt, WhatsappLogo } from '@phosphor-icons/react'; + + +export const JoinCTA = () => { + const { openModal } = useCommunityModal(); + + return ( + <Box + component='section' + sx={{ + bgcolor: COLORS.black, + py: { xs: 10, md: 14 }, + px: { xs: 4, md: 8 }, + textAlign: 'center', + }} + > + <motion.div + initial={{ opacity: 0, y: 30 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true }} + transition={{ duration: 0.7, ease: EASE }} + > + <Typography + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '2.8rem', sm: '3.5rem', md: '4.375rem' }, + lineHeight: 1, + color: COLORS.yellow, + mb: 1, + }} + > + ¡Sé parte de JSChile HOY! + </Typography> + + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 400, + fontSize: { xs: '1rem', md: '1.15rem' }, + lineHeight: 1.6, + color: 'rgba(255,255,255,0.85)', + maxWidth: 560, + mx: 'auto', + mb: 5, + }} + > + Ya sea que tengas una idea para compartir o simplemente quieras estar al tanto de lo que viene,{' '} + <Box component='span' sx={{ fontWeight: 700, color: '#fff' }}> + hay un lugar para ti en esta comunidad. + </Box> + </Typography> + + <Stack + direction={{ xs: 'column', sm: 'row' }} + spacing={2} + justifyContent='center' + alignItems='center' + > + <AnimatedButton + variant='contained' + href='https://tally.so/r/3x4Q1d' + target='_blank' + hoverColor={COLORS.yellowDark} + sx={{ + px: 4, + py: 1.6, + fontSize: '1rem', + fontWeight: 700, + bgcolor: COLORS.yellow, + color: COLORS.textPrimary, + borderRadius: '999px', + minWidth: 220, + }} + > + <Stack direction='row' alignItems='center' spacing={1}> + <PaperPlaneTilt size={20} weight='fill' /> + <span>Enviar mi propuesta</span> + </Stack> + </AnimatedButton> + + <AnimatedButton + variant='outlined' + onClick={openModal} + hoverColor={COLORS.yellowHover} + sx={{ + px: 4, + py: 1.6, + fontSize: '1rem', + fontWeight: 700, + bgcolor: '#fff', + color: COLORS.textPrimary, + borderRadius: '999px', + minWidth: 220, + border: 'none', + '&:hover': { border: 'none' }, + }} + > + <Stack direction='row' alignItems='center' spacing={1}> + <WhatsappLogo size={20} weight='fill' /> + <span>Unirme al WhatsApp</span> + </Stack> + </AnimatedButton> + </Stack> + </motion.div> + </Box> + ); +}; diff --git a/src/sections/NextEvent.tsx b/src/sections/NextEvent.tsx new file mode 100644 index 0000000..f03dd58 --- /dev/null +++ b/src/sections/NextEvent.tsx @@ -0,0 +1,72 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { COLORS } from '@/lib/tokens'; +import Box from '@mui/material/Box'; +import Container from '@mui/material/Container'; +import type { CalEvent, EventsJson } from '@/types/events'; +import { NextEventClient } from './NextEventClient'; + +async function loadUpcomingEvents(): Promise<CalEvent[]> { + try { + const raw = await readFile(join(process.cwd(), 'public', 'events.json'), 'utf-8'); + const data: EventsJson = JSON.parse(raw); + return data.upcoming; // already sorted ascending by start date + } catch { + return []; + } +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('es-CL', { + timeZone: 'America/Santiago', + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + }); +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString('es-CL', { + timeZone: 'America/Santiago', + hour: '2-digit', + minute: '2-digit', + }); +} + +export const NextEvent = async () => { + const events = await loadUpcomingEvents(); + if (events.length === 0) return null; + + const [first, ...rest] = events; + + const dateLabel = formatDate(first.start); + const timeLabel = `${formatTime(first.start)} hrs`; + const shortAddress = first.location ? first.location.split(',')[0] : null; + + const otherEvents = rest.map(e => ({ + uid: e.uid, + title: e.title, + url: e.url, + dateLabel: formatDate(e.start), + timeLabel: `${formatTime(e.start)} hrs`, + })); + + return ( + <Box + component='section' + aria-labelledby='next-event-heading' + sx={{ bgcolor: COLORS.darkBg, py: { xs: 8, md: 12 } }} + > + <Container maxWidth='xl' sx={{ px: { xs: 4, md: 8, lg: 12 } }}> + <NextEventClient + event={first} + dateLabel={dateLabel} + timeLabel={timeLabel} + shortAddress={shortAddress} + otherEvents={otherEvents} + /> + </Container> + </Box> + ); +}; diff --git a/src/sections/NextEventClient.tsx b/src/sections/NextEventClient.tsx new file mode 100644 index 0000000..4075d4c --- /dev/null +++ b/src/sections/NextEventClient.tsx @@ -0,0 +1,251 @@ +'use client'; +import { motion } from 'framer-motion'; +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import { Stack } from '@mui/material'; +import { CalendarDots, MapPin, ArrowRight } from '@phosphor-icons/react'; +import { jersey10 } from '@/lib/fonts'; +import { EASE, COLORS } from '@/lib/tokens'; +import { AnimatedButton } from '@/components/AnimatedButton'; +import type { CalEvent } from '@/types/events'; + +interface OtherEvent { + uid: string; + title: string; + url: string; + dateLabel: string; + timeLabel: string; +} + +interface Props { + event: CalEvent; + dateLabel: string; + timeLabel: string; + shortAddress: string | null; + otherEvents: OtherEvent[]; +} + +const stagger = { + hidden: {}, + show: { transition: { staggerChildren: 0.14 } }, +}; + +const slideLeft = { + hidden: { opacity: 0, x: -60 }, + show: { opacity: 1, x: 0, transition: { duration: 0.7, ease: EASE } }, +}; + +export function NextEventClient({ event, dateLabel, timeLabel, shortAddress, otherEvents }: Props) { + return ( + <Grid container spacing={6} alignItems='center'> + {/* Left column */} + <Grid size={{ xs: 12, md: 6 }}> + <motion.div + variants={stagger} + initial='hidden' + whileInView='show' + viewport={{ once: true, margin: '-80px' }} + > + <motion.div variants={slideLeft}> + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 700, + fontSize: '0.78rem', + letterSpacing: '0.15em', + textTransform: 'uppercase', + color: COLORS.yellow, + mb: 1.5, + }} + > + Próximo evento + </Typography> + </motion.div> + + <motion.div variants={slideLeft}> + <Typography + id='next-event-heading' + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '2.8rem', sm: '3.5rem', md: '4.375rem' }, + lineHeight: 0.85, + color: '#fff', + mb: 2, + }} + > + {event.title} + </Typography> + </motion.div> + + <motion.div variants={slideLeft}> + <Stack direction='row' alignItems='center' spacing={1} sx={{ mb: shortAddress ? 1 : 3 }}> + <CalendarDots size={18} color={COLORS.yellow} weight='fill' aria-hidden='true' /> + <Typography + sx={{ + fontFamily: 'Inter', + fontSize: '0.95rem', + color: 'rgba(255,255,255,0.8)', + textTransform: 'capitalize', + }} + > + {dateLabel} · {timeLabel} + </Typography> + </Stack> + </motion.div> + + {shortAddress && ( + <motion.div variants={slideLeft}> + <Stack direction='row' alignItems='center' spacing={1} sx={{ mb: 3 }}> + <MapPin size={18} color={COLORS.yellow} weight='fill' aria-hidden='true' /> + <Typography + sx={{ + fontFamily: 'Inter', + fontSize: '0.95rem', + color: 'rgba(255,255,255,0.6)', + }} + > + {shortAddress} + </Typography> + </Stack> + </motion.div> + )} + + <motion.div variants={slideLeft}> + <AnimatedButton + variant='contained' + href={event.url} + target='_blank' + rel='noopener noreferrer' + hoverColor={COLORS.yellowHover} + sx={{ + px: 4, + py: 1.6, + fontSize: '1rem', + fontWeight: 700, + bgcolor: COLORS.yellow, + color: COLORS.black, + }} + > + <Stack direction='row' alignItems='center' spacing={1}> + <CalendarDots size={20} weight='fill' aria-hidden='true' /> + <span>Inscribirme GRATIS</span> + </Stack> + </AnimatedButton> + </motion.div> + + {/* Upcoming events after the main one */} + {otherEvents.length > 0 && ( + <motion.div variants={slideLeft}> + <Box + sx={{ + mt: 4, + pt: 3, + borderTop: '1px solid rgba(255,255,255,0.1)', + }} + > + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 700, + fontSize: '0.72rem', + letterSpacing: '0.12em', + textTransform: 'uppercase', + color: 'rgba(255,255,255,0.4)', + mb: 2, + }} + > + También próximamente + </Typography> + + <Stack spacing={1.5}> + {otherEvents.map(e => ( + <Box + key={e.uid} + component='a' + href={e.url} + target='_blank' + rel='noopener noreferrer' + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 2, + textDecoration: 'none', + p: 1.5, + borderRadius: '10px', + border: '1px solid rgba(255,255,255,0.08)', + transition: 'border-color 0.2s, background 0.2s', + '&:hover': { + borderColor: 'rgba(255,255,255,0.2)', + bgcolor: 'rgba(255,255,255,0.04)', + }, + }} + > + <Box sx={{ minWidth: 0 }}> + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 600, + fontSize: '0.88rem', + color: '#fff', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + > + {e.title} + </Typography> + <Typography + sx={{ + fontFamily: 'Inter', + fontSize: '0.75rem', + color: 'rgba(255,255,255,0.4)', + textTransform: 'capitalize', + mt: 0.25, + }} + > + {e.dateLabel} · {e.timeLabel} + </Typography> + </Box> + <ArrowRight size={16} color='rgba(255,255,255,0.3)' style={{ flexShrink: 0 }} /> + </Box> + ))} + </Stack> + </Box> + </motion.div> + )} + </motion.div> + </Grid> + + {/* Right column — cover image */} + <Grid size={{ xs: 12, md: 6 }}> + <motion.div + initial={{ opacity: 0, x: 80, rotate: 3 }} + whileInView={{ opacity: 1, x: 0, rotate: 0 }} + viewport={{ once: true, margin: '-80px' }} + transition={{ type: 'spring', damping: 16, stiffness: 90, delay: 0.2 }} + style={{ display: 'flex', justifyContent: 'center' }} + > + <Box + role='img' + aria-label={`Portada: ${event.title}`} + sx={{ + width: { xs: '100%', md: '75%' }, + aspectRatio: '1/1', + borderRadius: '24px', + overflow: 'hidden', + backgroundImage: event.cover_url + ? `url(${event.cover_url})` + : 'url(/assets/default.webp)', + backgroundSize: 'cover', + backgroundPosition: 'center', + }} + /> + </motion.div> + </Grid> + </Grid> + ); +} diff --git a/src/sections/NuestrosValores.tsx b/src/sections/NuestrosValores.tsx new file mode 100644 index 0000000..95ff7f2 --- /dev/null +++ b/src/sections/NuestrosValores.tsx @@ -0,0 +1,154 @@ +'use client'; +import { jersey10 } from '@/lib/fonts'; +import { EASE, COLORS } from '@/lib/tokens'; + +import { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Container from '@mui/material/Container'; +import { motion } from 'framer-motion'; +import { Gavel } from '@phosphor-icons/react'; +import { CodeOfConductModal } from './cards/code-of-conduct/CodeOfConductModal'; +import { AnimatedButton } from '@/components/AnimatedButton'; +import { Stack } from '@mui/material'; + + +const stagger = { + hidden: {}, + show: { transition: { staggerChildren: 0.12 } }, +}; +const item = { + hidden: { opacity: 0, y: 24 }, + show: { opacity: 1, y: 0, transition: { duration: 0.65, ease: EASE } }, +}; + +export const NuestrosValores = () => { + const [modalOpen, setModalOpen] = useState(false); + + return ( + <> + <Box + id='valores' + component='section' + sx={{ bgcolor: COLORS.darkBg, pt: { xs: 8, md: 12 }, pb: { xs: 3, md: 4 } }} + > + <Container maxWidth='lg' sx={{ px: { xs: 4, md: 10, lg: 16 } }}> + {/* Row 1 — title + button */} + <motion.div + variants={item} + initial='hidden' + whileInView='show' + viewport={{ once: true, margin: '-80px' }} + > + <motion.div variants={item}> + <Box + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: 3, + mb: { xs: 3, md: 3 }, + }} + > + <Typography + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '2.8rem', sm: '3.5rem', md: '4.375rem' }, + color: COLORS.yellow, + lineHeight: 1, + }} + > + Nuestros valores + </Typography> + + <AnimatedButton + variant='contained' + color='primary' + onClick={() => setModalOpen(true)} + hoverColor={COLORS.yellowHover} + sx={{ + bgcolor: COLORS.yellow, + color: COLORS.black, + px: 4, + py: 1.6, + fontSize: '1rem', + fontWeight: 700, + whiteSpace: 'nowrap', + }} + > + <Stack direction='row' alignItems='center' spacing={1.5}> + <Gavel size={20} weight='fill' /> + <span>Ver código de conducta completo</span> + </Stack> + </AnimatedButton> + </Box> + </motion.div> + </motion.div> + + {/* Row 2 — paragraphs */} + <motion.div + variants={stagger} + initial='hidden' + whileInView='show' + viewport={{ once: true, margin: '-60px' }} + > + <motion.div variants={item}> + <Typography + sx={{ + fontFamily: 'Inter', + fontSize: { xs: '1rem', md: '1.05rem' }, + color: 'rgba(255,255,255,0.8)', + lineHeight: 1.75, + mb: 3, + }} + > + Queremos que cada evento sea un espacio seguro y libre de acoso{' '} + <Box component='strong' sx={{ color: '#fff' }}> + para todos + </Box> + , sin importar género, orientación sexual, raza, capacidades, apariencia física o + creencias. + </Typography> + </motion.div> + + <motion.div variants={item}> + <Typography + sx={{ + fontFamily: 'Inter', + fontSize: { xs: '1rem', md: '1.05rem' }, + color: 'rgba(255,255,255,0.8)', + lineHeight: 1.75, + mb: 3, + }} + > + <Box component='strong' sx={{ color: '#fff' }}> + No toleramos ningún tipo de acoso, directo o indirecto. + </Box>{' '} + Quien no respete esto será excluido del evento y de futuras instancias. + </Typography> + </motion.div> + + <motion.div variants={item}> + <Typography + sx={{ + fontFamily: 'Inter', + fontSize: { xs: '1rem', md: '1.05rem' }, + color: 'rgba(255,255,255,0.8)', + lineHeight: 1.75, + }} + > + ¿Viste o viviste algo así? Acércate a cualquier persona del staff (las + identificamos al inicio de cada evento). Te vamos a ayudar. + </Typography> + </motion.div> + </motion.div> + </Container> + </Box> + + <CodeOfConductModal open={modalOpen} onClose={() => setModalOpen(false)} /> + </> + ); +}; diff --git a/src/sections/PastEventsCarousel.tsx b/src/sections/PastEventsCarousel.tsx new file mode 100644 index 0000000..2713af5 --- /dev/null +++ b/src/sections/PastEventsCarousel.tsx @@ -0,0 +1,62 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { jersey10 } from '@/lib/fonts'; +import { COLORS } from '@/lib/tokens'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { PastEventsCarouselClient } from './PastEventsCarouselClient'; +import { FadeUp } from '@/components/ScrollReveal'; +import type { CalEvent, EventsJson } from '@/types/events'; + +async function loadPastEvents(): Promise<CalEvent[]> { + try { + const raw = await readFile(join(process.cwd(), 'public', 'events.json'), 'utf-8'); + const data: EventsJson = JSON.parse(raw); + return data.past; + } catch { + return []; + } +} + +export const PastEventsCarousel = async () => { + const events = await loadPastEvents(); + + return ( + <Box + component='section' + aria-labelledby='past-events-heading' + sx={{ bgcolor: COLORS.darkBg, py: { xs: 8, md: 10 }, overflow: 'hidden' }} + > + <FadeUp> + <Box sx={{ textAlign: 'center', mb: 6, px: 4 }}> + <Typography + id='past-events-heading' + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '2.8rem', sm: '3.5rem', md: '4.375rem' }, + lineHeight: 1, + color: '#fff', + mb: 0.5, + }} + > + Lo que nos perdimos juntos + </Typography> + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 400, + fontSize: '1rem', + color: 'rgba(255,255,255,0.6)', + }} + > + {events.length} eventos desde que empezamos + </Typography> + </Box> + </FadeUp> + + <PastEventsCarouselClient events={events} /> + </Box> + ); +}; diff --git a/src/sections/PastEventsCarouselClient.tsx b/src/sections/PastEventsCarouselClient.tsx new file mode 100644 index 0000000..5c994b0 --- /dev/null +++ b/src/sections/PastEventsCarouselClient.tsx @@ -0,0 +1,209 @@ +'use client'; +import { COLORS } from '@/lib/tokens'; +import { useRef } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { motion, useMotionValue, useAnimationFrame } from 'framer-motion'; +import { Images } from '@phosphor-icons/react'; +import { AnimatedButton } from '@/components/AnimatedButton'; +import { Stack } from '@mui/material'; +import type { CalEvent } from '@/types/events'; + +const CARD_WIDTH = 320; +const IMAGE_SIZE = 116; +const GAP = 14; +const ITEM_WIDTH = CARD_WIDTH + GAP; + +function EventCard({ event }: { event: CalEvent }) { + const date = new Date(event.start).toLocaleDateString('es-CL', { + timeZone: 'America/Santiago', + weekday: 'long', + day: 'numeric', + month: 'long', + }); + + const card = ( + <Box + sx={{ + width: CARD_WIDTH, + height: IMAGE_SIZE, + borderRadius: '14px', + overflow: 'hidden', + bgcolor: COLORS.oliveDark, + flexShrink: 0, + display: 'flex', + flexDirection: 'row', + transition: 'transform 0.2s', + '&:hover': { transform: 'scale(1.02)' }, + }} + > + <Box + role='img' + aria-label={`Portada de ${event.title}`} + sx={{ + width: IMAGE_SIZE, + height: IMAGE_SIZE, + flexShrink: 0, + backgroundImage: event.cover_url + ? `url(${event.cover_url})` + : 'url(/assets/default.webp)', + backgroundSize: 'cover', + backgroundPosition: 'center', + }} + /> + <Box + sx={{ + px: 2, + py: 1.5, + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + flex: 1, + minWidth: 0, + }} + > + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 700, + fontSize: '0.88rem', + color: '#fff', + lineHeight: 1.35, + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }} + > + {event.title} + </Typography> + + <Typography + sx={{ + fontFamily: 'Inter', + fontSize: '0.75rem', + color: '#fff', + textTransform: 'capitalize', + mt: 0.5, + }} + > + {date} + </Typography> + + {event.location && ( + <Typography + sx={{ + fontFamily: 'Inter', + fontSize: '0.7rem', + color: 'rgba(255,255,255,0.75)', + mt: 0.5, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + > + {event.location.split(',')[0]} + </Typography> + )} + </Box> + </Box> + ); + + return ( + <a + href={event.url} + target='_blank' + rel='noopener noreferrer' + aria-label={`Ver evento: ${event.title}, ${date}`} + style={{ display: 'contents', textDecoration: 'none' }} + > + {card} + </a> + ); +} + +function InfiniteRow({ events, reversed }: { events: CalEvent[]; reversed: boolean }) { + const totalWidth = ITEM_WIDTH * events.length; + // reversed row moves right (x: -totalWidth → 0), normal moves left (x: 0 → -totalWidth) + const x = useMotionValue(reversed ? -totalWidth : 0); + const paused = useRef(false); + // px per ms to complete one full loop in `events.length * 14` seconds + const speed = totalWidth / (events.length * 14 * 1000); + + useAnimationFrame((_, delta) => { + if (paused.current) return; + let current = x.get(); + if (reversed) { + current += speed * delta; + if (current >= 0) current -= totalWidth; + } else { + current -= speed * delta; + if (current <= -totalWidth) current += totalWidth; + } + x.set(current); + }); + + const tripled = [...events, ...events, ...events]; + + return ( + <Box sx={{ overflow: 'hidden', width: '100%', py: 0.75 }}> + <motion.div + style={{ display: 'flex', gap: GAP, width: 'fit-content', x }} + onMouseEnter={() => { paused.current = true; }} + onMouseLeave={() => { paused.current = false; }} + > + {tripled.map((event, i) => ( + <EventCard key={`${event.uid}-${i}`} event={event} /> + ))} + </motion.div> + </Box> + ); +} + +export function PastEventsCarouselClient({ events }: { events: CalEvent[] }) { + const half = Math.ceil(events.length / 2); + const row1 = events.slice(0, half); + const row2 = events.slice(half); + + return ( + <> + <Box sx={{ position: 'relative', overflow: 'hidden' }}> + <InfiniteRow events={row1.length >= 3 ? row1 : events} reversed={false} /> + <InfiniteRow events={row2.length >= 3 ? row2 : events} reversed={true} /> + + <Box + aria-hidden='true' + sx={{ + position: 'absolute', top: 0, left: 0, width: 140, height: '100%', + background: `linear-gradient(to right, ${COLORS.black}, transparent)`, + pointerEvents: 'none', zIndex: 2, + }} + /> + <Box + aria-hidden='true' + sx={{ + position: 'absolute', top: 0, right: 0, width: 140, height: '100%', + background: `linear-gradient(to left, ${COLORS.black}, transparent)`, + pointerEvents: 'none', zIndex: 2, + }} + /> + </Box> + + <Box sx={{ display: 'flex', justifyContent: 'center', mt: 5 }}> + <AnimatedButton + variant='contained' + href='https://gallery.jsconf.cl/' + target='_blank' + rel='noopener noreferrer' + hoverColor={COLORS.yellowHover} + sx={{ bgcolor: COLORS.yellow, color: COLORS.textPrimary, px: 4, py: 1.75, fontSize: '1rem' }} + > + <Stack direction='row' alignItems='center' spacing={1.5}> + <Images size={20} weight='fill' /> + <span>Revive los momentos</span> + </Stack> + </AnimatedButton> + </Box> + </> + ); +} diff --git a/src/sections/SpeakersSection.tsx b/src/sections/SpeakersSection.tsx new file mode 100644 index 0000000..6f38b26 --- /dev/null +++ b/src/sections/SpeakersSection.tsx @@ -0,0 +1,273 @@ +'use client'; +import { jersey10 } from '@/lib/fonts'; +import { EASE, COLORS } from '@/lib/tokens'; +import { Speaker } from '@/lib/fetchSpeakers'; + +import { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Container from '@mui/material/Container'; +import Grid from '@mui/material/Grid'; +import IconButton from '@mui/material/IconButton'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import { motion } from 'framer-motion'; +import { PaperPlaneTilt, UsersThree, LinkedinLogo, GithubLogo, InstagramLogo, Globe } from '@phosphor-icons/react'; +import { SpeakersModal } from './speakers/SpeakersModal'; +import { AnimatedButton } from '@/components/AnimatedButton'; + +interface SpeakersSectionProps { + sectionSpeakers: Speaker[]; + allSpeakers: Speaker[]; +} + +const stagger = { + hidden: {}, + show: { transition: { staggerChildren: 0.07 } }, +}; +const cardAnim = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0, transition: { duration: 0.5, ease: EASE } }, +}; + +const ICON_SX = { + color: COLORS.yellow, + bgcolor: 'rgba(255,255,255,0.08)', + borderRadius: '10px', + '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, +} as const; + +function SocialLinks({ speaker, size = 20 }: { speaker: Speaker; size?: number }) { + const links = [ + { href: speaker.linkedin, Icon: LinkedinLogo, label: 'LinkedIn' }, + { href: speaker.github, Icon: GithubLogo, label: 'GitHub' }, + { href: speaker.instagram, Icon: InstagramLogo, label: 'Instagram' }, + { href: speaker.website, Icon: Globe, label: 'Sitio web' }, + ].filter((l) => l.href); + + if (!links.length) return null; + + return ( + <Stack direction='row' spacing={0.5} mt={0.75} role='list'> + {links.map(({ href, Icon, label }) => ( + <Link + key={href} + href={href} + target='_blank' + rel='noopener noreferrer' + aria-label={`${label} del speaker (abre en nueva pestaña)`} + role='listitem' + > + <IconButton size='small' tabIndex={-1} aria-hidden sx={ICON_SX}> + <Icon size={size} weight='fill' /> + </IconButton> + </Link> + ))} + </Stack> + ); +} + +function SpeakerCard({ speaker }: { speaker: Speaker }) { + return ( + <motion.div variants={cardAnim} style={{ height: '100%' }}> + <Box + sx={{ + display: 'flex', + alignItems: 'center', + gap: 2, + bgcolor: COLORS.darkOliveCard, + borderRadius: '20px', + p: 2, + height: '100%', + boxSizing: 'border-box', + }} + > + <Box + sx={{ + width: 72, + height: 72, + flexShrink: 0, + borderRadius: '10px', + backgroundImage: `url(${speaker.avatar ?? '/assets/default.webp'})`, + backgroundSize: 'cover', + backgroundPosition: 'center', + }} + /> + <Box sx={{ minWidth: 0 }}> + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 700, + fontSize: '0.92rem', + color: COLORS.yellow, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + > + {speaker.name} + </Typography> + <SocialLinks speaker={speaker} size={16} /> + </Box> + </Box> + </motion.div> + ); +} + +export const SpeakersSection = ({ sectionSpeakers, allSpeakers }: SpeakersSectionProps) => { + const [modalOpen, setModalOpen] = useState(false); + + return ( + <> + <Box + id='speakers' + component='section' + sx={{ bgcolor: COLORS.darkOlive, py: { xs: 8, md: 10 } }} + > + <Container maxWidth='lg' sx={{ px: { xs: 4, md: 6 } }}> + <motion.div + initial={{ opacity: 0, y: 30 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: '-60px' }} + transition={{ duration: 0.7, ease: EASE }} + > + <Box + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: { xs: 'center', md: 'space-between' }, + flexDirection: { xs: 'column', md: 'row' }, + gap: 2, + mb: { xs: 2, md: 0 }, + }} + > + <Typography + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '2.8rem', sm: '3.5rem', md: '4.375rem' }, + color: COLORS.yellow, + lineHeight: 1, + textAlign: { xs: 'center', md: 'left' }, + }} + > + Speakers + </Typography> + + <AnimatedButton + variant='outlined' + onClick={() => setModalOpen(true)} + hoverColor='rgba(0, 0, 0, 0.2)' + sx={{ + bgcolor: 'transparent', + color: COLORS.yellow, + border: `1px solid ${COLORS.yellow}`, + px: 4, + py: 1.6, + fontSize: '1rem', + fontWeight: 700, + whiteSpace: 'nowrap', + '&:hover': { border: `1px solid ${COLORS.yellow}` }, + }} + > + <Stack direction='row' alignItems='center' spacing={1.5}> + <UsersThree size={18} weight='fill' /> + <span>Ver todos los speakers</span> + </Stack> + </AnimatedButton> + </Box> + + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 400, + fontSize: { xs: '1rem', md: '1.1rem' }, + color: 'rgba(255,255,255,0.7)', + maxWidth: '100%', + lineHeight: 1.65, + textAlign: { xs: 'center', md: 'left' }, + mb: { xs: 2, md: 2 }, + }} + > + Devs, diseñadores, CTOs y builders que subieron al escenario para que tú no tengas que aprenderlo solo. + </Typography> + </motion.div> + + <motion.div + variants={stagger} + initial='hidden' + whileInView='show' + viewport={{ once: true, margin: '-40px' }} + > + <Grid container spacing={2}> + {sectionSpeakers.map((speaker) => ( + <Grid key={speaker.id} size={{ xs: 12, sm: 6, md: 4 }}> + <SpeakerCard speaker={speaker} /> + </Grid> + ))} + </Grid> + </motion.div> + + <motion.div + initial={{ opacity: 0, y: 20 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true }} + transition={{ duration: 0.6, ease: EASE }} + > + <Box + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: { xs: 'center', md: 'space-between' }, + flexDirection: { xs: 'column', md: 'row' }, + gap: 3, + mt: { xs: 5, md: 7 }, + }} + > + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 600, + fontSize: { xs: '1rem', md: '1.1rem' }, + color: '#fff', + textAlign: { xs: 'center', md: 'left' }, + }} + > + ¿Tú también tienes algo que enseñar? + </Typography> + + <AnimatedButton + variant='contained' + color='primary' + href='https://tally.so/r/3x4Q1d' + target='_blank' + hoverColor={COLORS.yellowHover} + sx={{ + bgcolor: COLORS.yellow, + color: COLORS.black, + px: 4, + py: 1.6, + fontSize: '1rem', + fontWeight: 700, + whiteSpace: 'nowrap', + }} + > + <Stack direction='row' alignItems='center' spacing={1.5}> + <PaperPlaneTilt size={18} weight='fill' /> + <span>Enviar mi propuesta</span> + </Stack> + </AnimatedButton> + </Box> + </motion.div> + </Container> + </Box> + + <SpeakersModal + open={modalOpen} + onClose={() => setModalOpen(false)} + speakers={allSpeakers} + /> + </> + ); +}; diff --git a/src/sections/SpeakersSectionWrapper.tsx b/src/sections/SpeakersSectionWrapper.tsx new file mode 100644 index 0000000..fe115b2 --- /dev/null +++ b/src/sections/SpeakersSectionWrapper.tsx @@ -0,0 +1,8 @@ +import { fetchSpeakers } from '@/lib/fetchSpeakers'; +import { SpeakersSection } from './SpeakersSection'; + +export async function SpeakersSectionWrapper() { + const speakers = await fetchSpeakers(); + const sectionSpeakers = speakers.filter((s) => s.showInSection); + return <SpeakersSection sectionSpeakers={sectionSpeakers} allSpeakers={speakers} />; +} diff --git a/src/sections/StatsSection.tsx b/src/sections/StatsSection.tsx new file mode 100644 index 0000000..60f901b --- /dev/null +++ b/src/sections/StatsSection.tsx @@ -0,0 +1,194 @@ +'use client'; +import { EASE, COLORS } from '@/lib/tokens'; +import { jersey10 } from '@/lib/fonts'; + +import { useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { motion, useInView } from 'framer-motion'; + + +const BORDER_SUBTLE = '8px solid rgba(220, 215, 200, 0.85)'; +const BORDER_WHITE = '8px solid rgba(255, 255, 255, 1)'; + +/* ── Count-up animado ── */ +function CountUp({ target, suffix = '' }: { target: number; suffix?: string }) { + const [count, setCount] = useState(0); + const ref = useRef(null); + const inView = useInView(ref, { once: true }); + + useEffect(() => { + if (!inView) return; + let frame = 0; + const total = 60; + const timer = setInterval(() => { + frame++; + const progress = frame / total; + // ease-out cubic + const eased = 1 - Math.pow(1 - progress, 3); + setCount(Math.round(eased * target)); + if (frame >= total) clearInterval(timer); + }, 20); + return () => clearInterval(timer); + }, [inView, target]); + + return <span ref={ref}>{count.toLocaleString('es-CL')}{suffix}</span>; +} + +const STATS = [ + { prefix: '+', target: 120, label: 'Eventos', sub: 'desde 2013' }, + { prefix: '+', target: 4800, label: 'Asistentes', sub: 'desde 2013' }, + { prefix: '+', target: 200, label: 'Speakers', sub: 'desde 2013' }, + { prefix: '+', target: 1500, suffix: ' en', label: 'WhatsApp', sub: 'desde 2013' }, +]; + +const PHOTOS = [ + { + src: '/assets/events/group-crowd.webp', + alt: 'Asistentes en evento JSChile', + border: BORDER_SUBTLE, + containerSx: { + transform: 'rotate(-7.27deg)', + zIndex: 1, + width: { xs: 180, sm: 280, md: 370 }, + marginRight: { xs: '-50px', md: '-100px' }, + }, + fromRotate: -20, + delay: 0.1, + }, + { + src: '/assets/events/group-selfie.webp', + alt: 'JSConf Chile — foto grupal', + border: BORDER_WHITE, + containerSx: { + transform: { xs: 'rotate(-0.42deg) translateY(-30px)', md: 'rotate(-0.42deg) translateY(-70px)' }, + zIndex: 3, + width: { xs: 220, sm: 340, md: 480 }, + }, + fromRotate: 8, + delay: 0.5, + }, + { + src: '/assets/events/group-stage.webp', + alt: 'Escenario JSConf Chile', + border: BORDER_SUBTLE, + containerSx: { + transform: 'rotate(7.2deg)', + zIndex: 1, + width: { xs: 180, sm: 280, md: 370 }, + marginLeft: { xs: '-50px', md: '-100px' }, + }, + fromRotate: 20, + delay: 0.3, + }, +]; + +export const StatsSection = () => { + return ( + <Box id='comunidad' component='section' aria-label='Datos de la comunidad' sx={{ pb: { xs: 8, md: 14 } }}> + + {/* ── Fotos rotadas ── */} + <Box + sx={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + pl: '48px', + pr: '48px', + pb: '20px', + mb: '0px', + pt: '200px', + minHeight: '460px', + }} + > + {PHOTOS.map((photo) => ( + <motion.div + key={photo.src} + initial={{ opacity: 0, y: -160, rotate: photo.fromRotate }} + whileInView={{ opacity: 1, y: 0, rotate: 0 }} + viewport={{ once: true, margin: '-60px' }} + transition={{ type: 'spring', damping: 13, stiffness: 85, delay: photo.delay }} + style={{ display: 'flex', position: 'relative', zIndex: photo.containerSx.zIndex as number }} + > + <Box + sx={{ + ...photo.containerSx, + flexShrink: 0, + aspectRatio: '4/3', + borderRadius: '25px', + overflow: 'hidden', + border: photo.border, + boxShadow: '4px 10px 36px rgba(0,0,0,0.65)', + }} + > + <Box + component='img' + src={photo.src} + alt={photo.alt} + sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} + /> + </Box> + </motion.div> + ))} + </Box> + + {/* ── Stats con count-up ── */} + <Box + sx={{ + display: 'grid', + gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' }, + px: { xs: 6, md: 12, lg: 16 }, + }} + > + {STATS.map((stat, i) => ( + <motion.div + key={stat.label} + initial={{ opacity: 0, y: 40 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: '-40px' }} + transition={{ duration: 0.6, delay: i * 0.1, ease: EASE }} + > + <Box + sx={{ + px: { xs: 2, md: 4 }, + py: { xs: 3, md: 2 }, + borderLeft: i === 0 + ? 'none' + : { xs: i % 2 === 0 ? 'none' : '1px solid rgba(255,255,255,0.2)', md: '1px solid rgba(255,255,255,0.2)' }, + borderTop: i < 2 + ? 'none' + : { xs: '1px solid rgba(255,255,255,0.2)', md: 'none' }, + }} + > + <Typography + sx={{ + fontFamily: jersey10.style.fontFamily, + fontSize: { xs: '1.6rem', md: '2.2rem', lg: '2.8rem' }, + color: COLORS.yellow, + lineHeight: 1.05, + }} + > + {stat.prefix} + <CountUp target={stat.target} suffix={stat.suffix} /> + </Typography> + <Typography + sx={{ + fontFamily: jersey10.style.fontFamily, + fontSize: { xs: '1.5rem', md: '2rem', lg: '2.5rem' }, + color: COLORS.yellow, + lineHeight: 1.15, + mb: 0.5, + }} + > + {stat.label} + </Typography> + <Typography sx={{ fontSize: { xs: '0.85rem', md: '1rem' }, color: 'rgba(255,255,255,0.55)' }}> + {stat.sub} + </Typography> + </Box> + </motion.div> + ))} + </Box> + </Box> + ); +}; diff --git a/src/sections/WhatsAppCTA.tsx b/src/sections/WhatsAppCTA.tsx new file mode 100644 index 0000000..994b374 --- /dev/null +++ b/src/sections/WhatsAppCTA.tsx @@ -0,0 +1,102 @@ +'use client'; +import { jersey10 } from '@/lib/fonts'; +import { EASE, COLORS } from '@/lib/tokens'; + +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { motion } from 'framer-motion'; +import { AnimatedButton } from '@/components/AnimatedButton'; +import { useCommunityModal } from '@/providers/CommunityModalProvider'; +import { WhatsappLogo } from '@phosphor-icons/react'; +import { Stack } from '@mui/material'; + + +export const WhatsAppCTA = () => { + const { openModal } = useCommunityModal(); + + return ( + <> + <Box + component='section' + sx={{ + backgroundImage: 'url(/assets/banner-inivitation-whatsapp.webp)', + backgroundSize: 'cover', + backgroundPosition: 'center top', + backgroundRepeat: 'no-repeat', + minHeight: { xs: '130vw', sm: '55vw', md: '45vw' }, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'flex-start', + textAlign: 'center', + pt: { xs: 5, md: 8 }, + pb: { xs: 8, md: 10 }, + px: { xs: 4, md: 8 }, + }} + > + {/* Icono oscilante */} + <motion.div + initial={{ scale: 0, rotate: -30, opacity: 0 }} + whileInView={{ scale: 1, rotate: [null, 15, -10, 8, -4, 0], opacity: 1 }} + viewport={{ once: true }} + transition={{ duration: 0.9, ease: 'easeOut' }} + > + <Box sx={{ mb: 1 }}> + <WhatsappLogo size={64} weight='fill' color={COLORS.black} /> + </Box> + </motion.div> + + {/* Título */} + <motion.div + initial={{ opacity: 0, y: 40 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true }} + transition={{ duration: 0.7, delay: 0.2, ease: EASE }} + > + <Typography + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '2.8rem', sm: '3.8rem', md: '5rem' }, + lineHeight: 1.1, + color: COLORS.textPrimary, + mb: 4, + }} + > + Únete a donde pasan las cosas + </Typography> + </motion.div> + + {/* Botón con spring */} + <motion.div + initial={{ opacity: 0, scale: 0.7 }} + whileInView={{ opacity: 1, scale: 1 }} + viewport={{ once: true }} + transition={{ type: 'spring', damping: 14, stiffness: 120, delay: 0.35 }} + > + <AnimatedButton + variant='contained' + color='secondary' + onClick={openModal} + hoverColor={COLORS.charcoal} + sx={{ + px: 4, + py: 1.6, + fontSize: '1rem', + fontWeight: 700, + bgcolor: COLORS.black, + color: '#fff', + }} + > + <Stack direction='row' alignItems='center' spacing={1}> + <WhatsappLogo size={20} weight='fill' /> + <span>Unirme al WhatsApp</span> + </Stack> + </AnimatedButton> + </motion.div> + </Box> + + </> + ); +}; diff --git a/src/sections/cards/CardsContainer.tsx b/src/sections/cards/CardsContainer.tsx index c0f4fb4..071265f 100644 --- a/src/sections/cards/CardsContainer.tsx +++ b/src/sections/cards/CardsContainer.tsx @@ -9,7 +9,7 @@ import { Gallery } from './gallery/Gallery'; export const CardsContainer = () => { return ( - <Container maxWidth='xl' sx={{ px: { xs: 4, md: 8, lg: 12 }, mb: 4 }}> + <Container id='comunidad' maxWidth='xl' sx={{ px: { xs: 4, md: 8, lg: 12 }, mb: 4 }}> <Grid container spacing={4}> <Grid size={{ xs: 12, md: 6, lg: 3 }}> <OurStory /> diff --git a/src/sections/cards/code-of-conduct/CodeOfConductModal.tsx b/src/sections/cards/code-of-conduct/CodeOfConductModal.tsx index 5b7aeb0..6d8dd5a 100644 --- a/src/sections/cards/code-of-conduct/CodeOfConductModal.tsx +++ b/src/sections/cards/code-of-conduct/CodeOfConductModal.tsx @@ -1,10 +1,13 @@ +'use client'; +import { jersey10 } from '@/lib/fonts'; +import { COLORS } from '@/lib/tokens'; + import React from 'react'; import Modal from '@mui/material/Modal'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; -import GavelIcon from '@mui/icons-material/Gavel'; +import { X, Gavel } from '@phosphor-icons/react'; import { Stack } from '@mui/material'; interface CodeOfConductModalProps { @@ -12,27 +15,26 @@ interface CodeOfConductModalProps { onClose: () => void; } -const modalStyle = { - position: 'absolute' as const, - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: { xs: '95%', sm: 600, md: 800 }, - maxHeight: '90vh', - overflowY: 'auto', - bgcolor: 'background.paper', - borderRadius: 4, - boxShadow: 24, - p: { xs: 4, sm: 6 }, - display: 'flex', - flexDirection: 'column', - outline: 'none', -}; +const SECTIONS = [ + { + title: 'La versión más corta de todas', + body: '¿Has visto como luego de algunos eventos, todos terminan hablando de "aquella persona" que causó un mal rato a uno o más asistentes? Que no sea de ti de quien hablen.', + }, + { + title: 'La versión no tan corta', + body: 'En los eventos desarrollados por JSChile (JSConf, TechSchool, Conferencias, etc), así como en cualquiera de las plataformas de redes sociales buscamos crear un espacio libre de acoso. Todos son bienvenidos, sin importar género, raza, orientación sexual, capacidades, apariencia física y/o creencias. No toleramos el acoso bajo ningún tipo, forma ni contexto. Cualquier participante que viole estas reglas será sancionado y expulsado de este y futuros eventos.', + }, + { + title: '¿Qué se entiende por acoso?', + body: 'Abuso físico y/o verbal relacionados al género, orientación sexual, capacidades, apariencia física, nivel de conocimiento, raza y/o creencias religiosas. Creación y/o reproducción de imágenes de contenido sexual en espacios públicos, intimidación, acecho, interrupciones groseras de charlas y/o contacto físico inapropiado.', + }, + { + title: '¿Qué pasa si alguien viola el código?', + body: 'La organización se reserva el derecho de solicitar que cualquier comportamiento relacionado a lo anterior sea detenido inmediatamente. Quienes atenten contra este código podrán ser expulsados de este y futuros eventos. Si eres víctima o testigo de acoso, por favor contacta a un miembro de la organización de forma inmediata.', + }, +]; -export const CodeOfConductModal: React.FC<CodeOfConductModalProps> = ({ - open, - onClose, -}) => { +export const CodeOfConductModal: React.FC<CodeOfConductModalProps> = ({ open, onClose }) => { return ( <Modal open={open} @@ -41,112 +43,95 @@ export const CodeOfConductModal: React.FC<CodeOfConductModalProps> = ({ aria-describedby='code-of-conduct-modal-description' disableScrollLock > - <Box sx={modalStyle}> + <Box + sx={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: { xs: '95%', sm: 600, md: 780 }, + maxHeight: '90vh', + overflowY: 'auto', + bgcolor: COLORS.darkOlive, + borderRadius: '24px', + boxShadow: 24, + p: { xs: 3, md: 5 }, + display: 'flex', + flexDirection: 'column', + outline: 'none', + }} + > <IconButton onClick={onClose} - sx={{ position: 'absolute', right: 16, top: 16 }} aria-label='Cerrar modal' + sx={{ + position: 'absolute', + right: 16, + top: 16, + color: '#fff', + bgcolor: 'rgba(255,255,255,0.1)', + '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, + }} > - <CloseIcon /> + <X size={20} weight='fill' /> </IconButton> - <Stack - direction='row' - alignItems='center' - justifyContent='flex-start' - spacing={1.5} - sx={{ mb: 2 }} - > - <GavelIcon sx={{ color: 'primary.main', fontSize: '2.5rem' }} /> - - <Typography variant='h5' component='h2'> - Código de Conducta - </Typography> - </Stack> - - <Box> - <Typography variant='h6' sx={{ mt: 3, mb: 1, color: '#000000' }}> - La versión más corta de todas - </Typography> + <Stack direction='row' alignItems='center' spacing={1.5} sx={{ mb: 1, pr: 4 }}> + <Gavel size={32} weight='fill' color={COLORS.yellow} aria-hidden /> <Typography - variant='body1' - color='text.secondary' - sx={{ lineHeight: 1.6 }} + id='code-of-conduct-modal-title' + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '1.8rem', md: '2.4rem' }, + color: COLORS.yellow, + lineHeight: 1, + }} > - ¿Has visto como luego de algunos eventos, todos terminan hablando de - “aquella persona” que causó un mal rato a uno o más asistentes?. Que - no sea de ti de quien hablen + Código de Conducta </Typography> + </Stack> - <Typography variant='h6' sx={{ mt: 4, mb: 1, color: '#000000' }}> - La versión no tan corta - </Typography> - <Typography - variant='body1' - color='text.secondary' - sx={{ lineHeight: 1.6 }} - > - En los eventos desarrollados por Javascript Chile (JSConf, - TechSchool, Conferencias, etc), así como en cualquiera de las - plataformas de redes sociales (Facebook, Slack, Discord, Twitter, - etc) buscamos crear un espacio, y una experiencia, libre de acoso. - Nuestro lema es que todos son bienvenidos, sin importar género, - raza, orientación sexual, capacidades, apariencia física y/o - creencias. No toleramos el acoso bajo ningún tipo, forma ni - contexto, directa o indirectamente. Cualquier participante que viole - estas reglas será sancionado y expulsado de este y futuros eventos - </Typography> + <Typography + id='code-of-conduct-modal-description' + sx={{ + fontFamily: 'Inter', + fontSize: '0.95rem', + color: 'rgba(255,255,255,0.6)', + mb: 4, + }} + > + Nuestro compromiso con una comunidad segura e inclusiva para todos. + </Typography> - <Typography variant='h6' sx={{ mt: 4, mb: 1, color: '#000000' }}> - La versión más larga ¿Qué se entiende por acoso? - </Typography> - <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> - <Typography - variant='body1' - color='text.secondary' - sx={{ lineHeight: 1.6 }} - > - Abuso físico y/o verbal relacionados al género de una persona, - orientación sexual, capacidades, género, apariencia física, nivel - de conocimiento, raza y/o creencias religiosas. Adicionalmente, - creación y/o reproducción de imágenes de contenido sexual en - espacios públicos, intimidación física y/o verbal, acecho, - interrupciones groseras de charlas y/o contacto físico inapropiado - </Typography> - <Typography - variant='body1' - color='text.secondary' - sx={{ lineHeight: 1.6 }} - > - La organización se reserva el derecho de solicitar que cualquier - comportamiento relacionado a lo anterior que sea visto durante un - evento, sea detenido inmediatamente por quien(es) lo realizan. - Adicionalmente, quienes atenten contra este código de conducta, - podrán ser expulsados de este y futuros eventos - </Typography> - <Typography - variant='body1' - color='text.secondary' - sx={{ lineHeight: 1.6 }} - > - Si eres víctima o testigo de algún tipo de acoso, de cualquiera - según lo detallado anteriormente, por favor contacta a un miembro - de la organización de forma inmediata. Los encargados del evento - se identificarán oportunamente al comienzo de cada instancia. Si - es necesario, la organización ayudará a contactar autoridades - policiales y/o proveerá cualquier tipo de asistencia necesaria - para remediar la situación - </Typography> - <Typography - variant='body1' - color='text.secondary' - sx={{ lineHeight: 1.6 }} - > - Somos una comunidad inclusiva que valora la diversidad. Si no - estás de acuerdo con esto, probablemente no debas estar en - nuestros eventos - </Typography> - </Box> + <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> + {SECTIONS.map(({ title, body }) => ( + <Box key={title} sx={{ bgcolor: 'rgba(255,255,255,0.05)', borderRadius: '16px', p: 3 }}> + <Typography + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: '1.3rem', + color: COLORS.yellow, + mb: 1, + lineHeight: 1, + }} + > + {title} + </Typography> + <Typography + sx={{ + fontFamily: 'Inter', + fontSize: '0.9rem', + color: 'rgba(255,255,255,0.75)', + lineHeight: 1.65, + }} + > + {body} + </Typography> + </Box> + ))} </Box> </Box> </Modal> diff --git a/src/sections/cards/gallery/Gallery.tsx b/src/sections/cards/gallery/Gallery.tsx index a4914f4..dc21f9e 100644 --- a/src/sections/cards/gallery/Gallery.tsx +++ b/src/sections/cards/gallery/Gallery.tsx @@ -1,30 +1,24 @@ 'use client'; -import React, { useState } from 'react'; import { CardActions } from '@mui/material'; -import { GalleryModal } from './GalleryModal'; import { AnimatedButton } from '@/components/AnimatedButton'; import { BaseCard } from '@/components/BaseCard'; export const Gallery = () => { - const [isModalOpen, setIsModalOpen] = useState(false); - return ( - <> - <BaseCard sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}> - <CardActions sx={{ p: 2 }}> - <AnimatedButton - variant='contained' - color='secondary' - fullWidth - onClick={() => setIsModalOpen(true)} - > - Revive los Momentos - </AnimatedButton> - </CardActions> - </BaseCard> - - <GalleryModal open={isModalOpen} onClose={() => setIsModalOpen(false)} /> - </> + <BaseCard sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}> + <CardActions sx={{ p: 2 }}> + <AnimatedButton + variant='contained' + color='secondary' + fullWidth + href='https://gallery.jsconf.cl/' + target='_blank' + rel='noopener noreferrer' + > + Revive los Momentos + </AnimatedButton> + </CardActions> + </BaseCard> ); }; diff --git a/src/sections/cards/gallery/GalleryModal.tsx b/src/sections/cards/gallery/GalleryModal.tsx index e5770d9..aa261d1 100644 --- a/src/sections/cards/gallery/GalleryModal.tsx +++ b/src/sections/cards/gallery/GalleryModal.tsx @@ -1,15 +1,16 @@ 'use client'; +import { jersey10 } from '@/lib/fonts'; +import { COLORS } from '@/lib/tokens'; import React from 'react'; import Modal from '@mui/material/Modal'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; import ImageList from '@mui/material/ImageList'; import ImageListItem from '@mui/material/ImageListItem'; import { AnimatedButton } from '@/components/AnimatedButton'; -import PhotoCameraIcon from '@mui/icons-material/PhotoCamera'; +import { X, Camera } from '@phosphor-icons/react'; import { Stack } from '@mui/material'; interface GalleryModalProps { @@ -17,47 +18,13 @@ interface GalleryModalProps { onClose: () => void; } -const modalStyle = { - position: 'absolute' as const, - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: { xs: '90%', sm: '80%', md: '70%' }, - maxWidth: '900px', - maxHeight: '90vh', - bgcolor: 'background.paper', - borderRadius: 2, - boxShadow: 24, - p: 4, - overflow: 'auto', - outline: 'none', -}; - const itemData = [ - { - img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.01.jpeg', - title: '1ra Junta Casual JS Chile Febrero 2026', - }, - { - img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-07-08.34.59.jpg', - title: '1ra Junta Casual JS Chile Febrero 2026', - }, - { - img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.12.jpeg', - title: '1ra Junta Casual JS Chile Febrero 2026', - }, - { - img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.15.jpeg', - title: '1ra Junta Casual JS Chile Febrero 2026', - }, - { - img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.18.jpeg', - title: '1ra Junta Casual JS Chile Febrero 2026', - }, - { - img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.16.jpeg', - title: '1ra Junta Casual JS Chile Febrero 2026', - }, + { img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.01.webp', title: '1ra Junta Casual JS Chile Febrero 2026' }, + { img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-07-08.34.59.webp', title: '1ra Junta Casual JS Chile Febrero 2026' }, + { img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.12.webp', title: '1ra Junta Casual JS Chile Febrero 2026' }, + { img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.15.webp', title: '1ra Junta Casual JS Chile Febrero 2026' }, + { img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.18.webp', title: '1ra Junta Casual JS Chile Febrero 2026' }, + { img: '/assets/events/2026/1ra-junta/junta-casual-2026-02-06-18.35.16.webp', title: '1ra Junta Casual JS Chile Febrero 2026' }, ]; const pattern = [ @@ -72,68 +39,91 @@ const pattern = [ function srcset(image: string, size: number, rows = 1, cols = 1) { return { src: `${image}?w=${size * cols}&h=${size * rows}&fit=crop&auto=format`, - srcSet: `${image}?w=${size * cols}&h=${ - size * rows - }&fit=crop&auto=format&dpr=2 2x`, + srcSet: `${image}?w=${size * cols}&h=${size * rows}&fit=crop&auto=format&dpr=2 2x`, }; } -export const GalleryModal: React.FC<GalleryModalProps> = ({ - open, - onClose, -}) => { +export const GalleryModal: React.FC<GalleryModalProps> = ({ open, onClose }) => { return ( <Modal open={open} onClose={onClose} aria-labelledby='gallery-modal-title' + aria-describedby='gallery-modal-desc' disableScrollLock > - <Box sx={modalStyle}> + <Box + sx={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: { xs: '95%', sm: '85%', md: '70%' }, + maxWidth: 900, + maxHeight: '90vh', + overflowY: 'auto', + bgcolor: COLORS.darkOlive, + borderRadius: '24px', + boxShadow: 24, + p: { xs: 3, md: 5 }, + outline: 'none', + }} + > <IconButton onClick={onClose} - sx={{ position: 'absolute', right: 8, top: 8 }} aria-label='Cerrar modal' + sx={{ + position: 'absolute', + right: 16, + top: 16, + color: '#fff', + bgcolor: 'rgba(255,255,255,0.1)', + '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, + }} > - <CloseIcon /> + <X size={20} weight='fill' /> </IconButton> - <Stack - direction='row' - alignItems='center' - justifyContent='flex-start' - spacing={1.5} - sx={{ mb: 2 }} - > - <PhotoCameraIcon sx={{ color: 'primary.main', fontSize: '2.5rem' }} /> - <Typography variant='h5' component='h2'> + <Stack direction='row' alignItems='center' spacing={1.5} sx={{ mb: 1, pr: 4 }}> + <Camera size={32} weight='fill' color={COLORS.yellow} aria-hidden /> + <Typography + id='gallery-modal-title' + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '1.8rem', md: '2.4rem' }, + color: COLORS.yellow, + lineHeight: 1, + }} + > Galería de Eventos </Typography> </Stack> - <Typography variant='body1' color='text.secondary' sx={{ mb: 3 }}> + <Typography + id='gallery-modal-desc' + sx={{ + fontFamily: 'Inter', + fontSize: '0.95rem', + color: 'rgba(255,255,255,0.6)', + mb: 4, + }} + > Revive los mejores momentos de nuestros meetups y conferencias. - Inspiración y aprendizaje en cada encuentro. </Typography> <Box sx={{ width: '100%', mb: 3 }}> - <ImageList variant='quilted' cols={3} rowHeight={150} gap={12}> + <ImageList variant='quilted' cols={3} rowHeight={150} gap={8}> {itemData.map((item, index) => { const layout = pattern[index % pattern.length]; return ( - <ImageListItem - key={item.img} - cols={layout.cols} - rows={layout.rows} - > + <ImageListItem key={item.img} cols={layout.cols} rows={layout.rows}> <img {...srcset(item.img, 150, layout.rows, layout.cols)} alt={item.title} loading='lazy' - style={{ - borderRadius: '8px', - objectFit: 'cover', - }} + style={{ borderRadius: '12px', objectFit: 'cover' }} /> </ImageListItem> ); @@ -143,11 +133,17 @@ export const GalleryModal: React.FC<GalleryModalProps> = ({ <AnimatedButton variant='contained' - color='secondary' fullWidth href='https://gallery.jsconf.cl/' target='_blank' rel='noopener noreferrer' + hoverColor={COLORS.yellowHover} + sx={{ + bgcolor: COLORS.yellow, + color: COLORS.textPrimary, + fontWeight: 700, + py: 1.8, + }} > Ver Galería Completa → </AnimatedButton> diff --git a/src/sections/cards/our-story/OurStoryModal.tsx b/src/sections/cards/our-story/OurStoryModal.tsx index 36f0e33..363cd23 100644 --- a/src/sections/cards/our-story/OurStoryModal.tsx +++ b/src/sections/cards/our-story/OurStoryModal.tsx @@ -1,71 +1,49 @@ +'use client'; +import { jersey10 } from '@/lib/fonts'; +import { COLORS } from '@/lib/tokens'; + import React from 'react'; import Modal from '@mui/material/Modal'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; import { Stack } from '@mui/material'; -import HistoryIcon from '@mui/icons-material/History'; +import { X, ClockCounterClockwise } from '@phosphor-icons/react'; interface OurStoryModalProps { open: boolean; onClose: () => void; } -const modalStyle = { - position: 'absolute' as const, - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: { xs: '95%', sm: 600, md: 800 }, - maxHeight: '90vh', - overflowY: 'auto', - bgcolor: 'background.paper', - borderRadius: 4, - boxShadow: 24, - p: { xs: 4, sm: 6 }, - display: 'flex', - flexDirection: 'column', - outline: 'none', -}; - const timelineData = [ { year: '2013', - title: 'Los Inicios ', - description: - 'Nace la comunidad de JavaScript Chile como un pequeño grupo de entusiastas buscando compartir conocimientos y experiencias con esta tecnología web emergente.', + title: 'Los Inicios', + description: 'Nace la comunidad de JSChile como un pequeño grupo de entusiastas buscando compartir conocimientos y experiencias con esta tecnología web emergente.', }, { year: '2016', - title: 'Crecimiento y Consolidación ', - description: - 'Los meetups mensuales comienzan a ser un éxito, atrayendo a centenares de desarrolladores. JS Chile se convierte en la principal agrupación de la tecnología en el país.', + title: 'Crecimiento y Consolidación', + description: 'Los meetups mensuales comienzan a ser un éxito, atrayendo a centenares de desarrolladores. JS Chile se convierte en la principal agrupación de la tecnología en el país.', }, { year: '2018', - title: 'Hito Histórico: JSConf Chile ', - description: - 'Se organiza la primera edición de la prestigiosa conferencia internacional JSConf en nuestro país, posicionando a Chile en el mapa global del desarrollo frontend.', + title: 'Hito Histórico: JSConf Chile', + description: 'Se organiza la primera edición de la prestigiosa conferencia internacional JSConf en nuestro país, posicionando a Chile en el mapa global del desarrollo frontend.', }, { year: '2023', - title: 'Renacimiento Pospandemia ', - description: - 'Tras unos años desafiantes, un nuevo grupo de organizadores toma la batuta para reactivar la magia de los meetups presenciales, llenando nuevamente los auditorios.', + title: 'Renacimiento Pospandemia', + description: 'Tras unos años desafiantes, un nuevo grupo de organizadores toma la batuta para reactivar la magia de los meetups presenciales, llenando nuevamente los auditorios.', }, { year: '2026', - title: 'El Futuro es Ahora ', - description: - 'Lanzamiento de la nueva identidad visual y plataforma web oficial. La comunidad más grande de desarrollo en Chile sigue construyendo un puente educativo accesible para todos.', + title: 'El Futuro es Ahora', + description: 'Lanzamiento de la nueva identidad visual y plataforma web oficial. La comunidad más grande de desarrollo en Chile sigue construyendo un puente educativo accesible para todos.', }, ]; -export const OurStoryModal: React.FC<OurStoryModalProps> = ({ - open, - onClose, -}) => { +export const OurStoryModal: React.FC<OurStoryModalProps> = ({ open, onClose }) => { return ( <Modal open={open} @@ -74,49 +52,79 @@ export const OurStoryModal: React.FC<OurStoryModalProps> = ({ aria-describedby='our-story-modal-description' disableScrollLock > - <Box sx={modalStyle}> + <Box + sx={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: { xs: '95%', sm: 600, md: 720 }, + maxHeight: '90vh', + overflowY: 'auto', + bgcolor: COLORS.darkOlive, + borderRadius: '24px', + boxShadow: 24, + p: { xs: 3, md: 5 }, + display: 'flex', + flexDirection: 'column', + outline: 'none', + }} + > <IconButton onClick={onClose} - sx={{ position: 'absolute', right: 16, top: 16 }} aria-label='Cerrar modal' + sx={{ + position: 'absolute', + right: 16, + top: 16, + color: '#fff', + bgcolor: 'rgba(255,255,255,0.1)', + '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, + }} > - <CloseIcon /> + <X size={20} weight='fill' /> </IconButton> - <Stack - direction='row' - alignItems='center' - justifyContent='flex-start' - spacing={1.5} - sx={{ mb: 2 }} - > - <HistoryIcon sx={{ color: 'primary.main', fontSize: '2.5rem' }} /> - - <Typography variant='h5' component='h2'> + <Stack direction='row' alignItems='center' spacing={1.5} sx={{ mb: 1, pr: 4 }}> + <ClockCounterClockwise size={32} weight='fill' color={COLORS.yellow} aria-hidden /> + <Typography + id='our-story-modal-title' + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '1.8rem', md: '2.4rem' }, + color: COLORS.yellow, + lineHeight: 1, + }} + > Nuestra Historia </Typography> </Stack> <Typography - variant='body1' - color='text.secondary' - sx={{ mt: 1, mb: 4, lineHeight: 1.6 }} + id='our-story-modal-description' + sx={{ + fontFamily: 'Inter', + fontSize: '0.95rem', + color: 'rgba(255,255,255,0.6)', + mb: 4, + }} > - Conoce cómo evolucionó la comunidad de JavaScript más activa de Chile - a través de los años + Cómo evolucionó la comunidad JSChile más activa de Chile a través de los años. </Typography> - {/* Timeline Minimalista Custom */} - <Box sx={{ position: 'relative', ml: { xs: 2, sm: 4 }, mt: 2 }}> - {/* Línea vertical principal */} + {/* Timeline */} + <Box sx={{ position: 'relative', ml: 2 }}> + {/* Línea vertical */} <Box sx={{ position: 'absolute', - top: 0, - bottom: 0, - left: 4, // se ajusta para que caiga al centro del círculo + top: 8, + bottom: 8, + left: 4, width: '2px', - bgcolor: 'rgba(0,0,0,0.1)', + bgcolor: 'rgba(255,255,255,0.1)', }} /> @@ -126,48 +134,43 @@ export const OurStoryModal: React.FC<OurStoryModalProps> = ({ sx={{ position: 'relative', pl: 5, - pb: index !== timelineData.length - 1 ? 5 : 2, + pb: index !== timelineData.length - 1 ? 4 : 0, }} > - {/* Punto indicador */} + {/* Punto */} <Box sx={{ position: 'absolute', left: 0, - top: 4, + top: 6, width: 10, height: 10, borderRadius: '50%', - bgcolor: '#F0DB4F', // JS Yellow - border: '2px solid white', - boxShadow: '0 0 0 2px rgba(0,0,0,0.1)', + bgcolor: COLORS.yellow, + border: `2px solid ${COLORS.darkOlive}`, + boxShadow: `0 0 0 2px ${COLORS.yellow}`, zIndex: 1, }} /> - <Box sx={{ display: 'flex', flexDirection: 'column' }}> - <Typography - variant='h6' - sx={{ - color: 'text.primary', - lineHeight: 1.2, - }} - > - {item.year} - </Typography> - <Typography - variant='subtitle1' - sx={{ fontWeight: 700, mb: 1, color: '#444' }} - > - {item.title} - </Typography> - <Typography - variant='body2' - sx={{ color: 'text.secondary', lineHeight: 1.6 }} - > - {item.description} - </Typography> - </Box> + <Typography + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: '1.5rem', + color: COLORS.yellow, + lineHeight: 1, + mb: 0.25, + }} + > + {item.year} + </Typography> + <Typography sx={{ fontFamily: 'Inter', fontWeight: 700, fontSize: '0.92rem', color: '#fff', mb: 0.5 }}> + {item.title} + </Typography> + <Typography sx={{ fontFamily: 'Inter', fontSize: '0.85rem', color: 'rgba(255,255,255,0.6)', lineHeight: 1.6 }}> + {item.description} + </Typography> </Box> ))} </Box> diff --git a/src/sections/hero/HeroContainer.tsx b/src/sections/hero/HeroContainer.tsx index 8b21c2c..5e593d1 100644 --- a/src/sections/hero/HeroContainer.tsx +++ b/src/sections/hero/HeroContainer.tsx @@ -1,19 +1,29 @@ -import Container from '@mui/material/Container'; -import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; import { HeroInfo } from './HeroInfo'; -import { HeroNextEvent } from './HeroNextEvent'; export const HeroContainer = () => { return ( - <Container maxWidth='xl' sx={{ px: { xs: 4, md: 8, lg: 12 }, mb: 4 }}> - <Grid container spacing={4}> - <Grid size={{ xs: 12, lg: 8 }} sx={{ minHeight: '500px' }}> - <HeroInfo /> - </Grid> - <Grid size={{ xs: 12, lg: 4 }}> - <HeroNextEvent /> - </Grid> - </Grid> - </Container> + <Box + component='section' + aria-label='Comunidad JSChile' + sx={{ + minHeight: '100vh', + display: 'flex', + flexDirection: 'column', + }} + > + <Box + sx={{ + flexGrow: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + px: { xs: 3, md: 8 }, + py: { xs: 8, md: 10 }, + }} + > + <HeroInfo /> + </Box> + </Box> ); }; diff --git a/src/sections/hero/HeroInfo.tsx b/src/sections/hero/HeroInfo.tsx index cdb8d80..290b76c 100644 --- a/src/sections/hero/HeroInfo.tsx +++ b/src/sections/hero/HeroInfo.tsx @@ -1,82 +1,136 @@ -import Card from '@mui/material/Card'; -import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; +'use client'; +import { jersey10 } from '@/lib/fonts'; +import { EASE, COLORS } from '@/lib/tokens'; + +import { useState } from 'react'; import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Stack from '@mui/material/Stack'; +import { motion } from 'framer-motion'; +import { AnimatedButton } from '@/components/AnimatedButton'; +import { useCommunityModal } from '@/providers/CommunityModalProvider'; +import { WhatsappLogo, CaretDown } from '@phosphor-icons/react'; + + +const FINAL_TEXT = 'Más que eventos.\nUna red de devs que se apoyan'; + +const containerVariants = { + hidden: {}, + visible: { transition: { staggerChildren: 0.09, delayChildren: 0.1 } }, +}; + +const wordVariants = { + hidden: { opacity: 0, filter: 'blur(20px)', y: 8 }, + visible: { + opacity: 1, + filter: 'blur(0px)', + y: 0, + transition: { duration: 0.7, ease: EASE }, + }, +}; export const HeroInfo = () => { + const { openModal } = useCommunityModal(); + const [showContent, setShowContent] = useState(false); + + const lines = FINAL_TEXT.split('\n'); + return ( - <Card - sx={{ - bgcolor: 'primary.main', // Matches the yellow in the logo (Standard JS Yellow) - color: 'black', - height: '100%', - borderRadius: '32px', - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - p: 4, - position: 'relative', - overflow: 'hidden', - border: 'none', - backdropFilter: 'none', - WebkitBackdropFilter: 'none', - }} - > - <Grid container spacing={4} alignItems='center'> - <Grid size={{ xs: 12, md: 8 }}> - <Typography - variant='h1' - sx={{ - fontWeight: 800, - mb: 3, - lineHeight: 1, - fontSize: { xs: '2.5rem', md: '4rem' }, - letterSpacing: '-0.04em', - }} - > - JavaScript Chile - </Typography> - <Typography - variant='body1' - sx={{ - mb: 0, - fontWeight: 600, - lineHeight: 1.5, - maxWidth: '520px', - fontSize: '1.1rem', - color: '#1A1A1A', - position: 'relative', - zIndex: 1, - }} - > - Somos una comunidad de desarrolladores apasionados por la - tecnología. Organizamos meetups, workshops e iniciativas para - compartir conocimiento, conectar profesionales y construir el futuro - del desarrollo en Chile. - </Typography> - </Grid> - <Grid - size={{ xs: 12, md: 4 }} + <Box sx={{ textAlign: 'center', maxWidth: '1200px', mx: 'auto' }}> + + {/* ── Title blur-reveal word by word ── */} + <motion.div + variants={containerVariants} + initial='hidden' + animate='visible' + onAnimationComplete={() => setShowContent(true)} + > + <Typography + component='h1' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '3.8rem', sm: '5rem', md: '6.25rem' }, + lineHeight: '82%', + color: COLORS.black, + mb: '20px', + textAlign: 'center', + }} + > + {lines.map((line, li) => ( + <Box key={li} component='span' sx={{ display: 'block' }}> + {line.split(' ').map((word, wi) => ( + <motion.span + key={`${li}-${wi}`} + variants={wordVariants} + style={{ display: 'inline-block', margin: '0 0.14em' }} + > + {word} + </motion.span> + ))} + </Box> + ))} + </Typography> + </motion.div> + + {/* ── Subtitle + buttons fade in after title ── */} + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={showContent ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }} + transition={{ duration: 0.7, ease: EASE }} + > + <Typography + variant='body1' sx={{ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', + mb: 6, + fontFamily: 'Inter', + fontWeight: 400, + fontSize: { xs: '1rem', md: '1.25rem' }, + lineHeight: '109%', + color: COLORS.textPrimary, + maxWidth: '900px', + mx: 'auto', }} > - {/* Logo from Assets - Bigger size as requested */} - <Box - component='img' - src='/assets/javascript-chile-logo.png' - alt='JS Chile Logo large' - sx={{ - width: { xs: 200, md: 280 }, - height: 'auto', - objectFit: 'contain', - //mixBlendMode: "multiply", // Helps blend the logo background if slightly different - }} - /> - </Grid> - </Grid> - </Card> + Somos una comunidad de desarrolladores apasionados por la tecnología. + Organizamos meetups, workshops e iniciativas para compartir conocimiento, + conectar profesionales y construir el futuro del desarrollo en Chile. + </Typography> + + <Stack + direction={{ xs: 'column', sm: 'row' }} + spacing={2} + justifyContent='center' + alignItems='center' + > + <AnimatedButton + variant='contained' + color='secondary' + href='#comunidad' + hoverColor={COLORS.charcoal} hoverTextColor='#fff' + sx={{ px: 4, py: 1.5, fontSize: '1rem', minWidth: 200 }} + > + <Stack direction='row' alignItems='center' spacing={1}> + <CaretDown size={20} weight='fill' /> + <span>Conocer más</span> + </Stack> + </AnimatedButton> + + <AnimatedButton + variant='contained' + color='secondary' + onClick={openModal} + hoverColor={COLORS.charcoal} hoverTextColor='#fff' + sx={{ px: 4, py: 1.5, fontSize: '1rem', minWidth: 200 }} + > + <Stack direction='row' alignItems='center' spacing={1}> + <WhatsappLogo size={20} weight='fill' /> + <span>Unirme al WhatsApp</span> + </Stack> + </AnimatedButton> + </Stack> + </motion.div> + + </Box> ); }; diff --git a/src/sections/hero/HeroNextEvent.tsx b/src/sections/hero/HeroNextEvent.tsx index 5b493c9..b3dbd6b 100644 --- a/src/sections/hero/HeroNextEvent.tsx +++ b/src/sections/hero/HeroNextEvent.tsx @@ -1,13 +1,12 @@ +import { COLORS } from '@/lib/tokens'; import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; import CardActions from '@mui/material/CardActions'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; -import BoltIcon from '@mui/icons-material/Bolt'; import { AnimatedButton } from '@/components/AnimatedButton'; -import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; import { Stack } from '@mui/material'; -import FavoriteIcon from '@mui/icons-material/Favorite'; +import { Lightning, CalendarDots, Heart } from '@phosphor-icons/react'; export const HeroNextEvent = () => { return ( @@ -24,13 +23,13 @@ export const HeroNextEvent = () => { variant='h5' sx={{ fontWeight: 800, - color: '#000', + color: COLORS.black, gap: 1, letterSpacing: '0.1em', }} > <Box component='span'> - <BoltIcon sx={{ color: 'primary.main', fontSize: '2rem' }} /> + <Lightning size={32} weight='fill' color={COLORS.yellow} /> </Box> PRÓXIMOS EVENTOS </Typography> @@ -47,9 +46,7 @@ export const HeroNextEvent = () => { ¿Listo para aprender, conectar y crecer? Descubre nuestros próximos eventos y súmate a la comunidad <Box component='span'> - <FavoriteIcon - sx={{ color: 'primary.main', fontSize: '1.5rem', pt: 1 }} - /> + <Heart size={24} weight='fill' color={COLORS.yellow} /> </Box> </Typography> </CardContent> @@ -62,7 +59,7 @@ export const HeroNextEvent = () => { target='_blank' > <Stack display='flex' flexDirection='row' alignItems='center'> - <CalendarMonthIcon sx={{ mr: 1 }} /> + <CalendarDots size={20} weight='fill' style={{ marginRight: 8 }} /> Ver próximos eventos </Stack> </AnimatedButton> diff --git a/src/sections/speakers/SpeakersModal.tsx b/src/sections/speakers/SpeakersModal.tsx new file mode 100644 index 0000000..8afa9be --- /dev/null +++ b/src/sections/speakers/SpeakersModal.tsx @@ -0,0 +1,178 @@ +'use client'; +import { jersey10 } from '@/lib/fonts'; +import { COLORS } from '@/lib/tokens'; +import { Speaker } from '@/lib/fetchSpeakers'; + +import React from 'react'; +import Modal from '@mui/material/Modal'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Link from '@mui/material/Link'; +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import { X, Microphone, LinkedinLogo, GithubLogo, InstagramLogo, Globe } from '@phosphor-icons/react'; + +interface SpeakersModalProps { + open: boolean; + onClose: () => void; + speakers: Speaker[]; +} + +const ICON_SX = { + color: COLORS.yellow, + bgcolor: 'rgba(255,255,255,0.08)', + borderRadius: '10px', + '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, +} as const; + +function ModalSpeakerCard({ speaker }: { speaker: Speaker }) { + const links = [ + { href: speaker.linkedin, Icon: LinkedinLogo, label: 'LinkedIn' }, + { href: speaker.github, Icon: GithubLogo, label: 'GitHub' }, + { href: speaker.instagram, Icon: InstagramLogo, label: 'Instagram' }, + { href: speaker.website, Icon: Globe, label: 'Sitio web' }, + ].filter((l) => l.href); + + return ( + <Box + sx={{ + display: 'flex', + alignItems: 'center', + gap: 2, + bgcolor: COLORS.darkOliveCard, + borderRadius: '16px', + p: 2, + height: '100%', + boxSizing: 'border-box', + }} + > + <Box + sx={{ + width: 60, + height: 60, + flexShrink: 0, + borderRadius: '10px', + backgroundImage: `url(${speaker.avatar ?? '/assets/default.webp'})`, + backgroundSize: 'cover', + backgroundPosition: 'center', + }} + /> + <Box sx={{ minWidth: 0 }}> + <Typography + sx={{ + fontFamily: 'Inter', + fontWeight: 700, + fontSize: '0.88rem', + color: COLORS.yellow, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + > + {speaker.name} + </Typography> + {links.length > 0 && ( + <Stack direction='row' spacing={0.5} mt={0.5} role='list'> + {links.map(({ href, Icon, label }) => ( + <Link + key={href} + href={href} + target='_blank' + rel='noopener noreferrer' + aria-label={`${label} del speaker (abre en nueva pestaña)`} + role='listitem' + > + <IconButton size='small' tabIndex={-1} aria-hidden sx={ICON_SX}> + <Icon size={18} weight='fill' /> + </IconButton> + </Link> + ))} + </Stack> + )} + </Box> + </Box> + ); +} + +export const SpeakersModal: React.FC<SpeakersModalProps> = ({ open, onClose, speakers }) => { + return ( + <Modal + open={open} + onClose={onClose} + aria-labelledby='speakers-modal-title' + aria-describedby='speakers-modal-desc' + disableScrollLock + > + <Box + sx={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: { xs: '95%', sm: '85%', md: '75%' }, + maxWidth: 900, + maxHeight: '90vh', + overflowY: 'auto', + bgcolor: COLORS.darkOlive, + borderRadius: '24px', + boxShadow: 24, + p: { xs: 3, md: 5 }, + outline: 'none', + }} + > + <IconButton + onClick={onClose} + aria-label='Cerrar modal' + sx={{ + position: 'absolute', + right: 16, + top: 16, + color: '#fff', + bgcolor: 'rgba(255,255,255,0.1)', + '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, + }} + > + <X size={20} weight='fill' /> + </IconButton> + + <Stack direction='row' alignItems='center' spacing={1.5} sx={{ mb: 1 }}> + <Microphone size={32} weight='fill' color={COLORS.yellow} aria-hidden /> + <Typography + id='speakers-modal-title' + component='h2' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: { xs: '2rem', md: '2.8rem' }, + color: COLORS.yellow, + lineHeight: 1, + }} + > + Todos los Speakers + </Typography> + </Stack> + + <Typography + id='speakers-modal-desc' + sx={{ + fontFamily: 'Inter', + fontSize: '0.95rem', + color: 'rgba(255,255,255,0.6)', + mb: 4, + }} + > + {speakers.length} speakers que han subido al escenario de JSChile. + </Typography> + + <Grid container spacing={2}> + {speakers.map((speaker) => ( + <Grid key={speaker.id} size={{ xs: 12, sm: 6, md: 4 }}> + <ModalSpeakerCard speaker={speaker} /> + </Grid> + ))} + </Grid> + </Box> + </Modal> + ); +}; diff --git a/src/sections/staff/StaffCard.tsx b/src/sections/staff/StaffCard.tsx index a1e0a10..3769a2c 100644 --- a/src/sections/staff/StaffCard.tsx +++ b/src/sections/staff/StaffCard.tsx @@ -1,4 +1,5 @@ 'use client'; +import { COLORS } from '@/lib/tokens'; import React, { useState } from 'react'; import { motion } from 'framer-motion'; @@ -26,11 +27,16 @@ export const StaffCard: React.FC<StaffCardProps> = ({ return ( <Box + role='button' + tabIndex={0} + aria-label={`Ver perfil de ${staff.fullName}`} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onClick={onClick} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }} sx={{ cursor: 'pointer', + '&:focus-visible': { outline: `3px solid ${COLORS.yellow}`, borderRadius: '12px' }, }} > <Stack @@ -62,18 +68,28 @@ export const StaffCard: React.FC<StaffCardProps> = ({ height: imageSize, borderRadius: '12px', border: '3px solid', - borderColor: isHovered ? '#F0DB4F' : 'primary.main', + borderColor: isHovered ? COLORS.yellow : COLORS.black, transition: 'all 0.3s ease', position: 'relative', zIndex: 1, overflow: 'hidden', - backgroundImage: `url(${staff.imageUrl})`, - backgroundSize: 'cover', - backgroundPosition: 'center', - filter: - isExStaff && !isHovered ? 'grayscale(100%)' : 'grayscale(0%)', }} - /> + > + <Box + component='img' + src={staff.imageUrl} + alt={`Foto de ${staff.fullName}, ${staff.role}`} + sx={{ + width: '100%', + height: '100%', + objectFit: 'cover', + objectPosition: 'center', + display: 'block', + filter: isExStaff && !isHovered ? 'grayscale(100%)' : 'grayscale(0%)', + transition: 'filter 0.3s ease', + }} + /> + </Box> </motion.div> <Typography diff --git a/src/sections/staff/StaffCarousel.tsx b/src/sections/staff/StaffCarousel.tsx index ce6fb74..a0c3d6c 100644 --- a/src/sections/staff/StaffCarousel.tsx +++ b/src/sections/staff/StaffCarousel.tsx @@ -1,7 +1,8 @@ 'use client'; +import { COLORS } from '@/lib/tokens'; -import React, { useState, useEffect } from 'react'; -import { motion } from 'framer-motion'; +import React, { useEffect, useRef } from 'react'; +import { motion, useMotionValue, useAnimationFrame } from 'framer-motion'; import Box from '@mui/material/Box'; import { StaffMember } from '@/data/data_staff'; import { StaffCard } from './StaffCard'; @@ -23,67 +24,46 @@ export const StaffCarousel: React.FC<StaffCarouselProps> = ({ isModalOpen = false, reverseDirection = false, }) => { - const [isPaused, setIsPaused] = useState(false); - const [animationKey, setAnimationKey] = useState(0); - const [isHovering, setIsHovering] = useState(false); + const cardWidth = 120; + const gap = 16; + const totalWidth = (cardWidth + gap) * staff.length; + // non-reversed: x goes from -totalWidth → 0 (moves right) + // reversed: x goes from 0 → -totalWidth (moves left) + const x = useMotionValue(reverseDirection ? 0 : -totalWidth); + const speed = totalWidth / (staff.length * 5 * 1000); // px per ms - // Pausar si el modal está abierto - React.useEffect(() => { - if (isModalOpen) { - setIsPaused(true); - } else if (!isHovering) { - setIsPaused(false); + // Two independent reasons to pause — use a ref so changes don't re-render + const hovering = useRef(false); + const modalOpen = useRef(isModalOpen); + + useEffect(() => { + modalOpen.current = isModalOpen; + }, [isModalOpen]); + + useAnimationFrame((_, delta) => { + if (hovering.current || modalOpen.current) return; + let current = x.get(); + if (reverseDirection) { + current -= speed * delta; + if (current <= -totalWidth) current += totalWidth; + } else { + current += speed * delta; + if (current >= 0) current -= totalWidth; } - }, [isModalOpen, isHovering]); + x.set(current); + }); - // Duplicar el array para crear efecto infinito const triplicatedStaff = [...staff, ...staff, ...staff]; - const duplicatedStaff = reverseDirection - ? triplicatedStaff.reverse() - : triplicatedStaff; - - const cardWidth = 120; - const gap = 16; - const totalWidth = (cardWidth + gap) * staff.length; - const animationValues = reverseDirection - ? [0, -totalWidth] - : [-totalWidth, 0]; return ( - <Box - sx={{ - position: 'relative', - width: '100%', - overflow: 'hidden', - py: 1, - }} - > + <Box sx={{ position: 'relative', width: '100%', overflow: 'hidden', py: 1 }}> <motion.div - key={animationKey} - style={{ - display: 'flex', - gap: `${gap}px`, - width: 'fit-content', - }} - animate={{ - x: isPaused ? undefined : animationValues, - }} - transition={{ - duration: staff.length * 5, - ease: 'linear', - repeat: Infinity, - }} - onMouseEnter={() => setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} + style={{ display: 'flex', gap: `${gap}px`, width: 'fit-content', x }} + onMouseEnter={() => { hovering.current = true; }} + onMouseLeave={() => { hovering.current = false; }} > - {duplicatedStaff.map((member, index) => ( - <Box - key={`${member.id}-${index}`} - sx={{ - width: cardWidth, - flexShrink: 0, - }} - > + {triplicatedStaff.map((member, index) => ( + <Box key={`${member.id}-${index}`} sx={{ width: cardWidth, flexShrink: 0 }}> <StaffCard staff={member} onClick={() => onStaffClick(member)} @@ -94,29 +74,18 @@ export const StaffCarousel: React.FC<StaffCarouselProps> = ({ ))} </motion.div> - {/* Gradientes en los bordes para efecto fade */} <Box sx={{ - position: 'absolute', - top: 0, - left: 0, - width: '100px', - height: '100%', - background: 'linear-gradient(to right, white, transparent)', - pointerEvents: 'none', - zIndex: 1, + position: 'absolute', top: 0, left: 0, width: '100px', height: '100%', + background: `linear-gradient(to right, ${COLORS.yellow}, transparent)`, + pointerEvents: 'none', zIndex: 1, }} /> <Box sx={{ - position: 'absolute', - top: 0, - right: 0, - width: '100px', - height: '100%', - background: 'linear-gradient(to left, white, transparent)', - pointerEvents: 'none', - zIndex: 1, + position: 'absolute', top: 0, right: 0, width: '100px', height: '100%', + background: `linear-gradient(to left, ${COLORS.yellow}, transparent)`, + pointerEvents: 'none', zIndex: 1, }} /> </Box> diff --git a/src/sections/staff/StaffContainer.tsx b/src/sections/staff/StaffContainer.tsx index ec21e2b..b208a84 100644 --- a/src/sections/staff/StaffContainer.tsx +++ b/src/sections/staff/StaffContainer.tsx @@ -1,14 +1,16 @@ 'use client'; +import { COLORS } from '@/lib/tokens'; +import { jersey10 } from '@/lib/fonts'; import React, { useState } from 'react'; import { Box, Container, Typography, Grid, Stack } from '@mui/material'; import { DATA_STAFF, StaffMember } from '@/data/data_staff'; import StaffModal from './StaffModal'; -import StarIcon from '@mui/icons-material/Star'; -import CalendarViewMonthIcon from '@mui/icons-material/CalendarViewMonth'; +import { Star, SquaresFour } from '@phosphor-icons/react'; import { StaffCarousel } from './StaffCarousel'; + const StaffContainer: React.FC = () => { const [selectedStaff, setSelectedStaff] = useState<StaffMember | null>(null); const [isModalOpen, setIsModalOpen] = useState(false); @@ -27,12 +29,12 @@ const StaffContainer: React.FC = () => { const exStaff = DATA_STAFF.filter((staff) => staff.isExStaff); return ( - <Container maxWidth='xl' sx={{ px: { xs: 4, md: 8, lg: 12 }, mb: 2 }}> + <Container id='staff' maxWidth='xl' sx={{ px: { xs: 4, md: 8, lg: 12 }, py: { xs: 10, md: 16 } }}> <Grid container spacing={4}> <Grid size={12}> <Box sx={{ - bgcolor: 'white', + bgcolor: COLORS.yellow, borderRadius: '32px', py: { xs: 3, md: 4 }, px: { xs: 1, md: 2 }, @@ -48,13 +50,11 @@ const StaffContainer: React.FC = () => { spacing={1} sx={{ mb: 1 }} > - <StarIcon sx={{ color: 'primary.main', fontSize: '2rem' }} /> + <Star size={32} weight='fill' color={COLORS.black} /> <Typography - variant='h5' component='h3' - fontWeight='bold' - sx={{ color: 'black' }} + sx={{ fontFamily: jersey10.style.fontFamily, fontWeight: 400, fontSize: '3.125rem', color: 'black' }} > Staff 2026 </Typography> @@ -75,16 +75,9 @@ const StaffContainer: React.FC = () => { spacing={1} sx={{ mb: 1 }} > - <CalendarViewMonthIcon - sx={{ color: 'primary.main', fontSize: '2rem' }} - /> - <Typography - variant='h5' component='h3' - fontWeight='bold' - textAlign='center' - sx={{ mb: 1, color: 'black' }} + sx={{ fontFamily: jersey10.style.fontFamily, fontWeight: 400, fontSize: '1.5rem', mb: 1, color: 'black' }} > 2023 al 2025 </Typography> diff --git a/src/sections/staff/StaffModal.tsx b/src/sections/staff/StaffModal.tsx index d0a85b6..31a1246 100644 --- a/src/sections/staff/StaffModal.tsx +++ b/src/sections/staff/StaffModal.tsx @@ -1,15 +1,14 @@ +'use client'; +import { jersey10 } from '@/lib/fonts'; +import { COLORS } from '@/lib/tokens'; + import React from 'react'; import Modal from '@mui/material/Modal'; import Box from '@mui/material/Box'; -import Avatar from '@mui/material/Avatar'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import Link from '@mui/material/Link'; -import CloseIcon from '@mui/icons-material/Close'; -import LinkedInIcon from '@mui/icons-material/LinkedIn'; -import InstagramIcon from '@mui/icons-material/Instagram'; -import LanguageIcon from '@mui/icons-material/Language'; -import MailIcon from '@mui/icons-material/Mail'; +import { X, LinkedinLogo, InstagramLogo, Globe, Envelope } from '@phosphor-icons/react'; import { StaffMember } from '@/data/data_staff'; interface StaffModalProps { @@ -18,22 +17,6 @@ interface StaffModalProps { staff: StaffMember | null; } -const modalStyle = { - position: 'absolute' as const, - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: { xs: '90%', sm: 400 }, - bgcolor: 'background.paper', - borderRadius: 2, - boxShadow: 24, - p: 4, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - outline: 'none', -}; - const StaffModal: React.FC<StaffModalProps> = ({ open, onClose, staff }) => { if (!staff) return null; @@ -45,50 +28,115 @@ const StaffModal: React.FC<StaffModalProps> = ({ open, onClose, staff }) => { aria-describedby='staff-modal-description' disableScrollLock > - <Box sx={modalStyle}> + <Box + sx={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: { xs: '90%', sm: 400 }, + bgcolor: COLORS.darkOlive, + borderRadius: '24px', + boxShadow: 24, + p: { xs: 3, md: 4 }, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + outline: 'none', + }} + > <IconButton onClick={onClose} - sx={{ position: 'absolute', right: 8, top: 8 }} aria-label='Cerrar modal' + sx={{ + position: 'absolute', + right: 16, + top: 16, + color: '#fff', + bgcolor: 'rgba(255,255,255,0.1)', + '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, + }} > - <CloseIcon /> + <X size={20} weight='fill' /> </IconButton> - <Avatar + <Box + component='img' src={staff.imageUrl} - alt={staff.fullName} - sx={{ width: 120, height: 120 }} + alt={`Foto de ${staff.fullName}`} + sx={{ + width: 100, + height: 100, + borderRadius: '16px', + objectFit: 'cover', + border: `3px solid ${COLORS.yellow}`, + mb: 2, + mt: 1, + }} /> <Typography id='staff-modal-title' - variant='h5' component='h2' - fontWeight='bold' - gutterBottom - textAlign='center' + sx={{ + fontFamily: jersey10.style.fontFamily, + fontWeight: 400, + fontSize: '1.8rem', + color: COLORS.yellow, + lineHeight: 1, + textAlign: 'center', + mb: 0.5, + }} > {staff.fullName} </Typography> - <Typography variant='subtitle1' color='text.secondary' gutterBottom> + <Typography + id='staff-modal-description' + sx={{ + fontFamily: 'Inter', + fontSize: '0.9rem', + color: 'rgba(255,255,255,0.7)', + textAlign: 'center', + mb: 0.25, + }} + > {staff.role} </Typography> - <Typography variant='body2' color='text.secondary' sx={{ mb: 3 }}> + <Typography + sx={{ + fontFamily: 'Inter', + fontSize: '0.8rem', + color: 'rgba(255,255,255,0.4)', + textAlign: 'center', + mb: 3, + }} + > {staff.date} </Typography> - <Box sx={{ display: 'flex', gap: 1 }}> + <Box sx={{ display: 'flex', gap: 1 }} role='list' aria-label='Redes sociales'> {staff.linkedin && ( <Link href={staff.linkedin} target='_blank' rel='noopener noreferrer' - sx={{ display: 'flex', alignItems: 'center' }} + aria-label={`LinkedIn de ${staff.fullName} (abre en nueva pestaña)`} + role='listitem' > - <IconButton size='small' sx={{ color: 'primary.main' }}> - <LinkedInIcon /> + <IconButton + size='small' + tabIndex={-1} + aria-hidden + sx={{ + color: COLORS.yellow, + bgcolor: 'rgba(255,255,255,0.08)', + borderRadius: '10px', + '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, + }} + > + <LinkedinLogo size={22} weight='fill' /> </IconButton> </Link> )} @@ -97,10 +145,21 @@ const StaffModal: React.FC<StaffModalProps> = ({ open, onClose, staff }) => { href={staff.instagram} target='_blank' rel='noopener noreferrer' - sx={{ display: 'flex', alignItems: 'center' }} + aria-label={`Instagram de ${staff.fullName} (abre en nueva pestaña)`} + role='listitem' > - <IconButton size='small' sx={{ color: 'primary.main' }}> - <InstagramIcon /> + <IconButton + size='small' + tabIndex={-1} + aria-hidden + sx={{ + color: COLORS.yellow, + bgcolor: 'rgba(255,255,255,0.08)', + borderRadius: '10px', + '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, + }} + > + <InstagramLogo size={22} weight='fill' /> </IconButton> </Link> )} @@ -109,20 +168,42 @@ const StaffModal: React.FC<StaffModalProps> = ({ open, onClose, staff }) => { href={staff.website} target='_blank' rel='noopener noreferrer' - sx={{ display: 'flex', alignItems: 'center' }} + aria-label={`Sitio web de ${staff.fullName} (abre en nueva pestaña)`} + role='listitem' > - <IconButton size='small' sx={{ color: 'primary.main' }}> - <LanguageIcon /> + <IconButton + size='small' + tabIndex={-1} + aria-hidden + sx={{ + color: COLORS.yellow, + bgcolor: 'rgba(255,255,255,0.08)', + borderRadius: '10px', + '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, + }} + > + <Globe size={22} weight='fill' /> </IconButton> </Link> )} {staff.email && ( <Link href={`mailto:${staff.email}`} - sx={{ display: 'flex', alignItems: 'center' }} + aria-label={`Enviar email a ${staff.fullName}`} + role='listitem' > - <IconButton size='small' sx={{ color: 'primary.main' }}> - <MailIcon /> + <IconButton + size='small' + tabIndex={-1} + aria-hidden + sx={{ + color: COLORS.yellow, + bgcolor: 'rgba(255,255,255,0.08)', + borderRadius: '10px', + '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, + }} + > + <Envelope size={22} weight='fill' /> </IconButton> </Link> )} diff --git a/src/theme/theme.ts b/src/theme/theme.ts index de02515..69b320a 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -1,4 +1,4 @@ -'use client'; +import { COLORS } from '@/lib/tokens'; import { createTheme } from '@mui/material/styles'; import { Inter } from 'next/font/google'; @@ -11,40 +11,42 @@ const inter = Inter({ // Brand Palette based on JSChile Logo const brand = { - 50: '#FEFCE8', - 100: '#FEF9C3', - 200: '#FEF08A', - 300: '#FDE047', - 400: '#FACC15', - 500: '#ecd51a', // Official JS Yellow (Matches Logo) - 600: '#EAB308', - 700: '#CA8A04', - 800: '#A16207', - 900: '#713F12', + 50: COLORS.yellowHover, + 100: COLORS.yellowHover, + 200: COLORS.yellowHover, + 300: COLORS.yellow, + 400: COLORS.yellow, + 500: COLORS.yellow, + 600: COLORS.yellowMid, + 700: COLORS.yellowDark, + 800: COLORS.olive, + 900: COLORS.oliveDark, }; const gray = { - 50: '#FAFAFA', + 50: '#FAFAFA', 100: '#F5F5F5', 200: '#EEEEEE', 300: '#E0E0E0', 400: '#BDBDBD', 500: '#9E9E9E', - 600: '#757575', - 700: '#616161', - 800: '#424242', - 900: '#212121', + 600: COLORS.gray, + 700: COLORS.gray, + 800: COLORS.charcoal, + 900: COLORS.black, }; const theme = createTheme({ palette: { mode: 'light', primary: { - main: brand[500], // Yellow - contrastText: '#000000', + main: brand[500], + dark: brand[500], // keeps bg unchanged on hover — AnimatedButton's span handles it + contrastText: COLORS.black, }, secondary: { - main: '#000000', + main: COLORS.black, + dark: COLORS.black, // same reason contrastText: '#FFFFFF', }, background: { @@ -52,7 +54,7 @@ const theme = createTheme({ paper: '#FFFFFF', }, text: { - primary: '#000000', + primary: COLORS.black, secondary: gray[600], }, }, @@ -96,6 +98,15 @@ const theme = createTheme({ WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', }, + /* WCAG AA: detiene animaciones CSS para usuarios con prefers-reduced-motion */ + '@media (prefers-reduced-motion: reduce)': { + '*, *::before, *::after': { + animationDuration: '0.01ms !important', + animationIterationCount: '1 !important', + transitionDuration: '0.01ms !important', + scrollBehavior: 'auto !important', + }, + }, a: { color: 'inherit', textDecoration: 'none', @@ -141,11 +152,8 @@ const theme = createTheme({ }, }, containedSecondary: { - backgroundColor: '#000000', + backgroundColor: COLORS.black, color: '#FFFFFF', - '&:hover': { - backgroundColor: '#333333', - }, }, }, }, diff --git a/src/types/events.ts b/src/types/events.ts new file mode 100644 index 0000000..4f10026 --- /dev/null +++ b/src/types/events.ts @@ -0,0 +1,16 @@ +export interface CalEvent { + uid: string; + title: string; + start: string; + end: string; + location: string | null; + url: string; + cover_url: string | null; + status: 'upcoming' | 'ongoing' | 'past'; +} + +export interface EventsJson { + generated_at: string; + upcoming: CalEvent[]; + past: CalEvent[]; +}