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"}

- {format(new Date(date), "EEEE, MMMM d, y")},{" "} - {t("upcoming-event.at")} 7pm + {formatFullDate(date, i18n.language)}, {t("upcoming-event.at")}{" "} + {formatTime(date)} + {endDate ? ` – ${formatTime(endDate)}` : ""}

{t("upcoming-event.venue")}: @@ -39,14 +44,7 @@ function UpcomingEvent({ participants, type, date, venue }) {

- - {t("upcoming-event.get-tickets")} - +

{t("upcoming-event.line-up")} @@ -64,24 +62,6 @@ function UpcomingEvent({ participants, type, date, venue }) { /> ))} -

- {t("upcoming-event.while-you-wait")}{" "} -

- - {t("upcoming-event.code-of-conduct")} - -
- ,{" "} -
- - {t("upcoming-event.schedule")} - -
{" "} - {t("upcoming-event.or-request-an-open-mic")} -

); 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 ( + + +
+ +
+
+ ); + } return ( - -

- {t("schedule.schedule")} -

-

- {t("schedule.we-try-to-be-punctual")} -

- - {t("schedule.get-tickets")} - -
+ + +
- +
diff --git a/static/founders/giovanni-collazo.jpeg b/static/founders/giovanni-collazo.jpeg index a0bb6ab..befdfac 100644 Binary files a/static/founders/giovanni-collazo.jpeg and b/static/founders/giovanni-collazo.jpeg differ diff --git a/static/moderators/christian-rodriguez.jpeg b/static/moderators/christian-rodriguez.jpeg index 5df6acd..9ea975b 100644 Binary files a/static/moderators/christian-rodriguez.jpeg and b/static/moderators/christian-rodriguez.jpeg differ diff --git a/static/moderators/giovanni-collazo.jpeg b/static/moderators/giovanni-collazo.jpeg index a0bb6ab..befdfac 100644 Binary files a/static/moderators/giovanni-collazo.jpeg and b/static/moderators/giovanni-collazo.jpeg differ diff --git a/static/organizers/christian-rodriguez.jpeg b/static/organizers/christian-rodriguez.jpeg index 2809fb7..9ea975b 100644 Binary files a/static/organizers/christian-rodriguez.jpeg and b/static/organizers/christian-rodriguez.jpeg differ diff --git a/static/organizers/raul-negron.jpeg b/static/organizers/raul-negron.jpeg index 35f7e48..5022081 100644 Binary files a/static/organizers/raul-negron.jpeg and b/static/organizers/raul-negron.jpeg differ diff --git a/static/past-speakers/giovanni-collazo.jpeg b/static/past-speakers/giovanni-collazo.jpeg index a0bb6ab..befdfac 100644 Binary files a/static/past-speakers/giovanni-collazo.jpeg and b/static/past-speakers/giovanni-collazo.jpeg differ diff --git a/static/past-speakers/raul-negron.jpeg b/static/past-speakers/raul-negron.jpeg index 35f7e48..5022081 100644 Binary files a/static/past-speakers/raul-negron.jpeg and b/static/past-speakers/raul-negron.jpeg differ diff --git a/static/sponsors/posthog.svg b/static/sponsors/posthog.svg new file mode 100644 index 0000000..44d9f01 --- /dev/null +++ b/static/sponsors/posthog.svg @@ -0,0 +1 @@ + \ No newline at end of file