mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
## 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? As context, the events page pulls data from our webinar MDX files, Luma (for Hackathons, Events, and Meetups), and Notion (for third-party events we participate in). I recently unified our multiple Luma calendars into one and started using tags for Luma events. I fixed a few things on the /events page: 1. Webinars now show the proper location instead of "Unknown" 2. Event cards now show location properly depending on where the source of the events are 3. Event data is pulled from Luma and categorized based on tags. Fallback tag is "event" if a tag is absent Note that this structure assume that Notion events are third-party, Luma events are first-party (Hosted by Supabase), and Webinars are always online. This is a safe assumption for the time being, but I will revisit in the future if that changes. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Events now display tags and categories from Luma event data. * Webinar events are identified and labeled as "Supabase Live" with a video icon for improved visibility. * **Improvements** * Enhanced event metadata display with improved location and category information. * Streamlined event categorization for more consistent handling across event sources. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
225 lines
7.3 KiB
TypeScript
225 lines
7.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 }
|
|
}
|
|
|
|
// Maps Luma event tags to the app's category vocabulary. Keys are lowercased
|
|
// for case-insensitive matching against the tag names returned by Luma.
|
|
const LUMA_TAG_CATEGORY_MAP: Record<string, string> = {
|
|
hackathons: 'hackathon',
|
|
meetups: 'meetup',
|
|
events: 'conference',
|
|
}
|
|
|
|
const EventsContext = createContext<EventsContextValue | undefined>(undefined)
|
|
|
|
interface EventsProviderProps {
|
|
children: ReactNode
|
|
notionEvents: SupabaseEvent[]
|
|
mdxEvents: SupabaseEvent[]
|
|
onDemandMdxEvents: SupabaseEvent[]
|
|
}
|
|
|
|
export function EventsProvider({
|
|
children,
|
|
notionEvents,
|
|
mdxEvents,
|
|
onDemandMdxEvents,
|
|
}: 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 event's Luma tags: `Hackathons` → hackathon,
|
|
// `Meetups` → meetup, `Events` → conference. Untagged or unrecognized
|
|
// events fall back to the generic `conference` bucket.
|
|
const mappedCategories: string[] = (Array.isArray(event?.tags) ? event.tags : [])
|
|
.map((tag: string) => LUMA_TAG_CATEGORY_MAP[String(tag).trim().toLowerCase()])
|
|
.filter(Boolean)
|
|
const categories: string[] =
|
|
mappedCategories.length > 0 ? Array.from(new Set(mappedCategories)) : ['conference']
|
|
|
|
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 isOnDemandView = selectedCategories.includes('on-demand')
|
|
|
|
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
|
|
})
|
|
})
|
|
|
|
if (onDemandMdxEvents.length > 0) {
|
|
counts['on-demand'] = onDemandMdxEvents.length
|
|
}
|
|
|
|
return counts
|
|
}, [allEvents, onDemandMdxEvents.length])
|
|
|
|
const toggleCategory = (category: string) => {
|
|
if (category === 'on-demand') {
|
|
setSelectedCategories((prev) => (prev.includes('on-demand') ? ['all'] : ['on-demand']))
|
|
return
|
|
}
|
|
|
|
if (category === 'all') {
|
|
setSelectedCategories(['all'])
|
|
return
|
|
}
|
|
|
|
setSelectedCategories((prev) => {
|
|
const withoutAll = prev.filter((c) => c !== 'all' && c !== 'on-demand')
|
|
|
|
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 = isOnDemandView ? onDemandMdxEvents : allEvents
|
|
|
|
if (!isOnDemandView && !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 isOnDemandView ? dateB - dateA : dateA - dateB
|
|
})
|
|
}, [allEvents, onDemandMdxEvents, isOnDemandView, 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
|
|
}
|