Files
supabase/apps/www/app/events/context.tsx
Alan Daniel 73286972fb feat(www): load _events mdx files on /events listing (#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 -->
2026-05-08 11:22:35 -04:00

198 lines
6.3 KiB
TypeScript

'use client'
import { SUPABASE_HOST, SupabaseEvent } from '~/lib/eventsTypes'
import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react'
interface EventsContextValue {
allEvents: SupabaseEvent[]
filteredEvents: SupabaseEvent[]
featuredEvent: SupabaseEvent | undefined
isLoading: boolean
searchQuery: string
setSearchQuery: (query: string) => void
selectedCategories: string[]
toggleCategory: (category: string) => void
categories: { [key: string]: number }
}
const EventsContext = createContext<EventsContextValue | undefined>(undefined)
interface EventsProviderProps {
children: ReactNode
notionEvents: SupabaseEvent[]
mdxEvents: SupabaseEvent[]
}
export function EventsProvider({ children, notionEvents, mdxEvents }: EventsProviderProps) {
const [lumaEvents, setLumaEvents] = useState<SupabaseEvent[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategories, setSelectedCategories] = useState<string[]>(['all'])
useEffect(() => {
const fetchLumaEvents = async () => {
try {
setIsLoading(true)
const afterDate = new Date().toISOString()
const url = new URL('/api-v2/luma-events', window.location.origin)
url.searchParams.append('after', afterDate)
const res = await fetch(url.toString())
const data = await res.json()
if (data.success) {
const transformedEvents: SupabaseEvent[] = data.events.map((event: any) => {
// 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
try {
const parsed = new URL(rawUrl)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
safeUrl = rawUrl
}
} catch {
// invalid URL
}
return {
slug: '',
type: 'event',
title: event?.name || '',
date: event?.start_at || '',
description: event?.description || '',
thumb: '',
cover_url: event?.cover_url || '',
path: '',
url: safeUrl ?? '',
tags: categories,
categories,
timezone: event?.timezone || 'America/Los_Angeles',
location: new Intl.ListFormat('en', { style: 'narrow', type: 'unit' }).format(
[event?.city, event?.country].filter(Boolean)
),
// 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,
}
})
setLumaEvents(transformedEvents)
}
} catch (error) {
console.error('Error fetching Luma events:', error)
} finally {
setIsLoading(false)
}
}
fetchLumaEvents()
}, [])
// Merge Notion (server) + mdx (server) + Luma (client) events, showing only today and future.
const allEvents = useMemo(() => {
const now = new Date()
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 >= startOfToday
})
}, [notionEvents, mdxEvents, lumaEvents])
const categories = useMemo(() => {
const counts: { [key: string]: number } = { all: 0 }
allEvents.forEach((event) => {
counts.all += 1
event.categories?.forEach((category) => {
counts[category] = (counts[category] || 0) + 1
})
})
return counts
}, [allEvents])
const toggleCategory = (category: string) => {
if (category === 'all') {
setSelectedCategories(['all'])
return
}
setSelectedCategories((prev) => {
const withoutAll = prev.filter((c) => c !== 'all')
if (withoutAll.includes(category)) {
const updated = withoutAll.filter((c) => c !== category)
return updated.length === 0 ? ['all'] : updated
} else {
return [...withoutAll, category]
}
})
}
const filteredEvents = useMemo(() => {
let filtered = allEvents
if (!selectedCategories.includes('all')) {
filtered = filtered.filter((event) =>
event.categories?.some((cat) => selectedCategories.includes(cat))
)
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter((event) => {
const titleMatch = event.title?.toLowerCase().includes(query)
const descriptionMatch = event.description?.toLowerCase().includes(query)
const locationMatch = event.location?.toLowerCase().includes(query)
const tagsMatch = event.tags?.some((tag) => tag.toLowerCase().includes(query))
return titleMatch || descriptionMatch || locationMatch || tagsMatch
})
}
return [...filtered].sort((a, b) => {
const dateA = new Date(a.date).getTime()
const dateB = new Date(b.date).getTime()
return dateA - dateB
})
}, [allEvents, selectedCategories, searchQuery])
const featuredEvent = useMemo(() => {
if (allEvents.length === 0) return undefined
return [...allEvents].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())[0]
}, [allEvents])
const value: EventsContextValue = {
allEvents,
filteredEvents,
featuredEvent,
isLoading,
searchQuery,
setSearchQuery,
selectedCategories,
toggleCategory,
categories,
}
return <EventsContext.Provider value={value}>{children}</EventsContext.Provider>
}
export function useEvents() {
const context = useContext(EventsContext)
if (context === undefined) {
throw new Error('useEvents must be used within an EventsProvider')
}
return context
}