diff --git a/apps/www/app/api-v2/luma-events/route.tsx b/apps/www/app/api-v2/luma-events/route.tsx index 7dad9fd21a9..6104bebc476 100644 --- a/apps/www/app/api-v2/luma-events/route.tsx +++ b/apps/www/app/api-v2/luma-events/route.tsx @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/nextjs' -import { NextRequest, NextResponse } from 'next/server' import { DEFAULT_META_DESCRIPTION } from '~/lib/constants' +import { NextRequest, NextResponse } from 'next/server' export interface LumaGeoAddressJson { city: string @@ -58,81 +58,90 @@ interface LumaResponse { next_cursor?: string } +export type LumaCalendar = 'community' | 'hackathon' + +async function fetchLumaCalendar( + apiKey: string, + calendar: LumaCalendar, + after: string | null, + before: string | null +) { + const lumaUrl = new URL('https://public-api.lu.ma/public/v1/calendar/list-events') + if (after) lumaUrl.searchParams.append('after', after) + if (before) lumaUrl.searchParams.append('before', before) + + const response = await fetch(lumaUrl.toString(), { + method: 'GET', + headers: { + accept: 'application/json', + 'x-luma-api-key': apiKey, + }, + }) + + if (!response.ok) { + throw new Error(`Luma API error (${calendar}): ${response.status} ${response.statusText}`) + } + + const data: LumaResponse = await response.json() + + return data.entries + .filter(({ event }) => event.visibility === 'public') + .map(({ event }) => ({ + id: event.api_id, + calendar, + start_at: event.start_at, + end_at: event.end_at, + name: event.name, + city: event.geo_address_json?.city, + country: event.geo_address_json?.country, + url: event.url, + timezone: event.timezone, + cover_url: event.cover_url, + description: event.description, + hosts: event.hosts || [], + })) +} + export async function GET(request: NextRequest) { try { - const lumaApiKey = process.env.LUMA_API_KEY + const communityKey = process.env.LUMA_API_KEY + const hackathonKey = process.env.LUMA_HACKATHONS_API_KEY - if (!lumaApiKey) { - console.error('LUMA_API_KEY environment variable is not set') + if (!communityKey && !hackathonKey) { + console.error('No Luma API keys configured (LUMA_API_KEY / LUMA_HACKATHONS_API_KEY)') return NextResponse.json({ error: 'API configuration error' }, { status: 500 }) } - // Extract query parameters from the request const { searchParams } = new URL(request.url) const after = searchParams.get('after') const before = searchParams.get('before') - // Build the Luma API URL with query parameters - const lumaUrl = new URL('https://public-api.lu.ma/public/v1/calendar/list-events') + const calendarFetches: Array>>> = [] + if (communityKey) + calendarFetches.push(fetchLumaCalendar(communityKey, 'community', after, before)) + if (hackathonKey) + calendarFetches.push(fetchLumaCalendar(hackathonKey, 'hackathon', after, before)) - if (after) { - lumaUrl.searchParams.append('after', after) - } - if (before) { - lumaUrl.searchParams.append('before', before) - } + const results = await Promise.allSettled(calendarFetches) - // Fetch events from Luma API - const response = await fetch(lumaUrl.toString(), { - method: 'GET', - headers: { - accept: 'application/json', - 'x-luma-api-key': lumaApiKey, - }, - }) - - if (!response.ok) { - console.error('Luma API error:', response.status, response.statusText) - return NextResponse.json( - { - error: 'Failed to fetch events from Luma', - status: response.status, - }, - { status: response.status } - ) - } - - const data: LumaResponse = await response.json() - - const launchWeekEvents = data.entries - .filter(({ event }: { event: LumaPayloadEvent }) => event.visibility === 'public') - .map(({ event }: { event: LumaPayloadEvent }) => ({ - id: event.api_id, - start_at: event.start_at, - end_at: event.end_at, - name: event.name, - city: event.geo_address_json?.city, - country: event.geo_address_json?.country, - url: event.url, - timezone: event.timezone, - cover_url: event.cover_url, - description: event.description, - hosts: event.hosts || [], - })) + const events = results + .flatMap((result) => { + if (result.status === 'fulfilled') return result.value + Sentry.captureException(result.reason) + console.error(result.reason) + return [] + }) .sort((a, b) => new Date(a.start_at).getTime() - new Date(b.start_at).getTime()) return NextResponse.json({ success: true, - events: launchWeekEvents, - total: launchWeekEvents.length, - filters: { - after, - before, - }, + events, + total: events.length, + filters: { after, before }, }) } catch (error) { Sentry.captureException(error) - console.error('Error fetching meetups from Luma:', error) + console.error('Error fetching events from Luma:', error) return NextResponse.json( { error: 'Internal server error', diff --git a/apps/www/app/events/context.tsx b/apps/www/app/events/context.tsx index 0b95f6ecafb..6de7bd3b10c 100644 --- a/apps/www/app/events/context.tsx +++ b/apps/www/app/events/context.tsx @@ -20,9 +20,10 @@ const EventsContext = createContext(undefined) interface EventsProviderProps { children: ReactNode notionEvents: SupabaseEvent[] + mdxEvents: SupabaseEvent[] } -export function EventsProvider({ children, notionEvents }: EventsProviderProps) { +export function EventsProvider({ children, notionEvents, mdxEvents }: EventsProviderProps) { const [lumaEvents, setLumaEvents] = useState([]) const [isLoading, setIsLoading] = useState(true) const [searchQuery, setSearchQuery] = useState('') @@ -42,9 +43,12 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps) if (data.success) { const transformedEvents: SupabaseEvent[] = data.events.map((event: any) => { - let categories: string[] = [] - const isMeetup = event.name.toLowerCase().includes('meetup') - if (isMeetup) categories.push('meetup') + // Categorize by the originating Luma calendar: events from the + // Supabase Hackathons calendar → `hackathon`; everything from the + // Supabase Community Events calendar → `community`. + const categories: string[] = [ + event?.calendar === 'hackathon' ? 'hackathon' : 'community', + ] const rawUrl = event?.url || '' let safeUrl: string | undefined @@ -73,7 +77,8 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps) location: new Intl.ListFormat('en', { style: 'narrow', type: 'unit' }).format( [event?.city, event?.country].filter(Boolean) ), - hosts: isMeetup || event?.hosts?.length === 0 ? [SUPABASE_HOST] : event?.hosts || [], + // All Luma events are Supabase-hosted regardless of which calendar they're from. + hosts: [SUPABASE_HOST], source: 'luma' as const, disable_page_build: true, link: safeUrl ? { href: safeUrl, target: '_blank' as const } : undefined, @@ -91,14 +96,17 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps) fetchLumaEvents() }, []) - // Merge Notion (server) + Luma (client) events, excluding past events + // Merge Notion (server) + mdx (server) + Luma (client) events, showing only today and future. const allEvents = useMemo(() => { const now = new Date() - return [...notionEvents, ...lumaEvents].filter((event) => { + const startOfToday = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ) + return [...notionEvents, ...mdxEvents, ...lumaEvents].filter((event) => { const eventDate = new Date(event.end_date || event.date) - return eventDate >= now + return eventDate >= startOfToday }) - }, [notionEvents, lumaEvents]) + }, [notionEvents, mdxEvents, lumaEvents]) const categories = useMemo(() => { const counts: { [key: string]: number } = { all: 0 } diff --git a/apps/www/app/events/page.tsx b/apps/www/app/events/page.tsx index 75c29ede820..ca566397bae 100644 --- a/apps/www/app/events/page.tsx +++ b/apps/www/app/events/page.tsx @@ -1,6 +1,6 @@ import { EventClientRenderer } from '~/components/Events/new/EventClientRenderer' import { breadcrumbs } from '~/lib/breadcrumbs' -import { getNotionEvents } from '~/lib/events' +import { getMdxEvents, getNotionEvents } from '~/lib/events' import { breadcrumbListSchema, serializeJsonLd } from '~/lib/json-ld' import type { Metadata } from 'next' @@ -11,7 +11,10 @@ export const metadata: Metadata = { } export default async function EventsPage() { - const notionEvents = await getNotionEvents() + const [notionEvents, mdxEvents] = await Promise.all([ + getNotionEvents(), + Promise.resolve(getMdxEvents()), + ]) return ( <> @@ -21,7 +24,7 @@ export default async function EventsPage() { __html: serializeJsonLd(breadcrumbListSchema(breadcrumbs.eventsIndex)), }} /> - + ) } diff --git a/apps/www/components/Events/new/EventBanner.tsx b/apps/www/components/Events/new/EventBanner.tsx index bf075e14370..1ad1de80bfb 100644 --- a/apps/www/components/Events/new/EventBanner.tsx +++ b/apps/www/components/Events/new/EventBanner.tsx @@ -28,12 +28,14 @@ export function EventBanner() { )} -

