From 0f01f08767755cfcfedcaf728710e4776bf138f3 Mon Sep 17 00:00:00 2001 From: omkar hole Date: Thu, 15 Jan 2026 13:06:13 +0530 Subject: [PATCH 1/2] Fixed Create Event and view details for event --- src/components/EventList.tsx | 14 +- src/hooks/useMessaging.ts | 312 +++++++++++++++++--------------- src/pages/EventDetailPage.tsx | 324 +++++++++++++++++++++------------- 3 files changed, 379 insertions(+), 271 deletions(-) diff --git a/src/components/EventList.tsx b/src/components/EventList.tsx index 43ada72b..68376c19 100644 --- a/src/components/EventList.tsx +++ b/src/components/EventList.tsx @@ -1,5 +1,5 @@ -import { EventCard } from './EventCard'; -import type { EventWithDetails, EventFilters } from '../types/events'; +import { EventCard } from "./EventCard"; +import type { EventWithDetails, EventFilters } from "../types/events"; interface EventListProps { events: EventWithDetails[]; @@ -9,10 +9,10 @@ interface EventListProps { className?: string; } -export const EventList = ({ - events, - emptyMessage = "No events found", - className = "" +export const EventList = ({ + events, + emptyMessage = "No events found", + className = "", }: EventListProps) => { if (events.length === 0) { return ( @@ -29,4 +29,4 @@ export const EventList = ({ ))} ); -}; \ No newline at end of file +}; diff --git a/src/hooks/useMessaging.ts b/src/hooks/useMessaging.ts index 8602956a..a53ffb8d 100644 --- a/src/hooks/useMessaging.ts +++ b/src/hooks/useMessaging.ts @@ -1,28 +1,29 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { supabase } from '../supabase-client'; -import { useAuth } from '../hooks/useAuth'; -import type { - Message, - ConversationWithDetails, - CreateConversationData, +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { supabase } from "../supabase-client"; +import { useAuth } from "../hooks/useAuth"; +import type { + Message, + ConversationWithDetails, + CreateConversationData, SendMessageData, UserPresence, - TypingIndicator -} from '../types/messaging'; + TypingIndicator, +} from "../types/messaging"; // Hook for fetching conversations export const useConversations = () => { const { user } = useAuth(); - + return useQuery({ - queryKey: ['conversations', user?.id], + queryKey: ["conversations", user?.id], queryFn: async () => { if (!user) return []; - + const { data, error } = await supabase - .from('Conversations') - .select(` + .from("Conversations") + .select( + ` *, participants:ConversationParticipants( *, @@ -32,9 +33,10 @@ export const useConversations = () => { *, sender:auth.users(id, email, user_metadata) ) - `) - .order('updated_at', { ascending: false }); - + ` + ) + .order("updated_at", { ascending: false }); + if (error) throw error; return data as ConversationWithDetails[]; }, @@ -45,13 +47,14 @@ export const useConversations = () => { // Hook for fetching messages in a conversation export const useMessages = (conversationId: number) => { const { user } = useAuth(); - + return useQuery({ - queryKey: ['messages', conversationId], + queryKey: ["messages", conversationId], queryFn: async () => { const { data, error } = await supabase - .from('Messages') - .select(` + .from("Messages") + .select( + ` *, sender:auth.users(id, email, user_metadata), reply_to:Messages( @@ -62,11 +65,12 @@ export const useMessages = (conversationId: number) => { *, user:auth.users(id, email, user_metadata) ) - `) - .eq('conversation_id', conversationId) - .eq('is_deleted', false) - .order('created_at', { ascending: true }); - + ` + ) + .eq("conversation_id", conversationId) + .eq("is_deleted", false) + .order("created_at", { ascending: true }); + if (error) throw error; return data as Message[]; }, @@ -78,14 +82,14 @@ export const useMessages = (conversationId: number) => { export const useCreateConversation = () => { const queryClient = useQueryClient(); const { user } = useAuth(); - + return useMutation({ mutationFn: async (data: CreateConversationData) => { - if (!user) throw new Error('User not authenticated'); - + if (!user) throw new Error("User not authenticated"); + // Create conversation const { data: conversation, error: convError } = await supabase - .from('Conversations') + .from("Conversations") .insert({ name: data.name, type: data.type, @@ -95,29 +99,29 @@ export const useCreateConversation = () => { }) .select() .single(); - + if (convError) throw convError; - + // Add participants const participants = [ - { conversation_id: conversation.id, user_id: user.id, role: 'admin' }, - ...data.participant_ids.map(id => ({ + { conversation_id: conversation.id, user_id: user.id, role: "admin" }, + ...data.participant_ids.map((id) => ({ conversation_id: conversation.id, user_id: id, - role: 'member' as const - })) + role: "member" as const, + })), ]; - + const { error: partError } = await supabase - .from('ConversationParticipants') + .from("ConversationParticipants") .insert(participants); - + if (partError) throw partError; - + return conversation; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['conversations'] }); + queryClient.invalidateQueries({ queryKey: ["conversations"] }); }, }); }; @@ -126,34 +130,38 @@ export const useCreateConversation = () => { export const useSendMessage = () => { const queryClient = useQueryClient(); const { user } = useAuth(); - + return useMutation({ mutationFn: async (data: SendMessageData) => { - if (!user) throw new Error('User not authenticated'); - + if (!user) throw new Error("User not authenticated"); + const { data: message, error } = await supabase - .from('Messages') + .from("Messages") .insert({ conversation_id: data.conversation_id, sender_id: user.id, content: data.content, - message_type: data.message_type || 'text', + message_type: data.message_type || "text", file_url: data.file_url, file_name: data.file_name, reply_to_id: data.reply_to_id, }) - .select(` + .select( + ` *, sender:auth.users(id, email, user_metadata) - `) + ` + ) .single(); - + if (error) throw error; return message; }, onSuccess: (message) => { - queryClient.invalidateQueries({ queryKey: ['messages', message.conversation_id] }); - queryClient.invalidateQueries({ queryKey: ['conversations'] }); + queryClient.invalidateQueries({ + queryKey: ["messages", message.conversation_id], + }); + queryClient.invalidateQueries({ queryKey: ["conversations"] }); }, }); }; @@ -162,39 +170,43 @@ export const useSendMessage = () => { export const useRealtimeMessages = (conversationId: number) => { const queryClient = useQueryClient(); const { user } = useAuth(); - + useEffect(() => { if (!user || !conversationId) return; - + const channel = supabase .channel(`messages:${conversationId}`) .on( - 'postgres_changes', + "postgres_changes", { - event: 'INSERT', - schema: 'public', - table: 'Messages', + event: "INSERT", + schema: "public", + table: "Messages", filter: `conversation_id=eq.${conversationId}`, }, () => { - queryClient.invalidateQueries({ queryKey: ['messages', conversationId] }); - queryClient.invalidateQueries({ queryKey: ['conversations'] }); + queryClient.invalidateQueries({ + queryKey: ["messages", conversationId], + }); + queryClient.invalidateQueries({ queryKey: ["conversations"] }); } ) .on( - 'postgres_changes', + "postgres_changes", { - event: 'UPDATE', - schema: 'public', - table: 'Messages', + event: "UPDATE", + schema: "public", + table: "Messages", filter: `conversation_id=eq.${conversationId}`, }, () => { - queryClient.invalidateQueries({ queryKey: ['messages', conversationId] }); + queryClient.invalidateQueries({ + queryKey: ["messages", conversationId], + }); } ) .subscribe(); - + return () => { supabase.removeChannel(channel); }; @@ -205,32 +217,30 @@ export const useRealtimeMessages = (conversationId: number) => { export const useUserPresence = () => { const { user } = useAuth(); const [onlineUsers, setOnlineUsers] = useState([]); - + useEffect(() => { if (!user) return; - + // Update user status to online const updatePresence = async () => { - await supabase - .from('UserPresence') - .upsert({ - user_id: user.id, - status: 'online', - last_seen: new Date().toISOString(), - }); + await supabase.from("UserPresence").upsert({ + user_id: user.id, + status: "online", + last_seen: new Date().toISOString(), + }); }; - + updatePresence(); - + // Set up real-time subscription for presence updates const channel = supabase - .channel('user-presence') + .channel("user-presence") .on( - 'postgres_changes', + "postgres_changes", { - event: '*', - schema: 'public', - table: 'UserPresence', + event: "*", + schema: "public", + table: "UserPresence", }, () => { // Refetch presence data @@ -238,32 +248,32 @@ export const useUserPresence = () => { } ) .subscribe(); - + const fetchPresence = async () => { const { data } = await supabase - .from('UserPresence') - .select('*') - .eq('status', 'online'); - + .from("UserPresence") + .select("*") + .eq("status", "online"); + if (data) setOnlineUsers(data); }; - + fetchPresence(); - + // Update presence every 30 seconds const interval = setInterval(updatePresence, 30000); - + // Set status to offline on unmount return () => { clearInterval(interval); supabase.removeChannel(channel); supabase - .from('UserPresence') - .update({ status: 'offline', last_seen: new Date().toISOString() }) - .eq('user_id', user.id); + .from("UserPresence") + .update({ status: "offline", last_seen: new Date().toISOString() }) + .eq("user_id", user.id); }; }, [user]); - + return onlineUsers; }; @@ -271,61 +281,61 @@ export const useUserPresence = () => { export const useTypingIndicator = (conversationId: number) => { const { user } = useAuth(); const [typingUsers, setTypingUsers] = useState([]); - + const startTyping = async () => { if (!user || !conversationId) return; - - await supabase - .from('TypingIndicators') - .upsert({ - conversation_id: conversationId, - user_id: user.id, - }); + + await supabase.from("TypingIndicators").upsert({ + conversation_id: conversationId, + user_id: user.id, + }); }; - + const stopTyping = async () => { if (!user || !conversationId) return; - + await supabase - .from('TypingIndicators') + .from("TypingIndicators") .delete() - .eq('conversation_id', conversationId) - .eq('user_id', user.id); + .eq("conversation_id", conversationId) + .eq("user_id", user.id); }; - + useEffect(() => { if (!conversationId) return; - + const channel = supabase .channel(`typing:${conversationId}`) .on( - 'postgres_changes', + "postgres_changes", { - event: '*', - schema: 'public', - table: 'TypingIndicators', + event: "*", + schema: "public", + table: "TypingIndicators", filter: `conversation_id=eq.${conversationId}`, }, async () => { const { data } = await supabase - .from('TypingIndicators') - .select(` + .from("TypingIndicators") + .select( + ` *, user:auth.users(id, email, user_metadata) - `) - .eq('conversation_id', conversationId) - .neq('user_id', user?.id || ''); - + ` + ) + .eq("conversation_id", conversationId) + .neq("user_id", user?.id || ""); + if (data) setTypingUsers(data); } ) .subscribe(); - + return () => { supabase.removeChannel(channel); }; }, [conversationId, user]); - + return { typingUsers, startTyping, stopTyping }; }; @@ -333,44 +343,54 @@ export const useTypingIndicator = (conversationId: number) => { export const useMessageReactions = () => { const queryClient = useQueryClient(); const { user } = useAuth(); - + const addReaction = useMutation({ - mutationFn: async ({ messageId, emoji }: { messageId: number; emoji: string }) => { - if (!user) throw new Error('User not authenticated'); - - const { error } = await supabase - .from('MessageReactions') - .upsert({ - message_id: messageId, - user_id: user.id, - emoji, - }); - + mutationFn: async ({ + messageId, + emoji, + }: { + messageId: number; + emoji: string; + }) => { + if (!user) throw new Error("User not authenticated"); + + const { error } = await supabase.from("MessageReactions").upsert({ + message_id: messageId, + user_id: user.id, + emoji, + }); + if (error) throw error; }, onSuccess: () => { // Get conversation_id from the message to invalidate the right query - queryClient.invalidateQueries({ queryKey: ['messages'] }); + queryClient.invalidateQueries({ queryKey: ["messages"] }); }, }); - + const removeReaction = useMutation({ - mutationFn: async ({ messageId, emoji }: { messageId: number; emoji: string }) => { - if (!user) throw new Error('User not authenticated'); - + mutationFn: async ({ + messageId, + emoji, + }: { + messageId: number; + emoji: string; + }) => { + if (!user) throw new Error("User not authenticated"); + const { error } = await supabase - .from('MessageReactions') + .from("MessageReactions") .delete() - .eq('message_id', messageId) - .eq('user_id', user.id) - .eq('emoji', emoji); - + .eq("message_id", messageId) + .eq("user_id", user.id) + .eq("emoji", emoji); + if (error) throw error; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['messages'] }); + queryClient.invalidateQueries({ queryKey: ["messages"] }); }, }); - + return { addReaction, removeReaction }; -}; \ No newline at end of file +}; diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index 8e73dab3..9338663e 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -1,34 +1,33 @@ -import { useParams } from 'react-router-dom'; -import { useEvent } from '../hooks/useEvents'; -import { useAuth } from '../hooks/useAuth'; -import { useEventAttendance } from '../hooks/useEvents'; +import { useParams } from "react-router-dom"; +import { useEvent } from "../hooks/useEvents"; +import { useAuth } from "../hooks/useAuth"; +import { useEventAttendance } from "../hooks/useEvents"; import { showSuccess, showError } from "../utils/toast"; -import { - Calendar, - MapPin, - Users, - User, - Clock, - Tag, - Share2, - Heart, - CheckCircle, +import { + Calendar, + MapPin, + Users, + User, + Clock, + Share2, + CheckCircle, XCircle, Monitor, - Building, - Gauge -} from 'lucide-react'; -import { format } from 'date-fns'; -import { useState } from 'react'; + +} from "lucide-react"; +import { format } from "date-fns"; +import { useState } from "react"; export default function EventDetailPage() { const { id } = useParams<{ id: string }>(); const eventId = id ? parseInt(id) : 0; - + const { data: event, isLoading, error } = useEvent(eventId); const { user } = useAuth(); const { register, isRegistering } = useEventAttendance(); - const [attendanceStatus, setAttendanceStatus] = useState<'attending' | 'maybe' | 'not_attending'>('attending'); + const [attendanceStatus, setAttendanceStatus] = useState< + "attending" | "maybe" | "not_attending" + >("attending"); if (isLoading) { return ( @@ -45,10 +44,14 @@ export default function EventDetailPage() { return (
-

Event Not Found

-

The event you're looking for doesn't exist or has been removed.

- + Event Not Found + +

+ The event you're looking for doesn't exist or has been removed. +

+
Browse Events @@ -58,21 +61,26 @@ export default function EventDetailPage() { ); } - const handleAttendance = (status: 'attending' | 'maybe' | 'not_attending') => { + const handleAttendance = ( + status: "attending" | "maybe" | "not_attending" + ) => { if (!user) { - showError('Please sign in to register for events'); + showError("Please sign in to register for events"); return; } - + setAttendanceStatus(status); - register({ eventId, status }, { - onSuccess: () => { - showSuccess(`You are now ${status} this event`); - }, - onError: (error: any) => { - showError(error.message || 'Failed to register for event'); + register( + { eventId, status }, + { + onSuccess: () => { + showSuccess(`You are now ${status} this event`); + }, + onError: (error) => { + showError(error.message || "Failed to register for event"); + }, } - }); + ); }; const handleShareEvent = () => { @@ -85,17 +93,19 @@ export default function EventDetailPage() { }; const formatDate = (dateString: string) => { - return format(new Date(dateString), 'EEEE, MMMM d, yyyy'); + return format(new Date(dateString), "EEEE, MMMM d, yyyy"); }; const formatTime = (dateString: string) => { - return format(new Date(dateString), 'h:mm a'); + return format(new Date(dateString), "h:mm a"); }; const eventDate = new Date(event.event_date); const isPastEvent = eventDate < new Date(); - const isFull = event.max_attendees ? (event.attendee_count || 0) >= event.max_attendees : false; - + const isFull = event.max_attendees + ? (event.attendee_count || 0) >= event.max_attendees + : false; + const currentUserAttendance = event.user_attendance?.status || null; return ( @@ -117,7 +127,7 @@ export default function EventDetailPage() {
)}
- + {/* Floating badge for past events */} {isPastEvent && (
@@ -126,7 +136,7 @@ export default function EventDetailPage() { )}
-
+
{/* Event Header */} @@ -144,14 +154,24 @@ export default function EventDetailPage() { )}
- +
-
- +
{event.tags.map((tag, index) => ( - @@ -172,19 +192,22 @@ export default function EventDetailPage() { ))}
- + {/* Event Stats */}
-

EVENT STATS

- +

+ EVENT STATS +

+
Attendees - {event.attendee_count || 0}{event.max_attendees ? `/${event.max_attendees}` : ''} + {event.attendee_count || 0} + {event.max_attendees ? `/${event.max_attendees}` : ""}
- +
Status @@ -195,17 +218,31 @@ export default function EventDetailPage() { )}
- + {event.max_attendees && (
Capacity - {Math.round(((event.attendee_count || 0) / event.max_attendees) * 100)}% + + {Math.round( + ((event.attendee_count || 0) / + event.max_attendees) * + 100 + )} + % +
-
@@ -213,7 +250,7 @@ export default function EventDetailPage() {
- +
{/* Main Content */} - + {/* Description */}
-

About This Event

+

+ About This Event +

{event.description}

- + {/* Attendees */}

Attendees ({event.attendee_count || 0})

- +
- {event.EventAttendees.slice(0, 10).map((attendee, index) => ( -
-
- {index + 1} + {event.EventAttendees.slice(0, 10).map( + (attendee, index) => ( +
+
+ {index + 1} +
+ User {index + 1}
- User {index + 1} -
- ))} - {(event.EventAttendees.length > 10) && ( + ) + )} + {event.EventAttendees.length > 10 && (
- +{event.EventAttendees.length - 10} more + + +{event.EventAttendees.length - 10} more +
)} {event.EventAttendees.length === 0 && ( -

No attendees yet. Be the first to join!

+

+ No attendees yet. Be the first to join! +

)}
- + {/* Sidebar */}
{/* Registration */}
-

Register

- +

+ Register +

+ {!isPastEvent && ( <> {currentUserAttendance ? (
-

You are registered as:

+

+ You are registered as: +

{currentUserAttendance}

) : (
-

How are you attending?

+

+ How are you attending? +

)} - + {isFull && (
-

This event is full

+

+ This event is full +

)} - +
- + {/* Event Info Card */}
-

Event Info

- +

+ Event Info +

+
Created - {format(new Date(event.created_at), 'MMM d, yyyy')} + + {format(new Date(event.created_at), "MMM d, yyyy")} +
Last Updated - {format(new Date(event.updated_at), 'MMM d, yyyy')} + + {format(new Date(event.updated_at), "MMM d, yyyy")} +
Type - {event.is_virtual ? 'Virtual' : 'In-person'} + + {event.is_virtual ? "Virtual" : "In-person"} +
@@ -428,4 +516,4 @@ export default function EventDetailPage() {
); -} \ No newline at end of file +} From 70a3b943c27fdc5c841173066b1a8e3dc9237861 Mon Sep 17 00:00:00 2001 From: omkar hole Date: Mon, 19 Jan 2026 20:48:52 +0530 Subject: [PATCH 2/2] added documentation --- README.md | 23 ++- docs/EVENT_API.md | 90 ++++++++++- docs/EVENT_INTEGRATION.md | 63 +++++++- docs/EVENT_UI.md | 328 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 497 insertions(+), 7 deletions(-) create mode 100644 docs/EVENT_UI.md diff --git a/README.md b/README.md index bae91323..54617cfe 100644 --- a/README.md +++ b/README.md @@ -71,14 +71,27 @@ DevConnect is a full-stack web application that enables developers to: - 📁 **File Sharing** - Share images and files in conversations - 🔔 **Live Notifications** - Real-time typing indicators and message notifications - 👤 **User Presence** - See who's online and their status -- 📅 **Event Management** - Create, manage, and attend developer events and meetups -- 🎟️ **Event Registration** - RSVP system with attendance tracking -- 🌐 **Virtual Events** - Support for online events with meeting links -- 📸 **Event Detail Pages** - Beautiful, comprehensive event pages with banner images and rich details -- 📊 **Real-time Event Stats** - Live updating event statistics and attendee information - 🎨 **Modern UI** - Dark theme with cyan accents, professional design - 📱 **Responsive Design** - Works on desktop and mobile +### Event Management Features +- 📅 **Event Creation** - Create virtual or in-person events with detailed information +- 🎟️ **Three-Tier Attendance System**: + - ✅ **Going** - Confirmed attendance + - 🤔 **Maybe** - Tentative interest + - ❌ **Not Attending** - Declined participation +- 👥 **Capacity Management** - Set maximum attendees with real-time capacity tracking +- 📊 **Visual Capacity Indicators** - Progress bars and percentage displays +- 🚫 **Automatic Full-Event Handling** - Prevents over-registration +- 📸 **Rich Event Detail Pages** - Comprehensive event pages with banner images and stats +- 🌐 **Virtual Event Support** - Online events with meeting links (Zoom, Teams, etc.) +- 📍 **Physical Event Support** - In-person events with location details +- 🔔 **Event Notifications** - Toast notifications for registration success/errors +- 📤 **Share Events** - Native share API integration with clipboard fallback +- 🎯 **Smart Registration** - Authentication checks and capacity validation +- 📅 **Past Event Handling** - Automatic disabling of registration for ended events +- 👤 **Attendee Lists** - View all registered attendees with status indicators + ## 📁 Project Structure ``` diff --git a/docs/EVENT_API.md b/docs/EVENT_API.md index 064aaab4..48513ae3 100644 --- a/docs/EVENT_API.md +++ b/docs/EVENT_API.md @@ -61,4 +61,92 @@ const { data, error } = await supabase .gte('event_date', startDate) .contains('tags', [tag]) .or(`title.ilike.%${query}%,description.ilike.%${query}%`); -``` \ No newline at end of file +```## Event Attendance Management + +### Register/Update Attendance Status +```typescript +// Register or update attendance with specific status +const { data, error } = await supabase + .from('EventAttendees') + .upsert({ + event_id: eventId, + user_id: user.id, + status: 'attending' // 'attending' | 'maybe' | 'not_attending' + }, { + onConflict: 'event_id,user_id' + }) + .select() + .single(); +``` + +### Get User's Current Attendance Status +```typescript +const { data, error } = await supabase + .from('EventAttendees') + .select('status') + .eq('event_id', eventId) + .eq('user_id', user.id) + .maybeSingle(); + +// Returns: { status: 'attending' | 'maybe' | 'not_attending' } or null +``` + +### Check Event Capacity Before Registration +```typescript +// Validate if event is full before allowing registration +const { data: event, error } = await supabase + .from('Events') + .select('max_attendees, EventAttendees(count)') + .eq('id', eventId) + .single(); + +const attendeeCount = event.EventAttendees?.[0]?.count || 0; +const isFull = event.max_attendees && attendeeCount >= event.max_attendees; + +if (isFull) { + throw new Error('Event is at full capacity'); +} +``` + +### Get Event with Attendance Details +```typescript +// Fetch event with all attendees and user's status +const { data, error } = await supabase + .from('Events') + .select( + *, + Communities(name), + EventAttendees(id, user_id, status, registered_at) + ) + .eq('id', eventId) + .single(); + +// Calculate attendee count +const attendeeCount = data.EventAttendees.length; + +// Find current user's attendance +const userAttendance = data.EventAttendees.find( + a => a.user_id === currentUserId +); +``` + +### Cancel Event Registration +```typescript +// Remove user from event attendees +const { error } = await supabase + .from('EventAttendees') + .delete() + .eq('event_id', eventId) + .eq('user_id', user.id); +``` + +### Get Attendees by Status +```typescript +// Get all confirmed attendees +const { data, error } = await supabase + .from('EventAttendees') + .select('*, user:auth.users(id, email, user_metadata)') + .eq('event_id', eventId) + .eq('status', 'attending') + .order('registered_at', { ascending: true }); +``` diff --git a/docs/EVENT_INTEGRATION.md b/docs/EVENT_INTEGRATION.md index 36687063..bebe51b1 100644 --- a/docs/EVENT_INTEGRATION.md +++ b/docs/EVENT_INTEGRATION.md @@ -101,4 +101,65 @@ export const CreateEventPage = () => {
); }; -``` \ No newline at end of file +``` +## Event Attendance Hooks + +### useEventAttendance Hook +Manages event registration and attendance status updates with optimistic UI updates and proper error handling. + +```typescript +export const useEventAttendance = () => { + const { user } = useAuth(); + const queryClient = useQueryClient(); + + const register = useMutation({ + mutationFn: async ({ + eventId, + status + }: { + eventId: number; + status: 'attending' | 'maybe' | 'not_attending' + }) => { + if (!user) throw new Error('User not authenticated'); + + const { data, error } = await supabase + .from('EventAttendees') + .upsert({ + event_id: eventId, + user_id: user.id, + status + }, { + onConflict: 'event_id,user_id' + }) + .select() + .single(); + + if (error) throw error; + return data; + }, + onSuccess: (_, { eventId }) => { + queryClient.invalidateQueries({ queryKey: ['event', eventId] }); + queryClient.invalidateQueries({ queryKey: ['events'] }); + } + }); + + return { register, isRegistering: register.isPending }; +}; +``` + +### Usage in EventDetailPage +```typescript +const { register, isRegistering } = useEventAttendance(); + +const handleAttendance = (status: 'attending' | 'maybe' | 'not_attending') => { + if (!user) { + showError('Please sign in to register for events'); + return; + } + + register({ eventId, status }, { + onSuccess: () => showSuccess('You are now ' + status + ' this event'), + onError: (error) => showError(error.message || 'Failed to register') + }); +}; +``` diff --git a/docs/EVENT_UI.md b/docs/EVENT_UI.md new file mode 100644 index 00000000..4b2630a6 --- /dev/null +++ b/docs/EVENT_UI.md @@ -0,0 +1,328 @@ +# Event Detail Page UI Features + +## Overview +The Event Detail Page provides a comprehensive, interactive interface for viewing event information, managing registration, and tracking attendance. Built with a modern dark theme and responsive design. + +## Key Features + +### 1. Event Information Display + +#### Hero Section +- **Full-width banner** with event image or gradient background +- **Gradient overlay** for better text readability (slate-950 with transparency) +- **Floating badge** for past events (red, top-right corner) +- **Responsive height**: 400px on desktop, adapts on mobile + +#### Event Header +- **Large title** with prominent typography +- **Back navigation button** with arrow icon +- **Tag system** displaying event categories with slate-800 background +- **Breadcrumb context** for easy navigation + +#### Event Stats Card +- **Real-time metrics**: + - Current attendee count vs. maximum capacity + - Event status (Available/Full) + - Capacity percentage +- **Visual progress bar** showing fill percentage +- **Color-coded indicators**: + - Cyan for active/available + - Red for full/unavailable + +### 2. Attendance Registration System + +#### Three-State Registration +Users can indicate their attendance with three distinct options: + +| Status | Description | Icon | Color | +|--------|-------------|------|-------| +| **Going** | Confirmed attendance | ✓ CheckCircle | Cyan (#06b6d4) | +| **Maybe** | Tentative interest | 👤 User | Amber (#f59e0b) | +| **Not Attending** | Declined | ✗ XCircle | Red (#ef4444) | + +#### Registration Flow +```typescript +// Validation checks performed: +1. User authentication status +2. Event capacity (if max_attendees is set) +3. Event date (past events disabled) +4. Current registration status + +// User feedback mechanisms: +- Success toast: "You are now [status] this event" +- Error toast: Authentication or capacity errors +- Visual status indicator in registration card +- Button state changes (active/disabled) +``` + +#### Registration UI States + +**Not Registered (Default)** +``` +┌─────────────────────────────┐ +│ How are you attending? │ +├─────────┬─────────┬─────────┤ +│ Going │ Maybe │ Not │ +│ ✓ │ 👤 │ ✗ │ +└─────────┴─────────┴─────────┘ +``` + +**Already Registered** +``` +┌─────────────────────────────┐ +│ You are registered as: │ +│ ✓ ATTENDING │ +└─────────────────────────────┘ +``` + +**Event Full** +``` +┌─────────────────────────────┐ +│ ⚠ This event is full │ +│ [Disabled buttons] │ +└─────────────────────────────┘ +``` + +### 3. Capacity Management + +#### Visual Capacity Indicator +```typescript +// Capacity calculation +const attendeeCount = event.attendee_count || 0; +const maxAttendees = event.max_attendees; +const capacityPercent = (attendeeCount / maxAttendees) * 100; +const isFull = maxAttendees && attendeeCount >= maxAttendees; +``` + +**Progress Bar Design**: +- Background: slate-700 +- Fill: cyan-500 (bg-cyan-500) +- Height: 8px (h-2) +- Rounded corners (rounded-full) +- Percentage displayed above bar + +#### Full Event Handling +When event reaches capacity: +1. **Registration buttons disabled** for "Going" option +2. **Warning message** displayed: "This event is full" +3. **Red background** alert box (bg-red-900/20) +4. **"Maybe" and "Not Attending"** remain available +5. **Share functionality** stays active + +### 4. Event Details Grid + +#### Main Content (2/3 width on desktop) + +**Event Details Card** +- Date & Time with Calendar icon +- Duration with Clock icon +- Location with MapPin icon (physical events) +- Meeting Link with Monitor icon (virtual events) +- Organizer info with User icon + +**About Section** +- Full event description +- Preserved line breaks (whitespace-pre-line) +- Prose styling for rich content + +**Attendees Section** +- First 10 attendees displayed with numbered avatars +- "+X more" indicator for additional attendees +- Empty state: "No attendees yet. Be the first to join!" +- Cyan circular avatars with index numbers + +#### Sidebar (1/3 width on desktop) + +**Registration Card** +- Attendance options (three buttons) +- Current status display +- Share event button +- Conditional rendering based on event state + +**Event Info Card** +- Created date +- Last updated date +- Event type (Virtual/In-person) +- Formatted dates (MMM d, yyyy) + +### 5. Error Handling & Edge Cases + +#### Event Not Found +``` +┌─────────────────────────────────────┐ +│ │ +│ 🚫 Event Not Found │ +│ │ +│ The event you're looking for │ +│ doesn't exist or has been removed. │ +│ │ +│ [Browse Events Button] │ +│ │ +└─────────────────────────────────────┘ +``` + +**Features**: +- Full-screen centered layout +- Clear error messaging +- Call-to-action button to browse events +- Red accent color for error state + +#### Loading State +``` +┌─────────────────────────────────────┐ +│ │ +│ ⏳ Loading... │ +│ │ +└─────────────────────────────────────┘ +``` + +#### Authentication Required +```typescript +// Toast notification triggered on registration attempt +if (!user) { + showError('Please sign in to register for events'); + return; +} +``` + +#### Past Event State +- **"PAST EVENT" badge** on hero image +- **All registration disabled** +- **Message**: "This event has ended" +- **Share button** still available + +### 6. Responsive Design + +#### Desktop (lg: 1024px+) +- Two-column layout (2/3 + 1/3) +- Side-by-side event stats +- Full-width hero banner + +#### Tablet (md: 768px - 1023px) +- Stacked layout +- Full-width cards +- Condensed event details grid + +#### Mobile (< 768px) +- Single column layout +- Vertical button groups +- Optimized touch targets (min 44px) +- Reduced padding and margins + +### 7. Interactive Elements + +#### Share Event Button +```typescript +const handleShareEvent = () => { + const url = window.location.href; + if (navigator.share) { + navigator.share({ + title: event.title, + text: event.description, + url: url + }); + } else { + navigator.clipboard.writeText(url); + showSuccess('Link copied to clipboard!'); + } +}; +``` + +**Features**: +- Native share API support +- Fallback to clipboard copy +- Toast notification on success +- Share2 icon from Lucide + +### 8. Date & Time Formatting + +```typescript +// Display formats using date-fns +const formatDate = (dateString: string) => { + return format(new Date(dateString), 'EEEE, MMMM d, yyyy'); + // Example: "Monday, December 25, 2023" +}; + +const formatTime = (dateString: string) => { + return format(new Date(dateString), 'h:mm a'); + // Example: "3:30 PM" +}; +``` + +### 9. Color System + +| Element | Color | Hex | +|---------|-------|-----| +| Primary Action | Cyan | #06b6d4 | +| Success (Attending) | Cyan | #06b6d4 | +| Warning (Maybe) | Amber | #f59e0b | +| Danger (Not Attending) | Red | #ef4444 | +| Background Primary | Slate-950 | #020617 | +| Background Card | Slate-900 | #0f172a | +| Border | Slate-800 | #1e293b | +| Text Primary | White | #ffffff | +| Text Secondary | Slate-300 | #cbd5e1 | +| Text Muted | Slate-400 | #94a3b8 | + +### 10. Component Structure + +``` +EventDetailPage +├── Loading State +├── Error State (Event Not Found) +└── Main Layout + ├── Hero Section + │ ├── Image/Gradient Background + │ ├── Gradient Overlay + │ └── Past Event Badge (conditional) + ├── Container (max-w-6xl) + │ └── Card (bg-slate-900/80) + │ ├── Header Section + │ │ ├── Event Stats (right float) + │ │ └── Event Info (left) + │ │ ├── Back Button + │ │ ├── Title + │ │ └── Tags + │ └── Content Grid (lg:grid-cols-3) + │ ├── Main Content (lg:col-span-2) + │ │ ├── Event Details Card + │ │ ├── Description Section + │ │ └── Attendees Section + │ └── Sidebar + │ ├── Registration Card + │ └── Event Info Card +``` + +### 11. Accessibility Features + +- **Semantic HTML**: Proper heading hierarchy (h1, h2, h3) +- **ARIA labels**: Button actions clearly labeled +- **Keyboard navigation**: All interactive elements accessible via keyboard +- **Color contrast**: WCAG AA compliant contrast ratios +- **Focus indicators**: Visible focus states on interactive elements +- **Screen reader friendly**: Descriptive text for icons and buttons + +### 12. Performance Optimizations + +- **Lazy loading**: Event data fetched on demand +- **Query caching**: TanStack Query caches event details +- **Optimistic updates**: UI updates immediately on registration +- **Image optimization**: Responsive images with proper sizing +- **Code splitting**: Page-level code splitting with React Router + +## Implementation Checklist + +✅ Hero section with image/gradient +✅ Three-state attendance system (Going/Maybe/Not Attending) +✅ Capacity tracking with visual progress bar +✅ Real-time attendee count updates +✅ Authentication guards +✅ Past event handling +✅ Event not found error state +✅ Share functionality +✅ Responsive design (mobile/tablet/desktop) +✅ Toast notifications for user feedback +✅ Loading and error states +✅ Attendee list with avatars +✅ Date/time formatting +✅ Virtual vs. in-person event handling