Skip to content

Commit 7328697

Browse files
authored
feat(www): load _events mdx files on /events listing (supabase#45176)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature ## What is the current behavior? The /events page only loads events from the Notion "Developer Events" database and the Luma Community API. MDX files under `apps/www/_events/` (including webinars like agency-webinar, sentry, datadog, figma-make) are not surfaced on the listing, and past events could still appear until the moment they ended because the filter compared against `now()` rather than the current day. Addresses [DEBR-85](https://linear.app/supabase/issue/DEBR-85/events-page-powered-by-notion-page). ## What is the new behavior? - New `getMdxEvents()` reads `apps/www/_events/*.mdx`, parses frontmatter with `gray-matter`, and returns today-and-future events as `SupabaseEvent`s. - `/events` now merges Notion + mdx + Luma events. - Past events are hidden across all sources by comparing against the start of today (UTC) instead of `now()`, so events running today stay visible throughout the day. ## Additional context Links on mdx events point at the main_cta URL when it's an external \`http(s)\` URL, otherwise fall back to the built \`/events/{slug}\` page so on-demand recordings remain reachable. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Events can be sourced from MDX files and merged into site event listings. * Luma supports multiple calendars (community and hackathon) for richer feeds. * **Improvements** * Events now exclude anything before the start of the current UTC day. * Added a “Community Event” category filter and included it in counts. * Event title typography adjusted for improved readability. * “Hosted by” text now only shows when hosts exist; host fallbacks standardized. * **Chores** * Build env updated to include hackathon API key. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent e46ee77 commit 7328697

10 files changed

Lines changed: 257 additions & 95 deletions

File tree

apps/www/app/api-v2/luma-events/route.tsx

Lines changed: 66 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as Sentry from '@sentry/nextjs'
2-
import { NextRequest, NextResponse } from 'next/server'
32
import { DEFAULT_META_DESCRIPTION } from '~/lib/constants'
3+
import { NextRequest, NextResponse } from 'next/server'
44

55
export interface LumaGeoAddressJson {
66
city: string
@@ -58,81 +58,90 @@ interface LumaResponse {
5858
next_cursor?: string
5959
}
6060

61+
export type LumaCalendar = 'community' | 'hackathon'
62+
63+
async function fetchLumaCalendar(
64+
apiKey: string,
65+
calendar: LumaCalendar,
66+
after: string | null,
67+
before: string | null
68+
) {
69+
const lumaUrl = new URL('https://public-api.lu.ma/public/v1/calendar/list-events')
70+
if (after) lumaUrl.searchParams.append('after', after)
71+
if (before) lumaUrl.searchParams.append('before', before)
72+
73+
const response = await fetch(lumaUrl.toString(), {
74+
method: 'GET',
75+
headers: {
76+
accept: 'application/json',
77+
'x-luma-api-key': apiKey,
78+
},
79+
})
80+
81+
if (!response.ok) {
82+
throw new Error(`Luma API error (${calendar}): ${response.status} ${response.statusText}`)
83+
}
84+
85+
const data: LumaResponse = await response.json()
86+
87+
return data.entries
88+
.filter(({ event }) => event.visibility === 'public')
89+
.map(({ event }) => ({
90+
id: event.api_id,
91+
calendar,
92+
start_at: event.start_at,
93+
end_at: event.end_at,
94+
name: event.name,
95+
city: event.geo_address_json?.city,
96+
country: event.geo_address_json?.country,
97+
url: event.url,
98+
timezone: event.timezone,
99+
cover_url: event.cover_url,
100+
description: event.description,
101+
hosts: event.hosts || [],
102+
}))
103+
}
104+
61105
export async function GET(request: NextRequest) {
62106
try {
63-
const lumaApiKey = process.env.LUMA_API_KEY
107+
const communityKey = process.env.LUMA_API_KEY
108+
const hackathonKey = process.env.LUMA_HACKATHONS_API_KEY
64109

65-
if (!lumaApiKey) {
66-
console.error('LUMA_API_KEY environment variable is not set')
110+
if (!communityKey && !hackathonKey) {
111+
console.error('No Luma API keys configured (LUMA_API_KEY / LUMA_HACKATHONS_API_KEY)')
67112
return NextResponse.json({ error: 'API configuration error' }, { status: 500 })
68113
}
69114

70-
// Extract query parameters from the request
71115
const { searchParams } = new URL(request.url)
72116
const after = searchParams.get('after')
73117
const before = searchParams.get('before')
74118

75-
// Build the Luma API URL with query parameters
76-
const lumaUrl = new URL('https://public-api.lu.ma/public/v1/calendar/list-events')
119+
const calendarFetches: Array<Promise<Awaited<ReturnType<typeof fetchLumaCalendar>>>> = []
120+
if (communityKey)
121+
calendarFetches.push(fetchLumaCalendar(communityKey, 'community', after, before))
122+
if (hackathonKey)
123+
calendarFetches.push(fetchLumaCalendar(hackathonKey, 'hackathon', after, before))
77124

78-
if (after) {
79-
lumaUrl.searchParams.append('after', after)
80-
}
81-
if (before) {
82-
lumaUrl.searchParams.append('before', before)
83-
}
125+
const results = await Promise.allSettled(calendarFetches)
84126

85-
// Fetch events from Luma API
86-
const response = await fetch(lumaUrl.toString(), {
87-
method: 'GET',
88-
headers: {
89-
accept: 'application/json',
90-
'x-luma-api-key': lumaApiKey,
91-
},
92-
})
93-
94-
if (!response.ok) {
95-
console.error('Luma API error:', response.status, response.statusText)
96-
return NextResponse.json(
97-
{
98-
error: 'Failed to fetch events from Luma',
99-
status: response.status,
100-
},
101-
{ status: response.status }
102-
)
103-
}
104-
105-
const data: LumaResponse = await response.json()
106-
107-
const launchWeekEvents = data.entries
108-
.filter(({ event }: { event: LumaPayloadEvent }) => event.visibility === 'public')
109-
.map(({ event }: { event: LumaPayloadEvent }) => ({
110-
id: event.api_id,
111-
start_at: event.start_at,
112-
end_at: event.end_at,
113-
name: event.name,
114-
city: event.geo_address_json?.city,
115-
country: event.geo_address_json?.country,
116-
url: event.url,
117-
timezone: event.timezone,
118-
cover_url: event.cover_url,
119-
description: event.description,
120-
hosts: event.hosts || [],
121-
}))
127+
const events = results
128+
.flatMap((result) => {
129+
if (result.status === 'fulfilled') return result.value
130+
Sentry.captureException(result.reason)
131+
console.error(result.reason)
132+
return []
133+
})
122134
.sort((a, b) => new Date(a.start_at).getTime() - new Date(b.start_at).getTime())
123135

124136
return NextResponse.json({
125137
success: true,
126-
events: launchWeekEvents,
127-
total: launchWeekEvents.length,
128-
filters: {
129-
after,
130-
before,
131-
},
138+
events,
139+
total: events.length,
140+
filters: { after, before },
132141
})
133142
} catch (error) {
134143
Sentry.captureException(error)
135-
console.error('Error fetching meetups from Luma:', error)
144+
console.error('Error fetching events from Luma:', error)
136145
return NextResponse.json(
137146
{
138147
error: 'Internal server error',

apps/www/app/events/context.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ const EventsContext = createContext<EventsContextValue | undefined>(undefined)
2020
interface EventsProviderProps {
2121
children: ReactNode
2222
notionEvents: SupabaseEvent[]
23+
mdxEvents: SupabaseEvent[]
2324
}
2425

25-
export function EventsProvider({ children, notionEvents }: EventsProviderProps) {
26+
export function EventsProvider({ children, notionEvents, mdxEvents }: EventsProviderProps) {
2627
const [lumaEvents, setLumaEvents] = useState<SupabaseEvent[]>([])
2728
const [isLoading, setIsLoading] = useState(true)
2829
const [searchQuery, setSearchQuery] = useState('')
@@ -42,9 +43,12 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps)
4243

4344
if (data.success) {
4445
const transformedEvents: SupabaseEvent[] = data.events.map((event: any) => {
45-
let categories: string[] = []
46-
const isMeetup = event.name.toLowerCase().includes('meetup')
47-
if (isMeetup) categories.push('meetup')
46+
// Categorize by the originating Luma calendar: events from the
47+
// Supabase Hackathons calendar → `hackathon`; everything from the
48+
// Supabase Community Events calendar → `community`.
49+
const categories: string[] = [
50+
event?.calendar === 'hackathon' ? 'hackathon' : 'community',
51+
]
4852

4953
const rawUrl = event?.url || ''
5054
let safeUrl: string | undefined
@@ -73,7 +77,8 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps)
7377
location: new Intl.ListFormat('en', { style: 'narrow', type: 'unit' }).format(
7478
[event?.city, event?.country].filter(Boolean)
7579
),
76-
hosts: isMeetup || event?.hosts?.length === 0 ? [SUPABASE_HOST] : event?.hosts || [],
80+
// All Luma events are Supabase-hosted regardless of which calendar they're from.
81+
hosts: [SUPABASE_HOST],
7782
source: 'luma' as const,
7883
disable_page_build: true,
7984
link: safeUrl ? { href: safeUrl, target: '_blank' as const } : undefined,
@@ -91,14 +96,17 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps)
9196
fetchLumaEvents()
9297
}, [])
9398

94-
// Merge Notion (server) + Luma (client) events, excluding past events
99+
// Merge Notion (server) + mdx (server) + Luma (client) events, showing only today and future.
95100
const allEvents = useMemo(() => {
96101
const now = new Date()
97-
return [...notionEvents, ...lumaEvents].filter((event) => {
102+
const startOfToday = new Date(
103+
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
104+
)
105+
return [...notionEvents, ...mdxEvents, ...lumaEvents].filter((event) => {
98106
const eventDate = new Date(event.end_date || event.date)
99-
return eventDate >= now
107+
return eventDate >= startOfToday
100108
})
101-
}, [notionEvents, lumaEvents])
109+
}, [notionEvents, mdxEvents, lumaEvents])
102110

103111
const categories = useMemo(() => {
104112
const counts: { [key: string]: number } = { all: 0 }

apps/www/app/events/page.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EventClientRenderer } from '~/components/Events/new/EventClientRenderer'
22
import { breadcrumbs } from '~/lib/breadcrumbs'
3-
import { getNotionEvents } from '~/lib/events'
3+
import { getMdxEvents, getNotionEvents } from '~/lib/events'
44
import { breadcrumbListSchema, serializeJsonLd } from '~/lib/json-ld'
55
import type { Metadata } from 'next'
66

@@ -11,7 +11,10 @@ export const metadata: Metadata = {
1111
}
1212

1313
export default async function EventsPage() {
14-
const notionEvents = await getNotionEvents()
14+
const [notionEvents, mdxEvents] = await Promise.all([
15+
getNotionEvents(),
16+
Promise.resolve(getMdxEvents()),
17+
])
1518

1619
return (
1720
<>
@@ -21,7 +24,7 @@ export default async function EventsPage() {
2124
__html: serializeJsonLd(breadcrumbListSchema(breadcrumbs.eventsIndex)),
2225
}}
2326
/>
24-
<EventClientRenderer notionEvents={notionEvents} />
27+
<EventClientRenderer notionEvents={notionEvents} mdxEvents={mdxEvents} />
2528
</>
2629
)
2730
}

apps/www/components/Events/new/EventBanner.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ export function EventBanner() {
2828
</Badge>
2929
)}
3030
</div>
31-
<p
32-
className="text-lg font-medium text-foreground-light"
33-
title={`Hosted by ${formatHosts(featuredEvent.hosts).fullList}`}
34-
>
35-
{formatHosts(featuredEvent.hosts).displayText}
36-
</p>
31+
{featuredEvent.hosts.length > 0 && (
32+
<p
33+
className="text-lg font-medium text-foreground-light"
34+
title={`Hosted by ${formatHosts(featuredEvent.hosts).fullList}`}
35+
>
36+
{formatHosts(featuredEvent.hosts).displayText}
37+
</p>
38+
)}
3739
</div>
3840

3941
<p className="text-foreground-light line-clamp-3 lg:line-clamp-4">

apps/www/components/Events/new/EventClientRenderer.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,15 @@ function EventBannerSection() {
1919
)
2020
}
2121

22-
export function EventClientRenderer({ notionEvents }: { notionEvents: SupabaseEvent[] }) {
22+
export function EventClientRenderer({
23+
notionEvents,
24+
mdxEvents,
25+
}: {
26+
notionEvents: SupabaseEvent[]
27+
mdxEvents: SupabaseEvent[]
28+
}) {
2329
return (
24-
<EventsProvider notionEvents={notionEvents}>
30+
<EventsProvider notionEvents={notionEvents} mdxEvents={mdxEvents}>
2531
<DefaultLayout className="flex flex-col">
2632
<EventsContainer className="border-x border-b py-8">
2733
<h1 className="h3 p-0! m-0!">

apps/www/components/Events/new/EventGalleryFilters.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
'use client'
22

3-
import { Input_Shadcn_ as Input } from 'ui'
4-
import { SearchIcon } from 'lucide-react'
5-
import { Badge } from 'ui'
63
import { useEvents } from '~/app/events/context'
4+
import { SearchIcon } from 'lucide-react'
5+
import { Badge, Input_Shadcn_ as Input } from 'ui'
76

87
const CATEGORIES_FILTERS = [
98
{ name: 'All', value: 'all' },
109
{ name: 'Conference', value: 'conference' },
10+
{ name: 'Community Event', value: 'community' },
1111
{ name: 'Meetup', value: 'meetup' },
1212
{ name: 'Workshop', value: 'workshop' },
1313
{ name: 'Hackathon', value: 'hackathon' },

apps/www/components/Events/new/EventList.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Badge, Button, cn } from 'ui'
88

99
const CATEGORIES_FILTERS = [
1010
{ name: 'All', value: 'all' },
11+
{ name: 'Community Event', value: 'community' },
1112
{ name: 'Meetup', value: 'meetup' },
1213
{ name: 'Conference', value: 'conference' },
1314
{ name: 'Workshop', value: 'workshop' },
@@ -83,7 +84,7 @@ export function EventList() {
8384
<div className="flex flex-col gap-4">
8485
<div className="flex flex-col gap-2">
8586
<div className="flex items-center gap-2">
86-
<h3 className="leading-3">{event.title}</h3>
87+
<h3 className="leading-snug">{event.title}</h3>
8788
{event.isSpeaking && (
8889
<Badge variant="success" className="flex items-center gap-1">
8990
Speaking
@@ -107,18 +108,20 @@ export function EventList() {
107108
)}
108109
</div>
109110

110-
<div className="flex gap-2 items-center text-sm text-foreground-light">
111-
<div className="size-5 rounded-full border bg-linear-to-br from-background-surface-100 to-background-surface-200 relative">
112-
{event.hosts[0]?.avatar_url && (
113-
<img
114-
src={event.hosts[0].avatar_url}
115-
alt={event.hosts[0].name || 'Host image'}
116-
className="absolute inset-0 w-full h-full object-cover rounded-full"
117-
/>
118-
)}
111+
{event.hosts.length > 0 && (
112+
<div className="flex gap-2 items-center text-sm text-foreground-light">
113+
<div className="size-5 rounded-full border bg-linear-to-br from-background-surface-100 to-background-surface-200 relative">
114+
{event.hosts[0]?.avatar_url && (
115+
<img
116+
src={event.hosts[0].avatar_url}
117+
alt={event.hosts[0].name || 'Host image'}
118+
className="absolute inset-0 w-full h-full object-cover rounded-full"
119+
/>
120+
)}
121+
</div>
122+
Hosted by {formatHosts(event.hosts).displayText}
119123
</div>
120-
Hosted by {formatHosts(event.hosts).displayText}
121-
</div>
124+
)}
122125
</div>
123126

124127
<div className="flex items-center gap-2 relative z-10">

0 commit comments

Comments
 (0)