- {formatHosts(featuredEvent.hosts).displayText} -

+ {featuredEvent.hosts.length > 0 && ( +

+ {formatHosts(featuredEvent.hosts).displayText} +

+ )}

diff --git a/apps/www/components/Events/new/EventClientRenderer.tsx b/apps/www/components/Events/new/EventClientRenderer.tsx index 9cf74c5bbb7..3693dacc615 100644 --- a/apps/www/components/Events/new/EventClientRenderer.tsx +++ b/apps/www/components/Events/new/EventClientRenderer.tsx @@ -19,9 +19,15 @@ function EventBannerSection() { ) } -export function EventClientRenderer({ notionEvents }: { notionEvents: SupabaseEvent[] }) { +export function EventClientRenderer({ + notionEvents, + mdxEvents, +}: { + notionEvents: SupabaseEvent[] + mdxEvents: SupabaseEvent[] +}) { return ( - +

diff --git a/apps/www/components/Events/new/EventGalleryFilters.tsx b/apps/www/components/Events/new/EventGalleryFilters.tsx index 06b0a4f2bce..8396225b1e3 100644 --- a/apps/www/components/Events/new/EventGalleryFilters.tsx +++ b/apps/www/components/Events/new/EventGalleryFilters.tsx @@ -1,13 +1,13 @@ 'use client' -import { Input_Shadcn_ as Input } from 'ui' -import { SearchIcon } from 'lucide-react' -import { Badge } from 'ui' import { useEvents } from '~/app/events/context' +import { SearchIcon } from 'lucide-react' +import { Badge, Input_Shadcn_ as Input } from 'ui' const CATEGORIES_FILTERS = [ { name: 'All', value: 'all' }, { name: 'Conference', value: 'conference' }, + { name: 'Community Event', value: 'community' }, { name: 'Meetup', value: 'meetup' }, { name: 'Workshop', value: 'workshop' }, { name: 'Hackathon', value: 'hackathon' }, diff --git a/apps/www/components/Events/new/EventList.tsx b/apps/www/components/Events/new/EventList.tsx index 2af52ac17a9..2bfd6dcea53 100644 --- a/apps/www/components/Events/new/EventList.tsx +++ b/apps/www/components/Events/new/EventList.tsx @@ -8,6 +8,7 @@ import { Badge, Button, cn } from 'ui' const CATEGORIES_FILTERS = [ { name: 'All', value: 'all' }, + { name: 'Community Event', value: 'community' }, { name: 'Meetup', value: 'meetup' }, { name: 'Conference', value: 'conference' }, { name: 'Workshop', value: 'workshop' }, @@ -83,7 +84,7 @@ export function EventList() {
-

{event.title}

+

{event.title}

{event.isSpeaking && ( Speaking @@ -107,18 +108,20 @@ export function EventList() { )}
-
-
- {event.hosts[0]?.avatar_url && ( - {event.hosts[0].name - )} + {event.hosts.length > 0 && ( +
+
+ {event.hosts[0]?.avatar_url && ( + {event.hosts[0].name + )} +
+ Hosted by {formatHosts(event.hosts).displayText}
- Hosted by {formatHosts(event.hosts).displayText} -
+ )}
diff --git a/apps/www/lib/events.ts b/apps/www/lib/events.ts index 9deef76277a..107e701903d 100644 --- a/apps/www/lib/events.ts +++ b/apps/www/lib/events.ts @@ -16,9 +16,12 @@ * Are you speaking at this event? -> multi_select * Participation -> multi_select */ +import fs from 'fs' +import path from 'path' +import matter from 'gray-matter' import { queryDatabase } from 'lib/notion' -import { SUPABASE_HOST, SupabaseEvent } from './eventsTypes' +import { EventHost, SUPABASE_HOST, SupabaseEvent } from './eventsTypes' // The actual DB ID (child database inside the page) const NOTION_EVENTS_DB_ID_FALLBACK = '21b5004b775f8058872fe8fa81e2c7ac' @@ -70,6 +73,12 @@ function getMultiSelect(page: any, name: string): string[] { return prop.multi_select.map((s: any) => s.name) } +function getSelect(page: any, name: string): string { + const prop = page.properties[name] + if (!prop || prop.type !== 'select') return '' + return prop.select?.name ?? '' +} + // ─── Main fetch ───────────────────────────────────────────────────────────── /** @@ -107,6 +116,10 @@ export const getNotionEvents = async (): Promise => { const rawMeetingLink = getUrl(page, 'Book Meeting Link') const meetingLink = isSafeHttpUrl(rawMeetingLink) ? rawMeetingLink : '' const location = getRichText(page, 'Location') + const notionType = getSelect(page, 'Type') + // "Conference" events are third-party — we attend but don't host, so no + // "Hosted by" line. "Supabase event" → host = Supabase. + const isConferenceType = notionType.toLowerCase() === 'conference' const categories = ['conference'] const speakingAnswers = getMultiSelect(page, 'Are you speaking at this event?') const isSpeaking = speakingAnswers.includes('Yes') @@ -126,7 +139,7 @@ export const getNotionEvents = async (): Promise => { categories, timezone: '', location, - hosts: [SUPABASE_HOST], + hosts: isConferenceType ? [] : [SUPABASE_HOST], source: 'notion', disable_page_build: true, isSpeaking, @@ -140,3 +153,120 @@ export const getNotionEvents = async (): Promise => { return [] } } + +// ─── MDX events ───────────────────────────────────────────────────────────── + +const EVENTS_DIRECTORY = '_events' +const FILENAME_DATE_PREFIX_LENGTH = 11 + +type MdxHost = { name?: string; avatar_url?: string } + +type MdxFrontmatter = { + title?: string + subtitle?: string + description?: string + meta_description?: string + type?: string + date?: string + end_date?: string + timezone?: string + categories?: string[] + tags?: string[] + onDemand?: boolean + disable_page_build?: boolean + hosts?: MdxHost[] + main_cta?: { url?: string; target?: '_blank' | '_self'; label?: string } +} + +function mdxSlugFromFilename(filename: string): string { + // Matches the slug produced by getAllPostSlugs for `_events` (keeps any leading + // underscore from `YYYY-MM-DD__name.mdx`), so /events/{slug} lands on the built page. + return filename.replace(/\.mdx$/, '').substring(FILENAME_DATE_PREFIX_LENGTH) +} + +function mdxHostsToEventHosts(hosts: MdxHost[] | undefined): EventHost[] { + if (!hosts || hosts.length === 0) return [SUPABASE_HOST] + return hosts.map((host, i) => ({ + id: `mdx-host-${i}`, + email: '', + name: host.name ?? null, + first_name: host.name ?? null, + last_name: null, + avatar_url: host.avatar_url ?? '', + })) +} + +function startOfTodayUtc(): Date { + const now = new Date() + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) +} + +/** + * Read all events under `_events/` and return today-and-future events. + * Past events are excluded (by end_date when present, otherwise start date). + */ +export const getMdxEvents = (): SupabaseEvent[] => { + const postDirectory = path.join(process.cwd(), EVENTS_DIRECTORY) + + let fileNames: string[] + try { + fileNames = fs.readdirSync(postDirectory) + } catch (error) { + console.error('Error reading _events directory:', error) + return [] + } + + const today = startOfTodayUtc() + + return fileNames + .filter((filename) => filename.endsWith('.mdx')) + .map((filename): SupabaseEvent | null => { + try { + const fullPath = path.join(postDirectory, filename) + const fileContents = fs.readFileSync(fullPath, 'utf8') + const { data } = matter(fileContents) as unknown as { data: MdxFrontmatter } + + if (!data.title || !data.date) return null + + const eventEnd = new Date(data.end_date ?? data.date) + if (eventEnd < today) return null + + const slug = mdxSlugFromFilename(filename) + const categories = data.categories ?? (data.type ? [data.type] : []) + // Webinars don't show a "Hosted by" line — drop hosts entirely. + const isWebinar = data.type === 'webinar' || categories.includes('webinar') + const rawCtaUrl = data.main_cta?.url ?? '' + const isExternalCta = /^https?:\/\//i.test(rawCtaUrl) + const safeExternalCta = isExternalCta && isSafeHttpUrl(rawCtaUrl) ? rawCtaUrl : '' + const internalPath = `/events/${slug}` + const href = safeExternalCta || internalPath + const target = safeExternalCta ? '_blank' : '_self' + + return { + slug, + type: data.type ?? 'event', + title: data.title, + date: data.date, + end_date: data.end_date, + description: data.meta_description ?? data.description ?? data.subtitle ?? '', + thumb: '', + cover_url: '', + path: internalPath, + url: href, + tags: data.tags ?? categories, + categories, + timezone: data.timezone ?? '', + location: '', + hosts: isWebinar ? [] : mdxHostsToEventHosts(data.hosts), + source: 'mdx', + onDemand: data.onDemand, + disable_page_build: data.disable_page_build, + link: { href, target, label: data.main_cta?.label }, + } + } catch (error) { + console.error(`Error parsing mdx event ${filename}:`, error) + return null + } + }) + .filter((e): e is SupabaseEvent => e !== null) +} diff --git a/apps/www/lib/eventsTypes.ts b/apps/www/lib/eventsTypes.ts index 21b72084156..948dace67b2 100644 --- a/apps/www/lib/eventsTypes.ts +++ b/apps/www/lib/eventsTypes.ts @@ -45,7 +45,7 @@ export interface SupabaseEvent { timezone: string location: string hosts: EventHost[] - source: 'luma' | 'notion' + source: 'luma' | 'notion' | 'mdx' end_date?: string onDemand?: boolean disable_page_build?: boolean diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc index deb1af49844..a187d9e15a4 100644 --- a/apps/www/turbo.jsonc +++ b/apps/www/turbo.jsonc @@ -62,6 +62,7 @@ "ASSET_CDN_S3_ENDPOINT", "SITE_NAME", "LUMA_API_KEY", + "LUMA_HACKATHONS_API_KEY", "NEXT_RUNTIME", // These env vars are used by some community and marketing integrations "SENTRY_DSN_COMMUNITY",