diff --git a/CLAUDE.md b/CLAUDE.md
index 1cf8997..532e8a6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,8 +23,14 @@ Static marketing site for Fullstack Nights (a Puerto Rico tech/design community)
`CONFIG.activeEvent` is the single switch that drives event-related UI across the site:
-- When `false`: the `/schedule/` route is filtered out of the nav (`getSections` in `src/constants.js`) and the homepage hides ``.
-- When `true`: set `CONFIG.event` to `{ type, date, venue, participants }`. `type` is `"topic-tables"` or anything else (treated as Speakers). The homepage layout shifts (different spacing classes are applied).
+- When `false`: the `/schedule/` route is filtered out of the nav (`getSections` in `src/constants.js`), the homepage hides ``, and `/schedule/` falls back to the Luma calendar for anyone arriving by bookmark.
+- When `true`: set `CONFIG.event` to `{ type, date, registrationUrl, venue, participants }`, plus optional `endDate`, `agenda`, and `sponsors`. The homepage layout shifts (different spacing classes are applied).
+
+`date` and `endDate` are offset-less ISO strings (`"2026-09-10T18:00:00"`). JavaScript parses that form as _local_ time, so every visitor sees the venue's wall-clock time instead of their own timezone — do not append a `-04:00` offset or the times will shift for out-of-state visitors.
+
+`/schedule/` builds its timeline from `participants`, allotting one equal-length slot per speaker. The slots are numbered ("Presentation 1") rather than named, so the schedule doesn't commit to a speaking order — only the count of `participants` reaches the timeline. `agenda` (`{ kickOffMinutes, firstTalkMinutes, talkMinutes }`) tunes those offsets; omit it to use `DEFAULT_AGENDA` in `src/pages/schedule.js`.
+
+`type` only selects the "Topic Tables" vs "Speakers" label on the homepage card. The generated timeline is speakers-shaped either way — a topic-tables event needs a matching branch in `buildTimeline` plus the agenda copy in both locale files, neither of which exists today.
When adding a new event, edit only `src/config.js` — no other files should hardcode event data.
diff --git a/src/components/index.js b/src/components/index.js
index 15a91e4..d2ef513 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -9,6 +9,7 @@ import LumaEvents from "./luma-events";
import PageHighlight from "./page-highlight";
import PageSection from "./page-section";
import ProfileCard from "./profile-card";
+import RegistrationButton from "./registration-button";
import Question from "./question";
import Sponsors from "./sponsors";
import SEO from "./seo";
@@ -32,6 +33,7 @@ export {
PageHighlight,
PageSection,
ProfileCard,
+ RegistrationButton,
Question,
Sponsors,
SEO,
diff --git a/src/components/luma-events.js b/src/components/luma-events.js
index 3ed6cc9..40cdd84 100644
--- a/src/components/luma-events.js
+++ b/src/components/luma-events.js
@@ -1,11 +1,13 @@
import React from "react";
import { useTranslation } from "react-i18next";
+import { resolveLanguage } from "../language";
+
const LUMA_CALENDAR_ID = "cal-xorZLhCJO1uKH5s";
function LumaEvents() {
const { t, i18n } = useTranslation();
- const lang = i18n.language?.startsWith("es") ? "es" : "en";
+ const lang = resolveLanguage(i18n.language);
const src = `https://luma.com/embed/calendar/${LUMA_CALENDAR_ID}/events?lang=${lang}`;
return (
diff --git a/src/components/registration-button.js b/src/components/registration-button.js
new file mode 100644
index 0000000..b9d6b87
--- /dev/null
+++ b/src/components/registration-button.js
@@ -0,0 +1,19 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+function RegistrationButton({ url }) {
+ const { t } = useTranslation();
+
+ return (
+
+ {t("get-tickets")}
+
+ );
+}
+
+export default RegistrationButton;
diff --git a/src/components/sponsors.js b/src/components/sponsors.js
index f5ed43d..2244234 100644
--- a/src/components/sponsors.js
+++ b/src/components/sponsors.js
@@ -23,6 +23,8 @@ function Sponsors({ sponsors = [] }) {
className="max-h-20 m-6"
src={sponsor.logo}
alt={sponsor.name}
+ width="160"
+ height="28"
/>
);
diff --git a/src/components/upcoming-event.js b/src/components/upcoming-event.js
index 0127f48..a670b06 100644
--- a/src/components/upcoming-event.js
+++ b/src/components/upcoming-event.js
@@ -1,16 +1,20 @@
import React from "react";
import { useTranslation } from "react-i18next";
-import { Link } from "gatsby";
-import { format } from "date-fns";
-
import Card from "./card";
import ProfileCard from "./profile-card";
+import RegistrationButton from "./registration-button";
+import { formatFullDate, formatTime } from "../date";
-const EVENTBRITE_LINK = "https://fullstacknights.eventbrite.com";
-
-function UpcomingEvent({ participants, type, date, venue }) {
- const { t } = useTranslation();
+function UpcomingEvent({
+ participants,
+ type,
+ date,
+ endDate,
+ venue,
+ registrationUrl
+}) {
+ const { t, i18n } = useTranslation();
return (
@@ -23,8 +27,9 @@ function UpcomingEvent({ participants, type, date, venue }) {
{type === "topic-tables" ? "Topic Tables" : "Speakers"}
);
diff --git a/src/config.js b/src/config.js
index 722cc03..46915e6 100644
--- a/src/config.js
+++ b/src/config.js
@@ -1,9 +1,58 @@
+// Luma keeps the street address guests-only, so the venue links to the event
+// page rather than to a map.
+const LUMA_EVENT_URL = "https://luma.com/s9jx3jri";
+
const CONFIG = {
- activeEvent: false,
- // When activeEvent is true, populate event with:
- // { type, date, venue, participants, sponsors }
+ activeEvent: true,
+ // When activeEvent is true, `endDate`, `agenda`, and `sponsors` are optional;
+ // everything else is required.
+ // date/endDate are local wall-clock ISO strings (no offset) so every visitor
+ // sees the venue's time, not their own.
+ // agenda drives the /schedule/ timeline; omit it to use the defaults below.
// sponsors: [{ name, logo, url }] renders a Sponsors section on the home page.
- event: {}
+ event: {
+ type: "speakers",
+ date: "2026-09-10T18:00:00",
+ endDate: "2026-09-10T21:00:00",
+ registrationUrl: LUMA_EVENT_URL,
+ agenda: {
+ kickOffMinutes: 20,
+ firstTalkMinutes: 30,
+ // 15 min talk + 5 min Q&A.
+ talkMinutes: 20
+ },
+ venue: {
+ name: "Bayamón, Puerto Rico",
+ location: LUMA_EVENT_URL
+ },
+ participants: [
+ {
+ name: "Giovanni Collazo",
+ topic: "jj es más fácil que git",
+ img: "/founders/giovanni-collazo.jpeg",
+ links: [{ network: "github", url: "https://github.com/gcollazo" }]
+ },
+ {
+ name: "Christian Rodríguez",
+ topic: "Tus agents necesitan acceso (seguro) a producción",
+ img: "/organizers/christian-rodriguez.jpeg",
+ links: [{ network: "github", url: "https://github.com/chrisrodz" }]
+ },
+ {
+ name: "Raúl Negrón-Otero",
+ topic: "Un vistazo a Django Template Partials",
+ img: "/organizers/raul-negron.jpeg",
+ links: [{ network: "github", url: "https://github.com/rnegron" }]
+ }
+ ],
+ sponsors: [
+ {
+ name: "PostHog",
+ logo: "/sponsors/posthog.svg",
+ url: "https://posthog.com/"
+ }
+ ]
+ }
};
export default CONFIG;
diff --git a/src/date.js b/src/date.js
new file mode 100644
index 0000000..cfa6ee9
--- /dev/null
+++ b/src/date.js
@@ -0,0 +1,29 @@
+import { format } from "date-fns";
+import { enUS, es } from "date-fns/locale";
+
+import { resolveLanguage } from "./language";
+
+function isSpanish(language) {
+ return resolveLanguage(language) === "es";
+}
+
+// "Thursday, September 10th, 2026" / "jueves, 10 de septiembre de 2026"
+export function formatFullDate(date, language) {
+ return format(date, "PPPP", { locale: isSpanish(language) ? es : enUS });
+}
+
+// "6:00pm" — Puerto Rico uses 12-hour time in both languages, and date-fns
+// spells the day period the same way in each, so this needs no locale.
+export function formatTime(date) {
+ return format(date, "h:mmaaa");
+}
+
+// "Sep 10" / "10 sep". date-fns has no localized month-and-day-only token, so
+// the field order is spelled out per language.
+export function formatShortDay(date, language) {
+ const spanish = isSpanish(language);
+
+ return format(date, spanish ? "d MMM" : "MMM d", {
+ locale: spanish ? es : enUS
+ });
+}
diff --git a/src/i18n.js b/src/i18n.js
index e56fbdd..895fa44 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -4,6 +4,7 @@ import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
+import { resolveLanguage } from "./language";
import englishLocale from "./locales/en.json";
import spanishLocale from "./locales/es.json";
@@ -11,7 +12,7 @@ export function getLanguageSwitcher({ i18n, classNames }) {
let langDisplay;
let langSwitch;
- if (i18n.language === "en-US") {
+ if (resolveLanguage(i18n.language) === "en") {
langDisplay = "español";
langSwitch = "es-PR";
} else {
diff --git a/src/language.js b/src/language.js
new file mode 100644
index 0000000..70926b7
--- /dev/null
+++ b/src/language.js
@@ -0,0 +1,6 @@
+// i18next hands out full tags ("en-US", "es-PR", and whatever the browser
+// detector reports), but the site ships exactly two locales. Every "which
+// language is this?" decision routes through here so the answer can't drift.
+export function resolveLanguage(language) {
+ return language?.startsWith("es") ? "es" : "en";
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index c42ebd2..30ddcb4 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -3,6 +3,7 @@
"find-us": "Find us on",
"join-us": "Join us",
"learn-more": "Learn more",
+ "get-tickets": "Get tickets",
"submit": "Submit",
"subject": "Subject",
"your-thoughts": "Your thoughts",
@@ -65,7 +66,6 @@
"ready-to-become-a-speaker": "Ready to become a speaker?",
"join-the-community": "Join the community in Discord",
"ask-questions-get-help": "Ask questions, get help from the community and stay up to date with the latest events.",
-
"we-will-help-polish-your-talk": "How it works",
"everyone-has-valuable-knowledge-to-share": "Everyone has valuable knowledge to share, and we keep the process simple. Submit your topic and we'll take it from there.",
"topic-scoping": "Topic scoping",
@@ -117,16 +117,13 @@
"schedule": {
"schedule": "Schedule",
"we-try-to-be-punctual": "We try to be as organized and punctual as possible. Check out our schedule below.",
- "get-tickets": "Get tickets",
"make-your-way-to-our-venue": "Make your way to our venue",
"doors-open": "Doors open",
- "check-in": "Check-in and reset your open-mic spot",
+ "check-in": "Check-in",
"introduction-and-kick-off": "Introduction and kick-off",
"grab-your-seat": "Grab your seat",
- "open-mic-6-x-3": "Open Mic (6 x 3 min)",
- "enjoy-the-event": "Enjoy the event!",
+ "presentation": "Presentation {{number}}",
"wrap-up-networking": "Wrap-up / Networking",
- "networking-break": "Networking / Break",
"the-end": "The end"
},
"topic-tables": {
@@ -154,19 +151,14 @@
"upcoming-events": "Upcoming events",
"at": "at",
"venue": "Venue",
- "get-tickets": "Get tickets",
- "line-up": "Line up for the next event",
- "while-you-wait": "While you wait for the event check out our",
- "code-of-conduct": "code of conduct",
- "schedule": "schedule",
- "or-request-an-open-mic": "or request an open mic spot."
+ "line-up": "Line up for the next event"
},
"footer": {
"code-of-conduct": "Code of Conduct"
},
"sponsors": {
"title": "Sponsors",
- "thanks-to-our-sponsors": "Thanks to the sponsors making this event possible."
+ "thanks-to-our-sponsors": "We thank our sponsors for making this edition possible."
},
"submission": {
"title": "Submit your topic",
diff --git a/src/locales/es.json b/src/locales/es.json
index d116bf1..34b5698 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -3,6 +3,7 @@
"find-us": "Encuéntranos en",
"join-us": "Únete",
"learn-more": "Conoce más",
+ "get-tickets": "Obtén tu boleto",
"submit": "Enviar",
"subject": "Título",
"your-thoughts": "Sus ideas",
@@ -65,7 +66,6 @@
"ready-to-become-a-speaker": "¡Se parte de esta lista!",
"join-the-community": "Únete a la comunidad en Discord",
"ask-questions-get-help": "Haz preguntas, obtén ayuda y mantente al tanto de los eventos más recientes.",
-
"we-will-help-polish-your-talk": "Cómo funciona",
"everyone-has-valuable-knowledge-to-share": "Toda persona tiene conocimiento valioso que aportar a la comunidad, y mantenemos el proceso sencillo. Envíanos tu tema y nosotros nos encargamos del resto.",
"topic-scoping": "Alcance del tema",
@@ -117,16 +117,13 @@
"schedule": {
"schedule": "Horario",
"we-try-to-be-punctual": "Tratamos de que el evento sea lo más organizado y puntual posible. Abajo puedes confirmar el horario.",
- "get-tickets": "Obtén tu boleto",
"make-your-way-to-our-venue": "Llega hasta el local del evento",
"doors-open": "Puertas abren",
- "check-in": "Registro y confirmación de micrófono abierto",
+ "check-in": "Registro",
"introduction-and-kick-off": "Introducción y comienzo",
"grab-your-seat": "Tome asiento",
- "open-mic-6-x-3": "Open Mic (6 x 3 min)",
- "enjoy-the-event": "¡Disfrute el evento!",
+ "presentation": "Presentación {{number}}",
"wrap-up-networking": "Conclusión / Networking",
- "networking-break": "Networking / Descanso",
"the-end": "Fin"
},
"topic-tables": {
@@ -154,19 +151,14 @@
"upcoming-events": "Próximos eventos",
"at": "a las",
"venue": "Sitio",
- "get-tickets": "Obtén tu boleto",
- "line-up": "Apúntate para el próximo evento",
- "while-you-wait": "A lo que llega la hora del próximo evento, revisa nuestro",
- "code-of-conduct": "código de conducta",
- "or-request-an-open-mic": "o solicita un espacio de microfono abierto.",
- "schedule": "horario"
+ "line-up": "Apúntate para el próximo evento"
},
"footer": {
"code-of-conduct": "Código de Conducta"
},
"sponsors": {
"title": "Patrocinadores",
- "thanks-to-our-sponsors": "Gracias a los patrocinadores que hacen este evento posible."
+ "thanks-to-our-sponsors": "Agradecemos a nuestros patrocinadores por hacer posible esta edición."
},
"submission": {
"title": "Envía tu tema",
diff --git a/src/pages/index.js b/src/pages/index.js
index 47e2147..52310a4 100644
--- a/src/pages/index.js
+++ b/src/pages/index.js
@@ -45,7 +45,9 @@ function IndexPage() {
) : (
diff --git a/src/pages/schedule.js b/src/pages/schedule.js
index f15396a..f79a6d5 100644
--- a/src/pages/schedule.js
+++ b/src/pages/schedule.js
@@ -1,41 +1,103 @@
import React from "react";
import { useTranslation } from "react-i18next";
+import { addMinutes } from "date-fns";
+
import {
Card,
GradientBackground,
Layout,
+ LumaEvents,
+ RegistrationButton,
Timeline,
SEO
} from "../components/index";
+import CONFIG from "../config";
+import { formatShortDay, formatTime } from "../date";
-export default function Schedule() {
+const DEFAULT_AGENDA = {
+ kickOffMinutes: 20,
+ firstTalkMinutes: 30,
+ talkMinutes: 20
+};
+
+function buildTimeline(event, t, language) {
+ const { kickOffMinutes, firstTalkMinutes, talkMinutes } =
+ event.agenda ?? DEFAULT_AGENDA;
+ const start = new Date(event.date);
+ const at = (minutes) => formatTime(addMinutes(start, minutes));
+
+ return [
+ {
+ dateTime: formatShortDay(start, language),
+ header: event.venue.name,
+ subheader: t("schedule.make-your-way-to-our-venue")
+ },
+ {
+ dateTime: at(0),
+ header: t("schedule.doors-open"),
+ subheader: t("schedule.check-in")
+ },
+ {
+ dateTime: at(kickOffMinutes),
+ header: t("schedule.introduction-and-kick-off"),
+ subheader: t("schedule.grab-your-seat")
+ },
+ // Slots stay unnamed so the schedule doesn't commit to a speaking order.
+ ...event.participants.map((_, index) => ({
+ dateTime: at(firstTalkMinutes + index * talkMinutes),
+ header: t("schedule.presentation", { number: index + 1 })
+ })),
+ {
+ dateTime: at(firstTalkMinutes + event.participants.length * talkMinutes),
+ header: t("schedule.wrap-up-networking")
+ },
+ ...(event.endDate
+ ? [{ dateTime: formatTime(event.endDate), header: t("schedule.the-end") }]
+ : [])
+ ];
+}
+
+function ScheduleHeading({ children }) {
const { t } = useTranslation();
- const events = [];
+ return (
+
+
{t("schedule.schedule")}
+
+ {t("schedule.we-try-to-be-punctual")}
+
+ {children}
+
+ );
+}
+
+export default function Schedule() {
+ const { t, i18n } = useTranslation();
+ const { activeEvent, event } = CONFIG;
+
+ // The nav hides /schedule/ off-season, but the route stays reachable by
+ // bookmark — point those visitors at the calendar, not an empty timeline.
+ if (!activeEvent) {
+ return (
+
+
+