- About Attendify
+
Attendify is a modern attendance tracking platform designed specifically for school clubs
and organizations. We make it easy to manage attendance, so you can focus on what matters most
diff --git a/src/pages/AttendEvent.tsx b/src/pages/AttendEvent.tsx
new file mode 100644
index 0000000..6922daf
--- /dev/null
+++ b/src/pages/AttendEvent.tsx
@@ -0,0 +1,766 @@
+import { useState, useEffect } from 'react';
+import { supabase } from '../utils/supabaseClient';
+import { motion, AnimatePresence } from 'framer-motion';
+import { parseLocalDate } from '../lib/utils';
+import { getCloseMatches } from '../utils/nameMatcher';
+
+interface Event {
+ id: string;
+ name: string;
+ event_date: string;
+ invite_code: string;
+ club_id: string;
+ club_name?: string;
+ checkin_location_enabled?: boolean;
+ checkin_qr_enabled?: boolean;
+ checkin_code_enabled?: boolean;
+ checkin_code?: string | null;
+ location_lat?: number | null;
+ location_lng?: number | null;
+ location_radius_meters?: number | null;
+ recurrence?: string;
+ recurrence_until?: string | null;
+ event_start_time?: string | null;
+ event_end_time?: string | null;
+ checkin_only_during_event?: boolean;
+}
+
+interface ClubMember {
+ name: string;
+ id: string;
+ member_uuid: string | null;
+}
+
+const AttendEvent: React.FC = () => {
+ const [view, setView] = useState<'code' | 'events'>('events'); // Default to events view
+ const [step, setStep] = useState(1);
+ const [inviteCode, setInviteCode] = useState('');
+ const [memberName, setMemberName] = useState('');
+ const [memberUuid, setMemberUuid] = useState(null);
+ const [savedClubs, setSavedClubs] = useState([]);
+ const [availableEvents, setAvailableEvents] = useState([]);
+ const [clubMembers, setClubMembers] = useState([]);
+ const [nameMatches, setNameMatches] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const [eventDetails, setEventDetails] = useState(null);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ // Load member data from localStorage and fetch available events
+ useEffect(() => {
+ const fetchInitialData = async () => {
+ setIsLoading(true);
+
+ // Get member data from localStorage
+ const storedMemberId = localStorage.getItem('attendify_member_id');
+ if (storedMemberId) {
+ setMemberUuid(storedMemberId);
+
+ // Get saved clubs
+ const storedClubs = JSON.parse(localStorage.getItem('attendify_clubs') || '[]');
+ if (storedClubs.length > 0) {
+ setSavedClubs(storedClubs);
+ setMemberName(storedClubs[0]?.member_name || '');
+ }
+ }
+
+ // Fetch available events (limited to upcoming events in next 7 days)
+ try {
+ const today = new Date();
+ const nextWeek = new Date();
+ nextWeek.setDate(today.getDate() + 7);
+
+ // First, get the events
+ const { data: eventsData, error: eventsError } = await supabase
+ .from('events')
+ .select(`
+ id,
+ name,
+ event_date,
+ invite_code,
+ club_id,
+ checkin_location_enabled,
+ checkin_qr_enabled,
+ checkin_code_enabled,
+ checkin_code,
+ location_lat,
+ location_lng,
+ location_radius_meters,
+ recurrence,
+ recurrence_until,
+ event_start_time,
+ event_end_time,
+ checkin_only_during_event
+ `)
+ .gte('event_date', today.toISOString())
+ .lte('event_date', nextWeek.toISOString())
+ .order('event_date', { ascending: true });
+
+ if (eventsError) {
+ console.error('Error fetching events:', eventsError);
+ setIsLoading(false);
+ return;
+ }
+
+ if (!eventsData || eventsData.length === 0) {
+ setAvailableEvents([]);
+ setIsLoading(false);
+ return;
+ }
+
+ // For each event, get its club details
+ const enhancedEvents: Event[] = [];
+
+ for (const event of eventsData) {
+ const { data: clubData } = await supabase
+ .from('clubs')
+ .select('name')
+ .eq('id', event.club_id)
+ .single();
+
+ enhancedEvents.push({
+ id: event.id,
+ name: event.name,
+ event_date: event.event_date,
+ invite_code: event.invite_code,
+ club_id: event.club_id,
+ club_name: clubData?.name || 'Unknown Club',
+ checkin_location_enabled: event.checkin_location_enabled,
+ checkin_qr_enabled: event.checkin_qr_enabled,
+ checkin_code_enabled: event.checkin_code_enabled,
+ checkin_code: event.checkin_code,
+ location_lat: event.location_lat,
+ location_lng: event.location_lng,
+ location_radius_meters: event.location_radius_meters,
+ recurrence: event.recurrence,
+ recurrence_until: event.recurrence_until,
+ event_start_time: event.event_start_time,
+ event_end_time: event.event_end_time,
+ checkin_only_during_event: event.checkin_only_during_event
+ });
+ }
+
+ setAvailableEvents(enhancedEvents);
+ } catch (error) {
+ console.error('Error in data fetching:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchInitialData();
+ }, []);
+
+ const verifyInviteCode = async () => {
+ setError(null);
+ setLoading(true);
+
+ try {
+ // Find event by invite code
+ const { data: eventData, error: eventError } = await supabase
+ .from('events')
+ .select(`
+ id,
+ name,
+ event_date,
+ invite_code,
+ club_id,
+ checkin_location_enabled,
+ checkin_qr_enabled,
+ checkin_code_enabled,
+ checkin_code,
+ location_lat,
+ location_lng,
+ location_radius_meters,
+ recurrence,
+ recurrence_until,
+ event_start_time,
+ event_end_time,
+ checkin_only_during_event
+ `)
+ .eq('invite_code', inviteCode)
+ .single();
+
+ if (eventError || !eventData) {
+ setError('Invalid event code. Please check and try again.');
+ setLoading(false);
+ return;
+ }
+
+ // Get the club name
+ const { data: clubData } = await supabase
+ .from('clubs')
+ .select('name')
+ .eq('id', eventData.club_id)
+ .single();
+
+ // Now fetch preapproved members for this club for autocomplete
+ await fetchClubMembers(eventData.club_id);
+
+ // Create the event object
+ const event: Event = {
+ id: eventData.id,
+ name: eventData.name,
+ event_date: eventData.event_date,
+ invite_code: eventData.invite_code,
+ club_id: eventData.club_id,
+ club_name: clubData?.name || 'Unknown Club',
+ checkin_location_enabled: eventData.checkin_location_enabled,
+ checkin_qr_enabled: eventData.checkin_qr_enabled,
+ checkin_code_enabled: eventData.checkin_code_enabled,
+ checkin_code: eventData.checkin_code,
+ location_lat: eventData.location_lat,
+ location_lng: eventData.location_lng,
+ location_radius_meters: eventData.location_radius_meters,
+ recurrence: eventData.recurrence,
+ recurrence_until: eventData.recurrence_until,
+ event_start_time: eventData.event_start_time,
+ event_end_time: eventData.event_end_time,
+ checkin_only_during_event: eventData.checkin_only_during_event
+ };
+
+ setEventDetails(event);
+ setLoading(false);
+ setStep(2);
+ } catch (error) {
+ console.error('Error verifying event code:', error);
+ setError('An error occurred. Please try again.');
+ setLoading(false);
+ }
+ };
+
+ const selectEvent = async (event: Event) => {
+ setError(null);
+ setLoading(true);
+
+ try {
+ // Fetch preapproved members for this club for autocomplete
+ await fetchClubMembers(event.club_id);
+
+ setEventDetails(event);
+ setLoading(false);
+ setStep(2);
+ } catch (error) {
+ console.error('Error selecting event:', error);
+ setError('An error occurred. Please try again.');
+ setLoading(false);
+ }
+ };
+
+ const fetchClubMembers = async (clubId: string) => {
+ try {
+ const { data: members, error: membersError } = await supabase
+ .from('members')
+ .select('id, name, member_uuid')
+ .eq('club_id', clubId);
+
+ if (membersError) {
+ console.error('Error fetching club members:', membersError);
+ } else {
+ setClubMembers(members || []);
+ }
+ } catch (error) {
+ console.error('Error fetching club members:', error);
+ }
+ };
+
+ const handleNameInput = (input: string) => {
+ setMemberName(input);
+
+ if (input.trim() !== '') {
+ const matches = getCloseMatches(
+ input,
+ clubMembers.map(member => member.name)
+ );
+ setNameMatches(matches);
+ } else {
+ setNameMatches([]);
+ }
+ };
+
+ const selectName = (name: string) => {
+ setMemberName(name);
+ setNameMatches([]);
+ };
+
+ const handleAttendance = async () => {
+ setError(null);
+ setLoading(true);
+
+ if (!eventDetails) {
+ setError('No event selected.');
+ setLoading(false);
+ return;
+ }
+
+ try {
+ // ENFORCE CHECK-IN RESTRICTIONS
+ // 1. Check-in method: QR/direct or code
+ if (eventDetails.checkin_qr_enabled === false && eventDetails.checkin_code_enabled === false) {
+ setError('Check-in is not enabled for this event.');
+ setLoading(false);
+ return;
+ }
+ // If code is required, prompt for code and check
+ if (eventDetails.checkin_code_enabled) {
+ if (!inviteCode || (eventDetails.checkin_code && inviteCode !== eventDetails.checkin_code)) {
+ setError('Invalid check-in code for this event.');
+ setLoading(false);
+ return;
+ }
+ }
+ // 2. Location restriction
+ if (eventDetails.checkin_location_enabled) {
+ if (!navigator.geolocation) {
+ setError('Location check-in is required, but your device does not support geolocation.');
+ setLoading(false);
+ return;
+ }
+ const getPosition = () => new Promise((resolve, reject) => {
+ navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true });
+ });
+ let position;
+ try {
+ position = await getPosition();
+ } catch (geoErr) {
+ setError('Location permission denied or unavailable.');
+ setLoading(false);
+ return;
+ }
+ const { latitude, longitude } = position.coords;
+ const toRad = (x: number) => x * Math.PI / 180;
+ const dist = (() => {
+ if (eventDetails.location_lat == null || eventDetails.location_lng == null || !eventDetails.location_radius_meters) return Infinity;
+ const R = 6371000; // meters
+ const dLat = toRad(latitude - eventDetails.location_lat);
+ const dLon = toRad(longitude - eventDetails.location_lng);
+ const lat1 = toRad(eventDetails.location_lat);
+ const lat2 = toRad(latitude);
+ const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.sin(dLon/2) * Math.sin(dLon/2) * Math.cos(lat1) * Math.cos(lat2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
+ return R * c;
+ })();
+ if (dist > (eventDetails.location_radius_meters || 0)) {
+ setError('You are not at the event location.');
+ setLoading(false);
+ return;
+ }
+ }
+ // 3. Time window restriction
+ if (eventDetails.checkin_only_during_event) {
+ if (eventDetails.event_start_time && eventDetails.event_end_time) {
+ const now = new Date();
+ const start = new Date(eventDetails.event_start_time);
+ const end = new Date(eventDetails.event_end_time);
+ if (now < start || now > end) {
+ setError('Check-in is only allowed during the event time window.');
+ setLoading(false);
+ return;
+ }
+ }
+ }
+
+ // First check if member exists for this club
+ let memberId;
+
+ if (memberUuid) {
+ // Try to find member by UUID
+ const { data: existingMember } = await supabase
+ .from('members')
+ .select('id')
+ .eq('club_id', eventDetails.club_id)
+ .eq('member_uuid', memberUuid)
+ .single();
+
+ if (existingMember) {
+ memberId = existingMember.id;
+ }
+ }
+
+ // If no member found, create a new one
+ if (!memberId) {
+ // Check if member with same name exists
+ const { data: nameMatch } = await supabase
+ .from('members')
+ .select('id, member_uuid')
+ .eq('club_id', eventDetails.club_id)
+ .eq('name', memberName)
+ .single();
+
+ if (nameMatch) {
+ // Use existing member
+ memberId = nameMatch.id;
+
+ // If this member had no UUID before, update it
+ if (!nameMatch.member_uuid && memberUuid) {
+ await supabase
+ .from('members')
+ .update({ member_uuid: memberUuid })
+ .eq('id', memberId);
+ }
+ // If we didn't have UUID but member has one, store it
+ else if (nameMatch.member_uuid && !memberUuid) {
+ localStorage.setItem('attendify_member_id', nameMatch.member_uuid);
+ setMemberUuid(nameMatch.member_uuid);
+ }
+ } else {
+ // Member not found by UUID or name
+ setError(`Member '${memberName}' not found for ${eventDetails.club_name || 'this club'}. Please join the club first.`);
+ setLoading(false);
+ return; // Stop the check-in process
+ }
+ }
+
+ // Record attendance
+ const { error: attendanceError } = await supabase
+ .from('attendance')
+ .insert([{
+ event_id: eventDetails.id,
+ member_id: memberId
+ }]);
+
+ if (attendanceError) {
+ // Check if it's a unique constraint error (already attended)
+ if (attendanceError.code === '23505') {
+ setSuccess('You have already checked in to this event!');
+ } else {
+ setError('Failed to record attendance. Please try again.');
+ }
+ setLoading(false);
+ return;
+ }
+
+ setSuccess('Attendance recorded successfully!');
+ setLoading(false);
+ setStep(3);
+ } catch (error) {
+ console.error('Error recording attendance:', error);
+ setError('An error occurred. Please try again.');
+ setLoading(false);
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ if (!dateString) return '';
+ const date = parseLocalDate(dateString);
+ return date.toLocaleDateString('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ };
+
+ const formatRelativeTime = (dateString: string) => {
+ if (!dateString) return '';
+
+ const date = parseLocalDate(dateString);
+ const now = new Date();
+ const diffMs = date.getTime() - now.getTime();
+ const diffHours = diffMs / (1000 * 60 * 60);
+
+ if (diffHours < 0) return 'Past';
+ if (diffHours < 1) return 'Soon';
+ if (diffHours < 24) return 'Today';
+ if (diffHours < 48) return 'Tomorrow';
+ return `In ${Math.floor(diffHours / 24)} days`;
+ };
+
+ return (
+
+
+
+ {step === 1 && (
+
+
+ Check in
+
+
+ Select an event or enter an event code
+
+
+ {savedClubs.length > 0 && (
+
+
Welcome back, {memberName}
+
+ {savedClubs.map((club, idx) => (
+
+ {club.name}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+ {view === 'code' ? (
+
+ setInviteCode(e.target.value)}
+ placeholder="Event code"
+ className="w-full px-4 py-3 text-base tracking-wider text-black border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-black focus:border-black bg-white"
+ disabled={loading}
+ />
+
+
+
+ ) : (
+
+ {isLoading ? (
+
+ ) : availableEvents.length > 0 ? (
+
+ {availableEvents.map(event => (
+
+ ))}
+
+ ) : (
+
+
No upcoming events available
+
Try entering an event code instead
+
+ )}
+
+ )}
+
+
+ {error && (
+
+ {error}
+ {/* Add link to join page if error is due to member not found */}
+ {error.includes('not found for') && (
+
+ Join a Club
+
+ )}
+
+ )}
+
+
+
+ )}
+
+ {step === 2 && (
+
+
+ Event details
+
+
+
+
{eventDetails?.name}
+
+ {eventDetails?.club_name}
+
+
+ {formatDate(eventDetails?.event_date || '')}
+
+
+
+ {!savedClubs.length && (
+
+
+
handleNameInput(e.target.value)}
+ placeholder="Your name"
+ className="w-full px-4 py-3 text-base text-black border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-black focus:border-black bg-white"
+ disabled={loading}
+ required
+ />
+
+ {/* Name autocomplete suggestions */}
+ {nameMatches.length > 0 && (
+
+ {nameMatches.map((name, index) => (
+ - selectName(name)}
+ className="px-4 py-2 hover:bg-gray-50 cursor-pointer text-sm"
+ >
+ {name}
+
+ ))}
+
+ )}
+
+ )}
+
+ {error && (
+
+ {error}
+ {/* Add link to join page if error is due to member not found */}
+ {error.includes('not found for') && (
+
+ Join a Club
+
+ )}
+
+ )}
+
+ {success && (
+
+ {success}
+
+ )}
+
+
+
+
+
+
+ )}
+
+ {step === 3 && (
+
+
+
+
+ Checked in
+
+
+ You've successfully checked in to {eventDetails?.name}
+
+
+
+
+
+ Go to Dashboard
+
+
+
+ )}
+
+
+
+
+ Go to Dashboard
+
+
+
+
+ );
+};
+
+export default AttendEvent;
\ No newline at end of file
diff --git a/src/pages/ClubDetail.tsx b/src/pages/ClubDetail.tsx
new file mode 100644
index 0000000..c7efb1d
--- /dev/null
+++ b/src/pages/ClubDetail.tsx
@@ -0,0 +1,964 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { useParams, useNavigate, Link } from 'react-router-dom';
+import Layout from '../components/Layout';
+import { motion, AnimatePresence } from 'framer-motion';
+import { useAuth } from '../contexts/AuthContext';
+import { supabase } from '../utils/supabaseClient';
+import { QRCodeCanvas } from 'qrcode.react';
+import CreateEventModal from '../components/CreateEventModal';
+import { parseLocalDate } from '../lib/utils';
+import { IonIcon } from '@ionic/react';
+import { calendarOutline, peopleOutline, statsChartOutline, personCircleOutline, trashOutline } from 'ionicons/icons';
+
+// Re-declare or import interfaces if needed
+interface Club {
+ id: string;
+ name: string;
+ description: string;
+ category: string;
+ access_code: string;
+ created_at: string;
+}
+
+// Reuse Event interface from ClubDetail or define it here
+interface Event {
+ id: string;
+ club_id: string;
+ name: string;
+ event_date: string; // Store as ISO string
+ invite_code: string;
+ created_at: string;
+ // Add fields for badges and editing
+ checkin_location_enabled?: boolean;
+ checkin_code_enabled?: boolean;
+ checkin_qr_enabled?: boolean; // Derived field
+ checkin_only_during_event?: boolean;
+ location_lat?: number | null;
+ location_lng?: number | null;
+ location_radius_meters?: number | null;
+ recurrence?: string;
+ recurrence_until?: string | null; // Store as ISO string or null
+ event_start_time?: string | null; // Store as ISO string or null
+ event_end_time?: string | null; // Store as ISO string or null
+}
+
+interface Member {
+ id: string;
+ club_id: string;
+ name: string;
+ preapproved: boolean;
+ created_at: string;
+}
+
+// Shared transition for tab content (blur lingers longer than fade)
+const TAB_TRANSITION = {
+ opacity: { duration: 0.16, ease: [0.4, 0, 0.2, 1] },
+ filter: { duration: 0.28, ease: [0.4, 0, 0.2, 1] }
+};
+
+// Animation variants for tab content
+const tabVariants = {
+ hidden: {
+ opacity: 0,
+ filter: 'blur(16px)',
+ scale: 0.97,
+ y: -20
+ },
+ visible: {
+ opacity: 1,
+ filter: 'blur(0px)',
+ scale: 1,
+ y: 0,
+ transition: {
+ ...TAB_TRANSITION,
+ type: 'spring',
+ damping: 25,
+ stiffness: 300
+ }
+ },
+ exit: {
+ opacity: 0,
+ filter: 'blur(16px)',
+ scale: 0.97,
+ y: -20,
+ transition: {
+ ...TAB_TRANSITION,
+ duration: 0.2
+ }
+ }
+};
+
+const ClubDetail: React.FC = () => {
+ const { clubId } = useParams<{ clubId: string }>();
+ const { user, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+
+ // State previously in Clubs.tsx modal + loading/error for club details
+ const [club, setClub] = useState(null);
+ const [loadingClub, setLoadingClub] = useState(true);
+ const [errorClub, setErrorClub] = useState(null);
+
+ const [events, setEvents] = useState([]);
+ const [currentTab, setCurrentTab] = useState<'events' | 'members' | 'attendance'>('events');
+
+ const [members, setMembers] = useState([]);
+ const [memberName, setMemberName] = useState('');
+ const [memberError, setMemberError] = useState(null);
+ const [memberLoading, setMemberLoading] = useState(false);
+ const [deleteAllMembersLoading, setDeleteAllMembersLoading] = useState(false); // State for delete all action
+
+ const [attendanceTab, setAttendanceTab] = useState<'byEvent' | 'byMember'>('byEvent');
+ const [attendanceData, setAttendanceData] = useState([]);
+ const [attendanceLoading, setAttendanceLoading] = useState(false);
+ const csvRef = useRef(null);
+
+ // State to control the create event modal
+ const [isCreateEventModalOpen, setIsCreateEventModalOpen] = useState(false);
+ const [createEventError, setCreateEventError] = useState(null); // Error specifically for the creation process
+ const [eventToEdit, setEventToEdit] = useState(null); // State to hold the event being edited
+
+ // Check user auth
+ useEffect(() => {
+ if (!authLoading && !user) {
+ navigate('/login');
+ }
+ }, [user, authLoading, navigate]);
+
+ // Fetch club details
+ useEffect(() => {
+ const fetchClubDetails = async () => {
+ if (!clubId || !user) return;
+ setLoadingClub(true);
+ setErrorClub(null);
+
+ // Verify user owns this club first
+ const { data: ownerCheck, error: ownerError } = await supabase
+ .from('club_owners')
+ .select('club_id')
+ .eq('user_id', user.id)
+ .eq('club_id', clubId)
+ .maybeSingle(); // Use maybeSingle to handle no rows
+
+ if (ownerError) {
+ console.error('Error checking club ownership:', ownerError);
+ setErrorClub('Error verifying club ownership.');
+ setLoadingClub(false);
+ return;
+ }
+
+ if (!ownerCheck) {
+ setErrorClub('You do not have permission to view this club.');
+ setClub(null);
+ setLoadingClub(false);
+ // Optionally navigate away: navigate('/clubs');
+ return;
+ }
+
+ // Fetch club details
+ const { data, error } = await supabase
+ .from('clubs')
+ .select('*')
+ .eq('id', clubId)
+ .single();
+
+ if (error || !data) {
+ setErrorClub('Failed to load club details.');
+ setClub(null);
+ } else {
+ setClub(data);
+ }
+ setLoadingClub(false);
+ };
+ fetchClubDetails();
+ }, [clubId, user]);
+
+ // Fetch events for the club
+ useEffect(() => {
+ const fetchEvents = async () => {
+ if (!clubId) return;
+ setCreateEventError(null);
+ const { data, error } = await supabase
+ .from('events')
+ // Select ALL fields needed for display and editing
+ .select(`
+ id, club_id, name, event_date, invite_code, created_at,
+ checkin_location_enabled, checkin_code_enabled, checkin_only_during_event,
+ location_lat, location_lng, location_radius_meters,
+ recurrence, recurrence_until, event_start_time, event_end_time
+ `)
+ .eq('club_id', clubId)
+ .order('event_date', { ascending: false });
+ if (error) {
+ setCreateEventError('Failed to fetch events.');
+ setEvents([]);
+ } else {
+ // Add derived checkin_qr_enabled logic
+ const eventsWithQrFlag = (data || []).map(event => ({
+ ...event,
+ checkin_qr_enabled: !event.checkin_code_enabled
+ }));
+ setEvents(eventsWithQrFlag);
+ }
+ };
+ fetchEvents();
+ }, [clubId]);
+
+ // Fetch members when tab is active
+ useEffect(() => {
+ const fetchMembers = async () => {
+ if (!clubId || currentTab !== 'members') return;
+ setMemberLoading(true);
+ setMemberError(null);
+ const { data, error } = await supabase
+ .from('members')
+ .select('id, club_id, name, preapproved, created_at')
+ .eq('club_id', clubId)
+ .order('created_at', { ascending: false });
+ if (error) {
+ setMemberError('Failed to fetch members.');
+ setMembers([]);
+ } else {
+ setMembers(data || []);
+ }
+ setMemberLoading(false);
+ };
+ fetchMembers();
+ }, [clubId, currentTab]);
+
+ // Fetch attendance data when tab is active
+ useEffect(() => {
+ const fetchAttendance = async () => {
+ if (!clubId || currentTab !== 'attendance' || !events.length) {
+ setAttendanceData([]); // Clear data if conditions not met
+ return;
+ }
+ setAttendanceLoading(true);
+ const eventIds = events.map(e => e.id);
+ if (eventIds.length === 0) {
+ setAttendanceData([]);
+ setAttendanceLoading(false);
+ return;
+ }
+ const { data } = await supabase
+ .from('attendance')
+ .select('id, attended_at, event:events(name, event_date), member:members(name)')
+ .in('event_id', eventIds);
+ setAttendanceData(data || []);
+ setAttendanceLoading(false);
+ };
+ fetchAttendance();
+ }, [clubId, currentTab, events]);
+
+ // --- Event Handlers (Moved from Clubs.tsx Modal) ---
+
+ const handleCreateEventSubmit = async (eventData: any) => {
+ if (!clubId) return;
+ setCreateEventError(null);
+
+ try {
+ let error: any;
+ if (eventToEdit) {
+ // --- Update existing event ---
+ const { error: updateError } = await supabase
+ .from('events')
+ .update({ ...eventData })
+ .eq('id', eventToEdit.id);
+ error = updateError;
+ } else {
+ // --- Insert new event ---
+ const { error: insertError } = await supabase
+ .from('events')
+ .insert([{ ...eventData, club_id: clubId, created_at: new Date().toISOString() }]);
+ error = insertError;
+ }
+
+ if (error) {
+ console.error('Error saving event:', error);
+ throw new Error(error.message || `Failed to ${eventToEdit ? 'update' : 'create'} event. Please try again.`);
+ }
+
+ // Close modal and reset editing state on success
+ setIsCreateEventModalOpen(false);
+ setEventToEdit(null); // Clear the event being edited
+
+ // Refetch events to update the list
+ const { data: refetchData, error: refetchError } = await supabase
+ .from('events')
+ // Select ALL fields needed for display and editing, including the time fields
+ .select(`
+ id, club_id, name, event_date, invite_code, created_at,
+ checkin_location_enabled, checkin_code_enabled, checkin_only_during_event,
+ location_lat, location_lng, location_radius_meters,
+ recurrence, recurrence_until, event_start_time, event_end_time
+ `)
+ .eq('club_id', clubId)
+ .order('event_date', { ascending: false });
+
+ if (refetchError) {
+ console.error('Error refetching events:', refetchError);
+ setCreateEventError(`Event ${eventToEdit ? 'updated' : 'created'}, but failed to update the list.`);
+ } else {
+ // Add checkin_qr_enabled logic (QR is enabled if checkin_code_enabled is false)
+ const eventsWithQrFlag = (refetchData || []).map(event => ({
+ ...event,
+ checkin_qr_enabled: !event.checkin_code_enabled
+ }));
+ setEvents(eventsWithQrFlag);
+ }
+
+ } catch (error: any) {
+ setCreateEventError(error.message);
+ throw error;
+ }
+ };
+
+ const handleAddMember = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!clubId) return;
+ setMemberError(null);
+ setMemberLoading(true);
+ const { error } = await supabase
+ .from('members')
+ .insert([{ club_id: clubId, name: memberName, preapproved: true }]);
+ if (error) {
+ setMemberError('Failed to add member.');
+ } else {
+ setMemberName('');
+ // Refetch members
+ const { data } = await supabase
+ .from('members')
+ .select('id, club_id, name, preapproved, created_at')
+ .eq('club_id', clubId)
+ .order('created_at', { ascending: false });
+ setMembers(data || []);
+ }
+ setMemberLoading(false);
+ };
+
+ const handleDeleteClub = async () => {
+ if (!clubId || !club) return;
+ if (!window.confirm(`Are you sure you want to delete the club "${club.name}"? This action cannot be undone.`)) return;
+ const { error } = await supabase.from('clubs').delete().eq('id', clubId);
+ if (error) {
+ alert('Failed to delete club.');
+ return;
+ }
+ navigate('/clubs'); // Navigate back to the list after deletion
+ };
+
+ const handleExportCSV = () => {
+ if (!attendanceData.length || !club) return;
+ let csv = 'Event,Date,Member,Attended At\n';
+ attendanceData.forEach(row => {
+ csv += `${row.event?.name || ''},${row.event?.event_date || ''},${row.member?.name || ''},${row.attended_at || ''}\n`;
+ });
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ if (csvRef.current) {
+ csvRef.current.href = url;
+ csvRef.current.download = `${club.name || 'attendance'}.csv`;
+ csvRef.current.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+ };
+
+ // Delete an event by id
+ const handleDeleteEvent = async (eventId: string) => {
+ if (!window.confirm('Are you sure you want to delete this event? This action cannot be undone.')) return;
+ setCreateEventError('Deleting event...');
+ const { error } = await supabase.from('events').delete().eq('id', eventId);
+ if (error) {
+ setCreateEventError('Failed to delete event.');
+ } else {
+ // Refetch events
+ const { data } = await supabase
+ .from('events')
+ .select('id, club_id, name, event_date, invite_code, created_at')
+ .eq('club_id', clubId)
+ .order('event_date', { ascending: false });
+ setEvents(data || []);
+ }
+ };
+
+ // Delete a member by id
+ const handleDeleteMember = async (memberId: string) => {
+ if (!window.confirm('Are you sure you want to remove this member? This action cannot be undone.')) return;
+ setMemberLoading(true);
+ const { error } = await supabase.from('members').delete().eq('id', memberId);
+ if (error) {
+ setMemberError('Failed to remove member.');
+ } else {
+ // Refetch members
+ const { data } = await supabase
+ .from('members')
+ .select('id, club_id, name, preapproved, created_at')
+ .eq('club_id', clubId)
+ .order('created_at', { ascending: false });
+ setMembers(data || []);
+ }
+ setMemberLoading(false);
+ };
+
+ // Delete all members for the club
+ const handleDeleteAllMembers = async () => {
+ if (!clubId || members.length === 0) return; // Don't proceed if no club or no members
+ if (!window.confirm(`Are you sure you want to remove ALL ${members.length} preapproved member(s)? This action cannot be undone.`)) return;
+
+ setDeleteAllMembersLoading(true);
+ setMemberError(null);
+
+ const { error } = await supabase
+ .from('members')
+ .delete()
+ .eq('club_id', clubId);
+
+ if (error) {
+ console.error('Error deleting all members:', error);
+ setMemberError('Failed to remove all members. Please try again.');
+ } else {
+ setMembers([]); // Clear the list immediately on success
+ // Optionally, refetch to confirm, though setting to [] is faster UI-wise
+ // const { data } = await supabase.from('members').select('...').eq(...);
+ // setMembers(data || []);
+ }
+ setDeleteAllMembersLoading(false);
+ };
+
+ // --- Render Logic ---
+
+ if (authLoading || loadingClub) {
+ return (
+
+
+ Loading Club Details...
+
+
+ );
+ }
+
+ if (errorClub) {
+ return (
+
+
+
{errorClub}
+
+ Back to My Clubs
+
+
+
+ );
+ }
+
+ if (!club) {
+ // This case might be hit briefly or if permissions fail silently
+ return (
+
+
+
Club not found or access denied.
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Page Header */}
+
+
+ ← Back to My Clubs
+
+ {club.name}
+
+ {club.category}
+
+ Code: {club.access_code}
+
+ {/* Link to QR page - Icon Removed */}
+
+ Show Join QR Code
+
+
+ {club.description}
+
+
+ {/* Tab Navigation */}
+
+
+
+
+ {/* Delete Button - moved to end */}
+
+
+
+
+ {/* Tab Content Area */}
+
+
+ {currentTab === 'events' && (
+
+ {/* Header with Create Event Button */}
+
+
+
+ Manage Events
+
+
+
+
+ {/* Display event creation error if any */}
+ {createEventError &&
{createEventError}
}
+
+ {events.length > 0 ? (
+
+ {events.map(event => (
+ -
+
+ {/* Left side - Date indicator */}
+
+
+ {parseLocalDate(event.event_date).getDate()}
+
+
+ {parseLocalDate(event.event_date).toLocaleString('en-US', { month: 'short' })}
+
+
+
+ {/* Right side - Event details */}
+
+
+
+
+ {event.name}
+
+
+ {(() => {
+ console.log(`Raw event_date: "${event.event_date}", Raw event_start_time: "${event.event_start_time}"`);
+
+ // Emergency fallback display to ensure we see the actual data
+ let rawDisplay = `Date: ${event.event_date || 'None'}`;
+ rawDisplay += event.event_start_time ? ` Time: ${event.event_start_time}` : '';
+
+ // Try to parse and format correctly
+ try {
+ // YYYY-MM-DD format expected
+ const dateParts = event.event_date.split('-');
+ if (dateParts.length !== 3) {
+ console.error("Invalid date format:", event.event_date);
+ return rawDisplay; // Show raw values in UI if format is wrong
+ }
+
+ // Parse date parts as integers
+ const year = parseInt(dateParts[0]);
+ const month = parseInt(dateParts[1]) - 1; // JS months are 0-indexed
+ const day = parseInt(dateParts[2]);
+
+ console.log(`Parsed date components - Year: ${year}, Month: ${month} (0-indexed), Day: ${day}`);
+
+ // Create date using local date constructor (avoids timezone issues)
+ const eventDate = new Date(year, month, day);
+ console.log(`Created Date object:`, eventDate.toString());
+
+ // Format options for display
+ const formatOptions: Intl.DateTimeFormatOptions = {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ };
+
+ // Add time if available
+ if (event.event_start_time) {
+ formatOptions.hour = 'numeric';
+ formatOptions.minute = '2-digit';
+
+ // Handle both formats: "HH:MM:SS" and ISO "YYYY-MM-DDTHH:MM:SS"
+ try {
+ let timeString = event.event_start_time;
+
+ // If it contains a 'T' (ISO format), extract just the time part
+ if (timeString.includes('T')) {
+ console.log('ISO format detected, extracting time portion');
+ timeString = timeString.split('T')[1];
+ }
+
+ console.log('Extracted time string:', timeString);
+
+ // Now parse the time part
+ const timeParts = timeString.split(':');
+ if (timeParts.length >= 2) {
+ const hours = parseInt(timeParts[0]);
+ const minutes = parseInt(timeParts[1]);
+
+ console.log(`Parsed time - Hours: ${hours}, Minutes: ${minutes}`);
+
+ // Set time on our date object
+ eventDate.setHours(hours, minutes, 0);
+ console.log(`Date with time set:`, eventDate.toString());
+ }
+ } catch (error) {
+ console.error('Error parsing time:', error);
+ }
+ }
+
+ // Format the date for display
+ const formattedDate = eventDate.toLocaleString('en-US', formatOptions);
+ console.log(`Final formatted date: "${formattedDate}"`);
+
+ return formattedDate;
+ } catch (error) {
+ console.error("Error formatting date:", error);
+ return rawDisplay; // Fallback to raw display
+ }
+ })()}
+
+ {/* Add Event Badges Here */}
+
+ {event.checkin_location_enabled && }
+ {event.checkin_qr_enabled && !event.checkin_code_enabled && }
+ {event.checkin_code_enabled && }
+ {event.checkin_only_during_event && }
+
+
+
+ {event.invite_code}
+
+
+
+
+
+
+
+
+ {/* Action Buttons - Arranged Horizontally */}
+
+
+ Show Full QR Code
+
+
+ {/* Add Edit Button */}
+
+
+
+
+
+
+
+ ))}
+
+ ) : (
+
No events created yet.
+ )}
+
+ )}
+
+ {currentTab === 'members' && (
+
+ {/* Updated Header with Delete All button */}
+
+
+
+ Manage Preapproved Members
+
+
+
+
+ {/* Add Member Form - styled as card */}
+
+
+ {memberError &&
{memberError}
}
+
+
+ {/* Member List */}
+ {memberLoading ? (
+
Loading members...
+ ) : members.length > 0 ? (
+
+ {members.map(member => (
+ -
+
+
+
+ {member.name}
+ {member.preapproved && (
+
+ Preapproved
+
+ )}
+
+
+
+
+ ))}
+
+ ) : (
+
No members added yet.
+ )}
+
+ )}
+
+ {currentTab === 'attendance' && (
+
+
+
+
+ Attendance Records
+
+
+
+
+
+
+
+
+
+
+
+ {attendanceLoading ? (
+
Loading attendance...
+ ) : attendanceData.length > 0 ? (
+
+
+
+
+ | {attendanceTab === 'byEvent' ? 'Event' : 'Member'} |
+ {attendanceTab === 'byEvent' ? 'Member' : 'Event'} |
+ Event Date |
+ Checked In At |
+
+
+
+ {attendanceData
+ .sort((a, b) => new Date(b.attended_at).getTime() - new Date(a.attended_at).getTime()) // Sort by check-in time desc
+ .map(row => (
+
+ | {attendanceTab === 'byEvent' ? row.event?.name : row.member?.name} |
+ {attendanceTab === 'byEvent' ? row.member?.name : row.event?.name} |
+ {row.event?.event_date ? parseLocalDate(row.event.event_date).toLocaleDateString() : ''} |
+ {new Date(row.attended_at).toLocaleString()} |
+
+ ))}
+
+
+
+ ) : (
+
No attendance data found.
+ )}
+
+ )}
+
+
+
+ {/* Render the Create Event Modal */}
+
{
+ setIsCreateEventModalOpen(false);
+ setEventToEdit(null); // Reset editing state when closing
+ }}
+ onSubmit={handleCreateEventSubmit}
+ eventToEdit={eventToEdit}
+ />
+
+
+
+ );
+};
+
+export default ClubDetail;
+
+// --- EventTypeBadge Component (copied from Dashboard) ---
+const eventTypeExplanations: Record = {
+ geo: 'Geo-fenced: Check-in requires device location to be at the event.',
+ code: 'Code Required: Check-in requires entering the event code.',
+ qr: 'QR/Direct: Check-in via QR code scan or direct link.',
+ time: 'Time Window: Check-in is only allowed during the specified event time.'
+};
+
+function EventTypeBadge({ type }: { type: 'geo' | 'code' | 'qr' | 'time' }) {
+ const [show, setShow] = useState(false);
+ const typeStyles: Record = {
+ geo: 'bg-blue-100 text-blue-700',
+ code: 'bg-yellow-100 text-yellow-700',
+ qr: 'bg-green-100 text-green-700',
+ time: 'bg-purple-100 text-purple-700'
+ };
+ const typeLabels: Record = {
+ geo: 'Geo-fenced',
+ code: 'Code Only',
+ qr: 'QR/Link',
+ time: 'Time Restricted'
+ };
+
+ return (
+ setShow(false)}>
+ setShow(true)}
+ >
+ {typeLabels[type]}
+
+
+
+ {show && (
+
+ {eventTypeExplanations[type]}
+
+ )}
+
+
+ );
+}
+
+// Keep interfaces at the end or import from a types file
+/*
+interface Club { ... }
+interface Event { ... }
+interface Member { ... }
+*/
\ No newline at end of file
diff --git a/src/pages/ClubJoinPage.tsx b/src/pages/ClubJoinPage.tsx
new file mode 100644
index 0000000..ac87c8b
--- /dev/null
+++ b/src/pages/ClubJoinPage.tsx
@@ -0,0 +1,554 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { supabase } from '../utils/supabaseClient';
+import Logo from '../components/Logo';
+import { v4 as uuidv4 } from 'uuid';
+import { motion, AnimatePresence } from 'framer-motion';
+import { getCloseMatches } from '../utils/nameMatcher';
+
+interface ClubInfo {
+ id: string;
+ name: string;
+ description?: string;
+ category?: string;
+}
+
+// Shared transition for content
+const TAB_TRANSITION = {
+ opacity: { duration: 0.16, ease: [0.4, 0, 0.2, 1] },
+ filter: { duration: 0.28, ease: [0.4, 0, 0.2, 1] }
+};
+
+const tabVariants = {
+ hidden: {
+ opacity: 0,
+ filter: 'blur(16px)',
+ scale: 0.97,
+ y: -20
+ },
+ visible: {
+ opacity: 1,
+ filter: 'blur(0px)',
+ scale: 1,
+ y: 0,
+ transition: {
+ ...TAB_TRANSITION,
+ type: 'spring',
+ damping: 25,
+ stiffness: 300
+ }
+ },
+ exit: {
+ opacity: 0,
+ filter: 'blur(16px)',
+ scale: 0.97,
+ y: -20,
+ transition: {
+ ...TAB_TRANSITION,
+ duration: 0.2
+ }
+ }
+};
+
+const ClubJoinPage: React.FC = () => {
+ const { clubId } = useParams<{ clubId: string }>();
+ const navigate = useNavigate();
+ const [clubInfo, setClubInfo] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [memberName, setMemberName] = useState('');
+ const [inviteCode, setInviteCode] = useState('');
+ const [joinLoading, setJoinLoading] = useState(false);
+ const [joinError, setJoinError] = useState(null);
+ const [step, setStep] = useState(clubId ? 2 : 1); // Start at step 2 if clubId is provided
+ const [isDemo, setIsDemo] = useState(false);
+
+ // Fetch member_uuid from localStorage on load
+ const [, setMemberUuid] = useState(null);
+ const [suggestedNames, setSuggestedNames] = useState([]);
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [memberNames, setMemberNames] = useState([]);
+ const [preapprovedMembers, setPreapprovedMembers] = useState([]);
+
+ useEffect(() => {
+ setMemberUuid(localStorage.getItem('attendify_member_id'));
+ }, []);
+
+ // Debug: Log all supabase responses and errors
+ const debugLog = (...args: any[]) => {
+ if (window.location.search.includes('debug=1')) {
+ console.log('[JOIN DEBUG]', ...args);
+ }
+ };
+
+ // Handle name input and suggestions
+ const handleNameInput = (inputValue: string) => {
+ setMemberName(inputValue);
+
+ if (inputValue.trim().length > 0) {
+ const matches = getCloseMatches(inputValue, memberNames);
+ setSuggestedNames(matches);
+ setShowSuggestions(matches.length > 0);
+ } else {
+ setSuggestedNames([]);
+ setShowSuggestions(false);
+ }
+ };
+
+ const selectSuggestedName = (name: string) => {
+ setMemberName(name);
+ setShowSuggestions(false);
+ };
+
+ const fetchMembers = async (clubId: string) => {
+ try {
+ const { data: members, error } = await supabase
+ .from('members')
+ .select('name, preapproved')
+ .eq('club_id', clubId);
+ if (!error && members) {
+ setMemberNames(members.map(m => m.name));
+ setPreapprovedMembers(members.filter(m => m.preapproved).map(m => m.name));
+ }
+ } catch (err) {
+ console.error('Error fetching members:', err);
+ }
+ };
+
+ // If clubId is provided, fetch club info directly
+ useEffect(() => {
+ const fetchClubInfoById = async () => {
+ if (!clubId) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ // Try the original query with .single()
+ const { data, error: fetchError, status, statusText } = await supabase
+ .from('clubs')
+ .select('id, name, description, category')
+ .eq('access_code', clubId)
+ .single();
+ debugLog('fetchClubInfoById', { data, fetchError, status, statusText });
+ // EXTENSIVE LOGGING
+ if (fetchError) {
+ console.error('[JOIN DEBUG] fetchClubInfoById error:', fetchError);
+ if (fetchError.code === '406') {
+ // 406 Not Acceptable: Try again without .single() to see what comes back
+ console.warn('[JOIN DEBUG] 406 error detected, retrying without .single()');
+ const { data: arrData, error: arrError, status: arrStatus, statusText: arrStatusText } = await supabase
+ .from('clubs')
+ .select('id, name, description, category')
+ .eq('access_code', clubId);
+ debugLog('fetchClubInfoById array fallback', { arrData, arrError, arrStatus, arrStatusText });
+ console.log('[JOIN DEBUG] Array fallback result:', arrData, arrError);
+ if (arrError) {
+ setError('Could not load club information (406 fallback). Please check the link.');
+ setClubInfo(null);
+ setStep(1);
+ } else if (Array.isArray(arrData) && arrData.length === 1) {
+ setClubInfo(arrData[0]);
+ await fetchMembers(arrData[0].id);
+ // Check if this is a demo club
+ if (arrData[0].category === 'Demo') {
+ setIsDemo(true);
+ const demoName = `Demo User ${Math.floor(Math.random() * 1000)}`;
+ setMemberName(demoName);
+ if (window.location.search.includes('autojoin=1')) {
+ setTimeout(() => {
+ const joinButton = document.querySelector('button[type="submit"]') as HTMLButtonElement;
+ if (joinButton) joinButton.click();
+ }, 1500);
+ }
+ }
+ } else if (Array.isArray(arrData) && arrData.length === 0) {
+ setError('No club found for this code. (406 fallback)');
+ setClubInfo(null);
+ setStep(1);
+ } else if (Array.isArray(arrData) && arrData.length > 1) {
+ setError('Multiple clubs found for this code. Please contact support. (406 fallback)');
+ setClubInfo(null);
+ setStep(1);
+ } else {
+ setError('Unknown error (406 fallback).');
+ setClubInfo(null);
+ setStep(1);
+ }
+ return;
+ }
+ }
+ if (fetchError || !data) {
+ setError('Could not load club information. Please check the link.');
+ setClubInfo(null);
+ setStep(1); // Go back to step 1 if there's an error
+ } else {
+ setClubInfo(data);
+ await fetchMembers(data.id);
+ // Check if this is a demo club
+ if (data.category === 'Demo') {
+ setIsDemo(true);
+ // Prefill with demo username if this is a demo club
+ const demoName = `Demo User ${Math.floor(Math.random() * 1000)}`;
+ setMemberName(demoName);
+ // For enhanced demo experience, auto-join after a short delay
+ if (window.location.search.includes('autojoin=1')) {
+ setTimeout(() => {
+ const joinButton = document.querySelector('button[type="submit"]') as HTMLButtonElement;
+ if (joinButton) joinButton.click();
+ }, 1500);
+ }
+ }
+ }
+ } catch (error: any) {
+ debugLog('fetchClubInfoById catch', error);
+ console.error('Error fetching club info:', error);
+ setError(`Failed to load club: ${error.message || 'Please try again.'}`);
+ setStep(1); // Go back to step 1 if there's an error
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchClubInfoById();
+ }, [clubId]);
+
+ const verifyInviteCode = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!inviteCode.trim()) {
+ setJoinError('Please enter an invite code.');
+ return;
+ }
+
+ setJoinLoading(true);
+ setJoinError(null);
+
+ try {
+ // Find club by invite code
+ const { data: club, error: clubError } = await supabase
+ .from('clubs')
+ .select('id, name, description')
+ .eq('access_code', inviteCode)
+ .single();
+ debugLog('verifyInviteCode', { club, clubError });
+ if (clubError || !club) {
+ setJoinError('Invalid invite code. Please try again.');
+ setJoinLoading(false);
+ return;
+ }
+
+ setClubInfo(club);
+ await fetchMembers(club.id);
+ setJoinLoading(false);
+ setStep(2); // Move to step 2 (name input)
+ } catch (error: any) {
+ debugLog('verifyInviteCode catch', error);
+ console.error('Error verifying invite code:', error);
+ setJoinError(`Failed to verify code: ${error.message || 'Please try again.'}`);
+ setJoinLoading(false);
+ }
+ };
+
+ const handleJoinClub = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!clubInfo || !memberName.trim()) {
+ setJoinError('Please enter your name.');
+ return;
+ }
+ setJoinLoading(true);
+ setJoinError(null);
+
+ try {
+ // Check if member with this name already exists in club
+ const { data: existingMember, error: existingMemberError } = await supabase
+ .from('members')
+ .select('id, member_uuid')
+ .eq('club_id', clubInfo.id)
+ .eq('name', memberName.trim())
+ .single();
+ debugLog('handleJoinClub existingMember', { existingMember, existingMemberError });
+ if (existingMemberError && existingMemberError.code !== 'PGRST116') { // Ignore 'No rows found' error
+ throw existingMemberError;
+ }
+
+ // Initialize finalMemberUuid
+ let finalMemberUuid;
+
+ if (existingMember) {
+ // Member already exists in this club
+ // Use their existing UUID if they have one, otherwise generate new
+ finalMemberUuid = existingMember.member_uuid || uuidv4();
+
+ // Update the member's UUID if it was missing
+ if (!existingMember.member_uuid) {
+ const updateRes = await supabase.from('members').update({ member_uuid: finalMemberUuid }).eq('id', existingMember.id);
+ debugLog('handleJoinClub update member_uuid', updateRes);
+ }
+ } else {
+ // Member does not exist in this club, always generate a new UUID
+ // This ensures we don't reuse UUIDs across different members
+ finalMemberUuid = uuidv4();
+
+ // Create new member
+ const isPreapproved = preapprovedMembers.includes(memberName.trim());
+ const { error: insertError, data: insertData } = await supabase
+ .from('members')
+ .insert([{
+ club_id: clubInfo.id,
+ name: memberName.trim(),
+ member_uuid: finalMemberUuid,
+ preapproved: isPreapproved
+ }]);
+ debugLog('handleJoinClub insert member', { insertData, insertError });
+ if (insertError) {
+ throw insertError;
+ }
+ }
+
+ // Store the member UUID in localStorage
+ localStorage.setItem('attendify_member_id', finalMemberUuid);
+ setMemberUuid(finalMemberUuid); // Update state
+
+ // Record club membership in localStorage
+ const storedClubs = JSON.parse(localStorage.getItem('attendify_clubs') || '[]');
+ // Avoid duplicates
+ if (!storedClubs.some((c: any) => c.id === clubInfo.id)) {
+ storedClubs.push({
+ id: clubInfo.id,
+ name: clubInfo.name,
+ member_name: memberName.trim() // Store the name used to join this specific club
+ });
+ localStorage.setItem('attendify_clubs', JSON.stringify(storedClubs));
+ } else {
+ // Optional: Update name if it changed for an existing club record
+ const clubIndex = storedClubs.findIndex((c: any) => c.id === clubInfo.id);
+ if (clubIndex !== -1 && storedClubs[clubIndex].member_name !== memberName.trim()) {
+ storedClubs[clubIndex].member_name = memberName.trim();
+ localStorage.setItem('attendify_clubs', JSON.stringify(storedClubs));
+ }
+ }
+
+ debugLog('handleJoinClub success', { finalMemberUuid });
+ setStep(3); // Move to success step
+ } catch (error: any) {
+ debugLog('handleJoinClub catch', error);
+ console.error('Error joining club:', error);
+ setJoinError(`Failed to join club: ${error.message || 'Please try again.'}`);
+ } finally {
+ setJoinLoading(false);
+ }
+ };
+
+ return (
+
+ {/* Branding */}
+
+
+
+
+ Powered by Attendify
+
+
+
+ {loading && clubId ? (
+
+ Loading Club Information...
+
+ ) : error && clubId ? (
+
+ {error}
+
+
+ ) : (
+
+ {step === 1 && (
+
+ Join Club
+ Enter the invite code provided by your club organizer
+
+
+
+ )}
+
+ {step === 2 && clubInfo && (
+
+
+ {clubInfo.name}
+
+ Enter your name to complete joining
+
+
+
+ By joining, you agree to share your name with the club organizers.
+
+
+ )}
+
+ {step === 3 && clubInfo && (
+
+
+ Successfully Joined!
+ You have successfully joined {clubInfo.name} as {memberName}.
+
+ {isDemo ? (
+ {
+ // Focus the opener window (main demo tab)
+ if (window.opener) {
+ window.opener.focus();
+ }
+ // Close the current tab
+ window.close();
+ }}
+ whileHover={{ scale: 1.03 }}
+ whileTap={{ scale: 0.98 }}
+ >
+ Close this tab
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default ClubJoinPage;
\ No newline at end of file
diff --git a/src/pages/ClubJoinQR.tsx b/src/pages/ClubJoinQR.tsx
new file mode 100644
index 0000000..1703103
--- /dev/null
+++ b/src/pages/ClubJoinQR.tsx
@@ -0,0 +1,116 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, Link } from 'react-router-dom';
+import { supabase } from '../utils/supabaseClient';
+import { QRCodeCanvas } from 'qrcode.react';
+import { motion } from 'framer-motion';
+import Logo from '../components/Logo';
+
+interface ClubInfo {
+ name: string;
+ access_code: string;
+}
+
+const ClubJoinQR: React.FC = () => {
+ const { clubId } = useParams<{ clubId: string }>();
+ const [clubInfo, setClubInfo] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchClubInfo = async () => {
+ if (!clubId) {
+ setError('Club ID not found.');
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ const { data, error: fetchError } = await supabase
+ .from('clubs')
+ .select('name, access_code')
+ .eq('id', clubId)
+ .single();
+
+ if (fetchError || !data) {
+ setError('Could not load club information. Please check the link.');
+ setClubInfo(null);
+ } else {
+ setClubInfo(data);
+ }
+ setLoading(false);
+ };
+
+ fetchClubInfo();
+ }, [clubId]);
+
+ return (
+
+ {/* Attendify Branding (Top Left) */}
+
+
+
+
+ {/* Attendify Branding (Bottom Left) */}
+
+
+ Powered by Attendify
+
+
+
+ {loading ? (
+
+ Loading QR Code...
+
+ ) : error ? (
+
+ {error}
+
+ Back to Home
+
+
+ ) : clubInfo ? (
+
+
+ {clubInfo.name}
+
+
+ Scan the code below with your device to join the club
+
+
+ console.log('[QR DEBUG] QR for join code:', clubInfo.access_code) } : {})}
+ size={256}
+ level="H"
+ bgColor="#ffffff"
+ fgColor="#000000"
+ />
+
+ Or enter code:
+
+ {clubInfo.access_code}
+
+
+ at attendify.app/join
+
+
+ Back to Club Details
+
+
+ ) : (
+
Could not display QR code.
+ )}
+
+ );
+};
+
+export default ClubJoinQR;
\ No newline at end of file
diff --git a/src/pages/Clubs.tsx b/src/pages/Clubs.tsx
index 17fed78..fa863de 100644
--- a/src/pages/Clubs.tsx
+++ b/src/pages/Clubs.tsx
@@ -1,103 +1,178 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import CreateClubModal from '../components/CreateClubModal';
import { motion } from 'framer-motion';
+import { useAuth } from '../contexts/AuthContext';
+import { supabase } from '../utils/supabaseClient';
interface Club {
id: string;
name: string;
description: string;
- memberCount: number;
category: string;
+ access_code: string;
+ created_at: string;
}
-const containerVariants = {
- hidden: { opacity: 0 },
- visible: {
- opacity: 1,
- transition: {
- staggerChildren: 0.1,
- delayChildren: 0.2
- }
+function generateAccessCode(length = 8) {
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+ let code = '';
+ for (let i = 0; i < length; i++) {
+ code += chars.charAt(Math.floor(Math.random() * chars.length));
}
-};
-
-const itemVariants = {
- hidden: {
- opacity: 0,
- y: 20
- },
- visible: {
- opacity: 1,
- y: 0,
- transition: {
- type: "spring",
- stiffness: 100,
- damping: 20
- }
- }
-};
+ return code;
+}
const Clubs: React.FC = () => {
+ const { user, loading } = useAuth();
+ const navigate = useNavigate();
const [showCreateModal, setShowCreateModal] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [clubs, setClubs] = useState([]);
+ const [fetching, setFetching] = useState(true);
+ const [error, setError] = useState(null);
+ const isInitialLoad = React.useRef(true);
+ const hasAnimated = React.useRef(false);
+
+ useEffect(() => {
+ if (!loading && !user) {
+ navigate('/login');
+ }
+ }, [user, loading, navigate]);
+
+ useEffect(() => {
+ const fetchClubs = async () => {
+ if (!user) return;
+ setFetching(true);
+ setError(null);
+ const { data: ownerRows, error: ownerError } = await supabase
+ .from('club_owners')
+ .select('club_id')
+ .eq('user_id', user.id);
+ if (ownerError) {
+ setError('Failed to fetch clubs.');
+ setFetching(false);
+ return;
+ }
+ const clubIds = (ownerRows || []).map((row: any) => row.club_id).filter(Boolean);
+ if (!clubIds.length) {
+ setError(null);
+ setFetching(false);
+ return;
+ }
+ const validClubIds = clubIds.filter(id => typeof id === 'string' && id.length > 0);
+ console.log('Fetching clubs with IDs:', validClubIds);
+ if (!validClubIds.length) {
+ setError(null);
+ setFetching(false);
+ return;
+ }
+ const { data: clubsData, error: clubsError } = await supabase
+ .from('clubs')
+ .select('id, name, description, category, access_code, created_at')
+ .in('id', validClubIds);
+ if (clubsError) {
+ setError('Failed to fetch clubs.');
+ } else {
+ setClubs(clubsData || []);
+ setError(null);
+ }
+ setFetching(false);
+ isInitialLoad.current = false;
+ };
+ fetchClubs();
+ }, [user]);
const filteredClubs = clubs.filter(club =>
club.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
- club.description.toLowerCase().includes(searchQuery.toLowerCase())
+ (club.description || '').toLowerCase().includes(searchQuery.toLowerCase())
);
- const handleCreateClub = (clubData: { name: string; description: string; category: string }) => {
- const newClub: Club = {
- id: Date.now().toString(),
- ...clubData,
- memberCount: 1
- };
- setClubs([newClub, ...clubs]);
+ const handleCreateClub = async (clubData: { name: string; description: string; category: string }) => {
+ if (!user) return;
+ setError(null);
+ const access_code = generateAccessCode();
+ const { data: club, error: clubError } = await supabase
+ .from('clubs')
+ .insert([{
+ name: clubData.name,
+ access_code,
+ description: clubData.description || '',
+ category: clubData.category || '',
+ created_at: new Date().toISOString()
+ }])
+ .select()
+ .single();
+ if (clubError || !club) {
+ console.error('Club creation error:', clubError);
+ if (typeof window !== 'undefined') {
+ alert('Club creation error: ' + (clubError?.message || 'Unknown error'));
+ }
+ setError('Failed to create club.');
+ return;
+ }
+ await supabase
+ .from('club_settings')
+ .insert([{ club_id: club.id, preapproved_only: false }]);
+ const { error: ownerError } = await supabase
+ .from('club_owners')
+ .insert([{ club_id: club.id, user_id: user.id }]);
+ if (ownerError) {
+ setError('Failed to assign club owner.');
+ return;
+ }
+ setShowCreateModal(false);
+ setFetching(true);
+ const { data: ownerRows } = await supabase
+ .from('club_owners')
+ .select('club_id')
+ .eq('user_id', user.id);
+ const clubIds = (ownerRows || []).map((row: any) => row.club_id).filter(Boolean);
+ const validClubIds = clubIds.filter(id => typeof id === 'string' && id.length > 0);
+ if (!validClubIds.length) {
+ setError(null);
+ setFetching(false);
+ return;
+ }
+ const { data: clubsData } = await supabase
+ .from('clubs')
+ .select('id, name, description, category, access_code, created_at')
+ .in('id', validClubIds);
+ setClubs(clubsData || []);
+ setFetching(false);
};
return (
- {/* Header Section */}
- Your Clubs
-
- Join existing clubs or create your own. Track attendance, manage members, and organize events all in one place.
+
My Clubs
+
+ Manage your clubs, events, and members.
-
- {/* Search and Create */}
+
-
+
setShowCreateModal(true)}
- className="primary-button whitespace-nowrap"
+ className="w-full sm:w-auto px-4 py-2.5 text-sm bg-black text-white font-medium rounded-md hover:bg-gray-800 transition-all"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
- Create New Club
+ + Create Club
-
- {/* Clubs Grid */}
-
- {filteredClubs.length > 0 ? (
- filteredClubs.map(club => (
-
-
-
-
- {club.name.charAt(0)}
-
-
-
- {club.memberCount} member{club.memberCount !== 1 ? 's' : ''}
-
-
-
- {club.name}
-
-
- {club.description}
-
-
-
- {club.category}
-
-
- Join Club →
-
-
-
- ))
- ) : (
-
-
- {error}
}
+
+ {(fetching && isInitialLoad.current) ? (
+ Loading clubs...
+ ) : (
+
+
+ {filteredClubs.length > 0 ? (
+ filteredClubs.map(club => (
+ !hasAnimated.current ? (
+
{ hasAnimated.current = true; }}
+ onClick={() => navigate(`/clubs/${club.id}`)}
+ >
+
+
+ {club.name}
+
+
+ {club.access_code}
+
+
+
+ {club.description || 'No description provided'}
+
+
+ {club.category}
+
+
+ ) : (
+
navigate(`/clubs/${club.id}`)}
+ >
+
+
+ {club.name}
+
+
+ {club.access_code}
+
+
+
+ {club.description || 'No description provided'}
+
+
+ {club.category}
+
+
+ )
+ ))
+ ) : (
+
-
No clubs found
-
- {searchQuery
- ? "We couldn't find any clubs matching your search"
- : "Create your first club to get started!"}
-
-
setShowCreateModal(true)}
- className="primary-button"
- whileHover={{ scale: 1.02 }}
- whileTap={{ scale: 0.98 }}
- >
- Create New Club
-
-
-
-
- )}
-
+
+
+
+ {searchQuery ? 'No clubs found' : 'No clubs yet'}
+
+
+ {searchQuery
+ ? "No clubs matched your search. Try different keywords."
+ : "Get started by creating your first club."
+ }
+
+
setShowCreateModal(true)}
+ className="px-4 py-2 text-sm bg-black text-white font-medium rounded-md hover:bg-gray-800 transition-all"
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
+ >
+ + Create Club
+
+
+
+
+ )}
+
+
+ )}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..239179c
--- /dev/null
+++ b/src/pages/Dashboard.tsx
@@ -0,0 +1,1662 @@
+import React, { useEffect, useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { supabase } from '../utils/supabaseClient';
+import Logo from '../components/Logo';
+import CharFadeIn from '../components/CharFadeIn';
+import { Users, Calendar, CheckCircle, User, ArrowRight, Clock, MapPin, BarChart3, Home, PlusCircle, ArrowLeft, ClipboardList } from 'lucide-react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useAuth } from '../contexts/AuthContext';
+import { parseLocalDate } from '../lib/utils';
+
+// Animation config for staggered entrance with blur effect
+const TAB_TRANSITION = {
+ opacity: { duration: 0.16, ease: [0.2, 0, 0.2, 1] },
+ filter: { duration: 0.28, ease: [0.2, 0, 0.2, 1] }
+};
+
+const tabVariants = {
+ hidden: {
+ opacity: 0,
+ filter: 'blur(10px)',
+ scale: 0.97,
+ y: -10
+ },
+ visible: {
+ opacity: 1,
+ filter: 'blur(0px)',
+ scale: 1,
+ y: 0,
+ transition: {
+ ...TAB_TRANSITION,
+ type: 'spring',
+ damping: 20,
+ stiffness: 350
+ }
+ },
+ exit: {
+ opacity: 0,
+ filter: 'blur(10px)',
+ scale: 0.97,
+ y: -10,
+ transition: {
+ ...TAB_TRANSITION,
+ duration: 0.2
+ }
+ }
+};
+
+// Add this function for the blur animation effect
+const fadeInBlurVariants = {
+ hidden: {
+ opacity: 0,
+ filter: "blur(12px)",
+ scale: 0.98
+ },
+ visible: {
+ opacity: 1,
+ filter: "blur(0px)",
+ scale: 1,
+ transition: {
+ opacity: { duration: 0.35, ease: [0.2, 0, 0.2, 1] },
+ filter: { duration: 0.4, ease: [0.2, 0, 0.2, 1] },
+ scale: { duration: 0.35, ease: [0.2, 0, 0.2, 1] }
+ }
+ },
+ exit: {
+ opacity: 0,
+ filter: "blur(12px)",
+ scale: 0.98,
+ transition: {
+ opacity: { duration: 0.25, ease: [0.2, 0, 0.2, 1] },
+ filter: { duration: 0.3, ease: [0.2, 0, 0.2, 1] },
+ scale: { duration: 0.25, ease: [0.2, 0, 0.2, 1] }
+ }
+ }
+};
+
+// Define subcomponents for each tab's content
+
+// Events Tab Content
+const AllEventsSection: React.FC<{
+ loading: boolean;
+ upcomingEvents: Event[];
+ pastEvents: Event[];
+ eventAttendees: Record;
+ userName: string;
+}> = ({ loading, upcomingEvents, pastEvents, eventAttendees, userName }) => (
+
+ {loading && (
+
+ )}
+
+ {!loading && (
+
+ {/* Next upcoming event highlight */}
+ {upcomingEvents.length > 0 && upcomingEvents.some(e => e.has_attended) && (
+
+ {(() => {
+ // Find the next event that student is checked into
+ const nextCheckedEvent = upcomingEvents
+ .filter(e => e.has_attended)
+ .sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime())[0];
+
+ if (nextCheckedEvent) {
+ const eventDate = getEventStartDate(nextCheckedEvent);
+ const now = new Date();
+ const diffTime = eventDate.getTime() - now.getTime();
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+
+ return (
+
+
+
+
+ You're checked in!
+
+
{nextCheckedEvent.name}
+
+
+ {eventDate.toLocaleString()}
+
+
+
+ {diffDays > 0
+ ? `Starting in ${diffDays} day${diffDays !== 1 ? 's' : ''}`
+ : 'Starting today!'}
+
+
+
+ {nextCheckedEvent.checkin_location_enabled && (
+
+
+ Location required for this event
+
+ )}
+
+ {/* Decorative elements */}
+
+
+
+
+ );
+ }
+ return null;
+ })()}
+
+ )}
+
+
+
+
+
+ )}
+
+);
+
+const UpcomingEventsSection: React.FC<{
+ loading: boolean;
+ upcomingEvents: Event[];
+ eventAttendees: Record;
+ userName: string;
+}> = ({ loading, upcomingEvents, eventAttendees, userName }) => (
+
+
+
+
Upcoming Events
+
+
+ {loading && (
+
+ )}
+
+ {!loading && upcomingEvents.length > 0 && (
+
+ {upcomingEvents
+ .sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime())
+ .map(event => {
+ const eventDate = getEventStartDate(event);
+ const now = new Date();
+ const diffTime = eventDate.getTime() - now.getTime();
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+
+ return (
+
+
+
+
+
+ {/* Date Badge */}
+
+
+ {eventDate.getDate()}
+
+
+ {eventDate.toLocaleString('default', { month: 'short' })}
+
+
+
+
+
+ {event.name}
+
+
+
+ {eventDate.toLocaleString()}
+
+
+
+ 1
+ ? 'bg-gray-100 text-black'
+ : diffDays === 1
+ ? 'bg-gray-800 text-white'
+ : 'bg-black text-white')
+ }`}>
+ {diffDays > 1
+ ? `Starts in ${diffDays} days`
+ : diffDays === 1
+ ? 'Tomorrow'
+ : 'Today'}
+
+
+
+
+
+ {/* Requirements/Badges Section - Redesigned */}
+ {(event.checkin_location_enabled || event.checkin_qr_enabled || event.checkin_only_during_event) && (
+
+ {event.checkin_location_enabled && (
+
+
+ Location Required
+
+ )}
+ {event.checkin_qr_enabled && !event.checkin_code_enabled && (
+
+
+ QR Scan Enabled
+
+ )}
+ {event.checkin_only_during_event && (
+
+
+ Time Restricted
+
+ )}
+
+ )}
+
+
+
+ {event.has_attended ? (
+
+
+ Checked In
+
+ ) : (
+
+ Check In
+
+
+ )}
+
+
+
+ {/* Attendees - Conditionally rendered with improved styling */}
+ {eventAttendees[event.id] && eventAttendees[event.id].length > 0 && (
+
+
+
+ {eventAttendees[event.id].length} attendees so far
+
+
+ {eventAttendees[event.id].slice(0, 5).map(member => (
+
+ {member.name}
+
+ ))}
+ {eventAttendees[event.id].length > 5 && (
+
+ +{eventAttendees[event.id].length - 5} more
+
+ )}
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ {!loading && upcomingEvents.length === 0 && (
+
+
+ No upcoming events scheduled
+ Check back later for new events
+
+ )}
+
+);
+
+const PastEventsSection: React.FC<{
+ loading: boolean;
+ pastEvents: Event[];
+ eventAttendees: Record;
+ userName: string;
+}> = ({ loading, pastEvents, eventAttendees, userName }) => (
+
+
+
+
Past Events
+
+
+ {loading && (
+
+ )}
+
+ {!loading && pastEvents.length > 0 && (
+
+ {pastEvents.map(event => {
+ const eventDate = getEventStartDate(event);
+ const now = new Date();
+ const diffTime = now.getTime() - eventDate.getTime();
+ const daysAgo = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+
+ return (
+
+
+
+
+
+ {/* Date Badge */}
+
+ {eventDate.getDate()}
+ {eventDate.toLocaleString('default', { month: 'short' })}
+
+
+
+
{event.name}
+
+
+ {eventDate.toLocaleString()}
+
+
+
+
+ {daysAgo === 0 ? 'Today' : daysAgo === 1 ? 'Yesterday' : `${daysAgo} days ago`}
+
+
+
+
+
+
+
+ {event.has_attended ? (
+
+
+ Attended
+
+ ) : (
+
+ )}
+
+
+
+ {/* Attendees - Conditionally rendered */}
+ {eventAttendees[event.id] && eventAttendees[event.id].length > 0 && (
+
+
+
+ {eventAttendees[event.id].length} attendees
+
+
+ {eventAttendees[event.id].slice(0, 5).map(member => (
+
+ {member.name}
+
+ ))}
+ {eventAttendees[event.id].length > 5 && (
+
+ +{eventAttendees[event.id].length - 5} more
+
+ )}
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ {!loading && pastEvents.length === 0 && (
+
+
+ No past events
+
+ Once you attend events, they'll appear here
+
+
+ )}
+
+);
+
+// Members Tab Content
+const MembersSection: React.FC<{
+ loading: boolean;
+ members: Member[];
+ userName: string;
+}> = ({ loading, members, userName }) => (
+
+
+
+
Club Members
+
+
+ {loading && (
+
+ )}
+
+ {!loading && members.length > 0 && (
+
+
+
+ {members.length} member{members.length !== 1 ? 's' : ''} in this club
+
+
+
+ You are highlighted
+
+
+
+
+ {members.map(member => (
+
+
+
+
+
+
+
+ {member.name}
+
+ {member.name === userName ? (
+
+ This is you
+
+ ) : (
+
+ Member
+
+ )}
+
+
+
+ ))}
+
+
+ )}
+
+ {!loading && members.length === 0 && (
+
+
+ No members found
+
+ Members will appear here once they join
+
+
+ )}
+
+);
+
+// Attendance Tab Content
+const AttendanceSection: React.FC<{
+ loading: boolean;
+ records: Attendance[];
+ ownerPreviewMode?: boolean;
+}> = ({ loading, records, ownerPreviewMode }) => (
+
+
+
+
My Attendance
+
+ {ownerPreviewMode ? (
+
+
+
Attendance preview is not available for owners.
+
You are not a member of this club, so you have no attendance records.
+
+ ) : loading ? (
+
+ ) : records.length > 0 ? (
+
+ {/* Attendance Summary Card */}
+
+
+
+ Attendance Summary
+
+
+
+
Total Events Attended
+
{records.length}
+
+
+
First Attendance
+
+ {records.length > 0
+ ? new Date(records
+ .sort((a, b) => new Date(a.attended_at).getTime() - new Date(b.attended_at).getTime())[0]
+ .attended_at).toLocaleDateString()
+ : 'N/A'}
+
+
+
+
Latest Attendance
+
+ {records.length > 0
+ ? new Date(records
+ .sort((a, b) => new Date(b.attended_at).getTime() - new Date(a.attended_at).getTime())[0]
+ .attended_at).toLocaleDateString()
+ : 'N/A'}
+
+
+
+
+
+ {/* Attendance Table / Mobile Cards */}
+
+ {/* Desktop Table - Hidden on Mobile */}
+
+
+
+
+ | Event |
+ Event Date |
+ Check-in Time |
+
+
+
+ {records
+ .sort((a, b) => new Date(b.attended_at).getTime() - new Date(a.attended_at).getTime())
+ .map(record => (
+
+ | {record.event_name} |
+ {parseLocalDate(record.event_date).toLocaleDateString()} |
+ {new Date(record.attended_at).toLocaleString()} |
+
+ ))}
+
+
+
+
+ {/* Mobile Cards - Shown on Mobile Only */}
+
+ {records
+ .sort((a, b) => new Date(b.attended_at).getTime() - new Date(a.attended_at).getTime())
+ .map(record => (
+
+
{record.event_name}
+
+
+
+ {parseLocalDate(record.event_date).toLocaleDateString()}
+
+
+
+ {new Date(record.attended_at).toLocaleString()}
+
+
+
+ Attended
+
+
+
+
+ ))}
+
+
+
+ ) : (
+
+
+ No attendance records yet
+
+ Check in to events to start building your attendance history
+
+
+ )}
+
+);
+
+// Add cross-fade transition effect for club switching
+const crossFadeVariants = {
+ initial: {
+ opacity: 0,
+ filter: "blur(12px)",
+ scale: 0.98
+ },
+ animate: {
+ opacity: 1,
+ filter: "blur(0px)",
+ scale: 1,
+ transition: {
+ opacity: { duration: 0.4, ease: [0.2, 0, 0.2, 1] },
+ filter: { duration: 0.5, ease: [0.2, 0, 0.2, 1] },
+ scale: { duration: 0.45, ease: [0.2, 0, 0.2, 1] }
+ }
+ },
+ exit: {
+ opacity: 0,
+ filter: "blur(12px)",
+ scale: 0.98,
+ transition: {
+ opacity: { duration: 0.25, ease: [0.2, 0, 0.2, 1] },
+ filter: { duration: 0.3, ease: [0.2, 0, 0.2, 1] },
+ scale: { duration: 0.25, ease: [0.2, 0, 0.2, 1] }
+ }
+ }
+};
+
+interface Event {
+ id: string;
+ name: string;
+ event_date: string;
+ invite_code: string;
+ club_id: string;
+ club_name?: string;
+ club_owner?: string;
+ checkin_location_enabled?: boolean;
+ checkin_code_enabled?: boolean;
+ checkin_qr_enabled?: boolean;
+ checkin_only_during_event?: boolean;
+ event_start_time?: string | null;
+ event_end_time?: string | null;
+ has_attended?: boolean;
+}
+
+interface Club {
+ id: string;
+ name: string;
+ description?: string;
+ category?: string;
+ owner_name?: string;
+ owner_id?: string;
+ member_name?: string;
+ access_code?: string;
+}
+
+interface Member {
+ id: string;
+ name: string;
+ preapproved?: boolean;
+}
+
+// Helper to resolve the actual start time for an event
+const getEventStartDate = (event: Event): Date => {
+ if (event.event_start_time) {
+ // Handle both full timestamp strings and plain time strings
+ const direct = new Date(event.event_start_time);
+ if (!isNaN(direct.getTime())) return direct;
+
+ // If only a time was provided, combine with the event date
+ if (event.event_date) {
+ const combined = new Date(`${event.event_date}T${event.event_start_time}`);
+ if (!isNaN(combined.getTime())) return combined;
+ }
+ }
+ return parseLocalDate(event.event_date);
+};
+
+interface Attendance {
+ id: string;
+ event_name: string;
+ event_date: string;
+ attended_at: string;
+ member_name: string;
+}
+
+interface ClubContentWithFadeProps {
+ selectedClubId: string | null;
+ activeTab: 'events' | 'members' | 'attendance';
+ selectedClub: Club | null;
+ userClubs: Club[];
+ userName: string;
+ handleClubChange: (clubId: string) => void;
+ setActiveTab: React.Dispatch>;
+ loadingEvents: boolean;
+ upcomingEvents: Event[];
+ pastEvents: Event[];
+ eventAttendees: Record;
+ loadingMembers: boolean;
+ clubMembers: Member[];
+ loadingAttendance: boolean;
+ attendanceRecords: Attendance[];
+ ownerPreviewMode: boolean;
+}
+
+const ClubContentWithFade: React.FC = ({
+ selectedClubId,
+ activeTab,
+ selectedClub,
+ userClubs,
+ userName,
+ handleClubChange,
+ setActiveTab,
+ loadingEvents,
+ upcomingEvents,
+ pastEvents,
+ eventAttendees,
+ loadingMembers,
+ clubMembers,
+ loadingAttendance,
+ attendanceRecords,
+ ownerPreviewMode
+}) => {
+ // Determine if the current tab content is loading
+ const isCurrentTabLoading =
+ (activeTab === 'events' && loadingEvents) ||
+ (activeTab === 'members' && loadingMembers) ||
+ (activeTab === 'attendance' && loadingAttendance);
+
+ // Only key the outer container by selectedClubId, not by activeTab
+ return (
+
+
+ {/* Welcome header with student name - Updated with monochrome design */}
+
+
+
+
Welcome,
+
+ {userClubs.length === 1
+ ? `Ready to engage with your club?`
+ : `Select a club from the dropdown below.`}
+
+
+ {userClubs.length > 1 && (
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Club content with smooth transitions */}
+ {selectedClub && (
+
+ {/* Updated Tab Navigation - More seamless design */}
+
+ {[
+ { key: 'events', label: 'Events', icon: },
+ { key: 'members', label: 'Members', icon: },
+ { key: 'attendance', label: 'My Attendance', icon: }
+ ].map((tab) => (
+
+ ))}
+
+
+ {/* Tab content using AnimatePresence and subcomponents - Using key={activeTab} for targeted cross-fade */}
+
+
+ {/* Loading overlay */}
+ {isCurrentTabLoading && (
+
+ )}
+
+ {activeTab === 'events' && (
+
+ )}
+ {activeTab === 'members' && (
+
+ )}
+ {activeTab === 'attendance' && (
+
+ )}
+
+
+
+ )}
+
+
+ );
+};
+
+const Dashboard: React.FC = () => {
+ const navigate = useNavigate();
+ const { user, loading: authLoading } = useAuth();
+ const [profile, setProfile] = useState(null);
+
+ // Students can access the dashboard without signing in, so only redirect
+ // creators who attempt to use owner features.
+ useEffect(() => {
+ if (!authLoading && !user) {
+ // No automatic redirect; dashboard works in guest mode
+ return;
+ }
+ }, [authLoading, user]);
+
+ // Fetch user profile after auth is loaded and user is present
+ useEffect(() => {
+ if (!authLoading && user) {
+ supabase
+ .from('profiles')
+ .select('name, email, role')
+ .eq('id', user.id)
+ .single()
+ .then(({ data, error }) => {
+ console.log('Profile fetch result:', { data, error });
+ if (error || !data) {
+ setProfile(null);
+ } else {
+ setProfile(data);
+ }
+ });
+ } else if (!user) {
+ setProfile(null);
+ }
+ }, [authLoading, user]);
+
+ const [ownerPreviewMode, setOwnerPreviewMode] = useState(false);
+
+ // Fetch joined clubs from localStorage so the dashboard works for guests too
+ useEffect(() => {
+ if (authLoading) return;
+ setOwnerPreviewMode(false); // Reset preview mode when loading from localStorage
+ setLoadingClubs(true);
+ try {
+ const storedClubs = JSON.parse(localStorage.getItem('attendify_clubs') || '[]');
+ setUserClubs(storedClubs);
+ if (storedClubs.length > 0) {
+ setSelectedClubId(storedClubs[0].id);
+ setSelectedClub(storedClubs[0]);
+ setUserName(storedClubs[0].member_name || 'Member');
+ } else {
+ setUserName('Member');
+ }
+ } catch (error) {
+ setUserClubs([]);
+ setUserName('Member');
+ } finally {
+ setLoadingClubs(false);
+ }
+ }, [authLoading, user]);
+
+ const [activeTab, setActiveTab] = useState<'events' | 'members' | 'attendance'>('events');
+ const [selectedClubId, setSelectedClubId] = useState(null);
+ const [selectedClub, setSelectedClub] = useState(null);
+ const [userName, setUserName] = useState('');
+
+ const [upcomingEvents, setUpcomingEvents] = useState([]);
+ const [pastEvents, setPastEvents] = useState([]);
+ const [loadingEvents, setLoadingEvents] = useState(true);
+
+ const [userClubs, setUserClubs] = useState([]);
+ const [loadingClubs, setLoadingClubs] = useState(true);
+
+ const [clubMembers, setClubMembers] = useState([]);
+ const [eventAttendees, setEventAttendees] = useState>({});
+ const [loadingMembers, setLoadingMembers] = useState(true);
+
+ const [attendanceRecords, setAttendanceRecords] = useState([]);
+ const [loadingAttendance, setLoadingAttendance] = useState(true);
+
+ // Helper to check if we should show the creator view
+ const isAuthenticatedCreator = () => {
+ return !!user && profile?.role === 'owner';
+ };
+
+ // Helper function to safely get creator name text
+ const getCreatorNameText = () => {
+ if (!profile) return 'Member';
+ if (profile.role === 'owner') {
+ return profile.name || 'Creator';
+ }
+ return 'Member';
+ };
+
+ // Fetch club details ONLY when a valid club ID is selected
+ useEffect(() => {
+ const fetchClubDetails = async () => {
+ // Explicitly return if no valid club ID is selected
+ if (!selectedClubId) {
+ setSelectedClub(null); // Clear any previous club details
+ return;
+ }
+
+ try {
+ // Get complete club details
+ const { data: clubData, error: clubError } = await supabase
+ .from('clubs')
+ .select('id, name, description, category, access_code')
+ .eq('id', selectedClubId)
+ .single();
+
+ if (clubError) throw clubError;
+
+ if (clubData) {
+ // Determine the correct display name based on context
+ let displayUserName = userName; // Start with current userName
+ // No owner check by user_id, just use preview mode logic
+ if (ownerPreviewMode) {
+ displayUserName = profile?.name || 'Creator';
+ } else {
+ // If member is viewing a joined club, userName should reflect member_name for THAT club
+ // This requires finding the specific club in userClubs if it exists
+ const joinedClub = userClubs.find(c => c.id === selectedClubId);
+ displayUserName = joinedClub?.member_name || 'Member';
+ }
+ setUserName(displayUserName);
+ setSelectedClub({
+ ...clubData,
+ member_name: displayUserName
+ });
+ } else {
+ // If club data not found for the ID, clear selected club
+ setSelectedClub(null);
+ }
+ } catch (error) {
+ console.error('Error fetching club details:', error);
+ setSelectedClub(null); // Clear on error
+ }
+ };
+
+ fetchClubDetails();
+ // Dependencies: selectedClubId, userName (for potential default setting), profile?.role (for creator checks), user?.id (for creator checks), userClubs (for member name lookup)
+ }, [selectedClubId, profile?.role, user?.id, userClubs]);
+
+ // Key fix: Reset event data when the club changes
+ useEffect(() => {
+ if (selectedClubId) {
+ // Reset event data when selected club changes
+ setUpcomingEvents([]);
+ setPastEvents([]);
+ setLoadingEvents(true);
+ }
+ }, [selectedClubId]);
+
+ // Now revise the effect that fetches club events to ensure it properly filters by the selected club
+ useEffect(() => {
+ const fetchEvents = async () => {
+ // *** CRITICAL FIX: Only fetch if a valid club ID is selected ***
+ if (!selectedClubId) {
+ setUpcomingEvents([]); // Ensure event lists are empty if no club is selected
+ setPastEvents([]);
+ setEventAttendees({});
+ setLoadingEvents(false); // Set loading to false as there's nothing to load
+ return;
+ }
+
+ setLoadingEvents(true); // Set loading true ONLY if we proceed to fetch
+
+ try {
+ const today = new Date();
+ const todayISO = today.toISOString();
+
+ // IMPORTANT: Add explicit filtering by club_id
+ const { data: upcomingData, error: upcomingError } = await supabase
+ .from('events')
+ .select('id, name, event_date, invite_code, club_id, checkin_location_enabled, checkin_code_enabled, checkin_qr_enabled, checkin_only_during_event, event_start_time, event_end_time')
+ .eq('club_id', selectedClubId) // Explicitly filter by selected club
+ .gte('event_date', todayISO)
+ .order('event_date', { ascending: true });
+
+ if (upcomingError) throw upcomingError;
+
+ // IMPORTANT: Add explicit filtering by club_id
+ const { data: pastData, error: pastError } = await supabase
+ .from('events')
+ .select('id, name, event_date, invite_code, club_id, checkin_location_enabled, checkin_code_enabled, checkin_qr_enabled, checkin_only_during_event, event_start_time, event_end_time')
+ .eq('club_id', selectedClubId) // Explicitly filter by selected club
+ .lt('event_date', todayISO)
+ .order('event_date', { ascending: false });
+
+ if (pastError) throw pastError;
+
+ // Determine if we need to check attendance (is the user a member or creator previewing?)
+ let memberIdForAttendanceCheck: string | null = null;
+ const memberUuid = localStorage.getItem('attendify_member_id');
+
+ if (profile?.role !== 'owner' && memberUuid) {
+ // Regular member: Find their membership ID for this specific club
+ const { data: memberData } = await supabase
+ .from('members')
+ .select('id')
+ .eq('club_id', selectedClubId)
+ .eq('member_uuid', memberUuid)
+ .single();
+ if (memberData) {
+ memberIdForAttendanceCheck = memberData.id;
+ }
+ } else if (profile?.role === 'owner' && user?.id === selectedClub?.owner_id) {
+ // Creator viewing their OWN club: Check attendance using their *creator* ID if they are also a member
+ // This requires checking if the owner is *also* listed in the members table for this club
+ const { data: creatorMemberData } = await supabase
+ .from('members')
+ .select('id')
+ .eq('club_id', selectedClubId)
+ .eq('user_id', user.id) // Check if owner's user_id exists in members
+ .maybeSingle(); // Use maybeSingle as owner might not be a member
+
+ if (creatorMemberData) {
+ memberIdForAttendanceCheck = creatorMemberData.id;
+ }
+ }
+
+ let attendedIds: string[] = [];
+ if (memberIdForAttendanceCheck) {
+ const { data: attendanceData } = await supabase
+ .from('attendance')
+ .select('event_id')
+ .eq('member_id', memberIdForAttendanceCheck);
+
+ attendedIds = attendanceData?.map(a => a.event_id) || [];
+ }
+
+ // Process events and mark attendance
+ const enhanceEvent = (event: any) => ({
+ ...event,
+ club_name: selectedClub?.name,
+ club_owner: selectedClub?.owner_name,
+ has_attended: attendedIds.includes(event.id)
+ });
+
+ setUpcomingEvents(upcomingData?.map(enhanceEvent) || []);
+ setPastEvents(pastData?.map(enhanceEvent) || []);
+
+ // Fetch attendees for each event (remains the same)
+ const allEvents = [...(upcomingData || []), ...(pastData || [])];
+ const attendeesByEvent: Record = {};
+
+ for (const event of allEvents) {
+ const { data: attendanceData } = await supabase
+ .from('attendance')
+ .select('member_id')
+ .eq('event_id', event.id);
+
+ if (attendanceData && attendanceData.length > 0) {
+ const memberIds = attendanceData.map(a => a.member_id);
+
+ const { data: membersData } = await supabase
+ .from('members')
+ .select('id, name')
+ .in('id', memberIds);
+
+ if (membersData) {
+ attendeesByEvent[event.id] = membersData;
+ }
+ } else {
+ attendeesByEvent[event.id] = [];
+ }
+ }
+
+ setEventAttendees(attendeesByEvent);
+ } catch (error) {
+ console.error('Error fetching events:', error);
+ setUpcomingEvents([]); // Clear on error
+ setPastEvents([]);
+ setEventAttendees({});
+ } finally {
+ setLoadingEvents(false); // Ensure loading is set to false
+ }
+ };
+
+ fetchEvents();
+ // Refresh events when selectedClubId changes OR when selectedClub details (like owner_id) become available
+ }, [selectedClubId, selectedClub?.owner_id, profile?.role, user?.id]);
+
+ // Fetch members when tab is active
+ useEffect(() => {
+ const fetchMembers = async () => {
+ if (!selectedClubId || activeTab !== 'members') return;
+
+ setLoadingMembers(true);
+
+ try {
+ const { data, error } = await supabase
+ .from('members')
+ .select('id, name, preapproved')
+ .eq('club_id', selectedClubId)
+ .order('name');
+
+ if (error) throw error;
+
+ setClubMembers(data || []);
+ } catch (error) {
+ console.error('Error fetching members:', error);
+ } finally {
+ setLoadingMembers(false);
+ }
+ };
+
+ fetchMembers();
+ }, [selectedClubId, activeTab]);
+
+ // Fetch attendance records when tab is active
+ useEffect(() => {
+ const fetchAttendance = async () => {
+ if (!selectedClubId || activeTab !== 'attendance') return;
+ if (ownerPreviewMode) {
+ setAttendanceRecords([]);
+ setLoadingAttendance(false);
+ return;
+ }
+ setLoadingAttendance(true);
+ try {
+ // Get all events for this club
+ const { data: eventsData, error: eventsError } = await supabase
+ .from('events')
+ .select('id, name, event_date')
+ .eq('club_id', selectedClubId);
+
+ if (eventsError) throw eventsError;
+
+ if (!eventsData || eventsData.length === 0) {
+ setAttendanceRecords([]);
+ setLoadingAttendance(false);
+ return;
+ }
+
+ // Get member UUID from localStorage
+ const memberUuid = localStorage.getItem('attendify_member_id');
+
+ if (!memberUuid) {
+ setAttendanceRecords([]);
+ setLoadingAttendance(false);
+ return;
+ }
+
+ // Get member's ID for this club
+ const { data: memberData, error: memberError } = await supabase
+ .from('members')
+ .select('id')
+ .eq('club_id', selectedClubId)
+ .eq('member_uuid', memberUuid)
+ .single();
+
+ if (memberError && memberError.code !== 'PGRST116') throw memberError;
+
+ if (!memberData) {
+ setAttendanceRecords([]);
+ setLoadingAttendance(false);
+ return;
+ }
+
+ // Get attendance records for this member
+ const { data: attendanceData, error: attendanceError } = await supabase
+ .from('attendance')
+ .select('id, event_id, attended_at')
+ .eq('member_id', memberData.id);
+
+ if (attendanceError) throw attendanceError;
+
+ if (!attendanceData || attendanceData.length === 0) {
+ setAttendanceRecords([]);
+ setLoadingAttendance(false);
+ return;
+ }
+
+ // Map events to attendance records
+ const records = attendanceData.map(record => {
+ const event = eventsData.find(e => e.id === record.event_id);
+
+ return {
+ id: record.id,
+ event_name: event?.name || 'Unknown Event',
+ event_date: event?.event_date || '',
+ attended_at: record.attended_at,
+ member_name: userName
+ };
+ });
+
+ setAttendanceRecords(records);
+ } catch (error) {
+ console.error('Error fetching attendance:', error);
+ } finally {
+ setLoadingAttendance(false);
+ }
+ };
+ fetchAttendance();
+ }, [selectedClubId, activeTab, userName, ownerPreviewMode]);
+
+ // Handle club selection
+ const handleClubChange = (clubId: string) => {
+ setSelectedClubId(clubId);
+
+ // Load new club data in the background
+ const club = userClubs.find(c => c.id === clubId);
+ if (club) {
+ setSelectedClub(club);
+ // Set userName based on role
+ if (profile?.role === 'owner') {
+ setUserName(profile?.name || 'Creator'); // Use creator's actual name
+ } else {
+ setUserName(club.member_name || 'Member'); // Use member name from joined club data
+ }
+
+ // Set loading states for the appropriate tab
+ if (activeTab === 'members') {
+ setLoadingMembers(true);
+ } else if (activeTab === 'attendance') {
+ setLoadingAttendance(true);
+ }
+
+ // Always load events for the selected club
+ setLoadingEvents(true);
+ }
+ };
+
+ // Function for owners to load their owned clubs into the student view
+ const loadOwnedClubsAsStudent = async () => {
+ setOwnerPreviewMode(true);
+ console.log('loadOwnedClubsAsStudent called', { user, profile });
+ if (!isAuthenticatedCreator() || !user?.id) {
+ console.log('loadOwnedClubsAsStudent early return', { isAuthenticatedCreator: isAuthenticatedCreator(), userId: user?.id });
+ return;
+ }
+ setLoadingClubs(true);
+ try {
+ // Fetch clubs where the user is the owner (from club_owners table)
+ const { data: ownerRows, error: ownerError } = await supabase
+ .from('club_owners')
+ .select('club_id')
+ .eq('user_id', user.id);
+ if (ownerError) throw ownerError;
+ const clubIds = (ownerRows || []).map((row: any) => row.club_id).filter(Boolean);
+ console.log('clubIds', clubIds); // TEMP DEBUG
+ console.log('typeof clubIds[0]', typeof clubIds[0]);
+ console.log('Array.isArray(clubIds)', Array.isArray(clubIds));
+ if (!clubIds.length) {
+ setUserClubs([]);
+ setSelectedClubId(null);
+ setSelectedClub(null);
+ setUserName(profile?.name || 'Creator');
+ setLoadingClubs(false);
+ return;
+ }
+ const { data: clubsData, error: clubsError } = await supabase
+ .from('clubs')
+ .select('id, name, description, category, access_code')
+ .in('id', clubIds);
+ console.log('clubsData', clubsData, 'clubsError', clubsError);
+ if (clubsError) throw clubsError;
+ setUserClubs(clubsData || []);
+ if (clubsData && clubsData.length > 0) {
+ setSelectedClubId(clubsData[0].id);
+ setSelectedClub(clubsData[0]);
+ setUserName(profile?.name || 'Creator');
+ } else {
+ setSelectedClubId(null);
+ setSelectedClub(null);
+ setUserName(profile?.name || 'Creator');
+ }
+ } catch (error) {
+ setUserClubs([]);
+ setSelectedClubId(null);
+ setSelectedClub(null);
+ setUserName(profile?.name || 'Creator');
+ } finally {
+ setLoadingClubs(false);
+ }
+ };
+
+ return (
+
+ {/* Updated Header */}
+
+
+
+
+ {profile?.role === 'owner' && (
+
+
+
Return to Clubs
+
+ )}
+
+
+
Home
+
+
+
+
Join Club
+
+
+
+
+
+
+
+ {/* LOADING STATE: Show spinner and debug info */}
+ {authLoading ? (
+
+
+
Loading authentication...
+
+ ) : loadingClubs ? (
+
+
+
Loading dashboard state...
+
+ ) :
+ /* NO CLUBS VIEW: Show when loadingClubs is false and userClubs is empty */
+ userClubs.length === 0 ? (
+
+
+
+ {isAuthenticatedCreator() ? `Hi ${getCreatorNameText()}, you haven't joined any clubs yet!` : 'Welcome to Attendify'}
+
+ {isAuthenticatedCreator() ? (
+
+ Join a club as a student to see this view populated, any club you join will treat you like a student, or preview how students will see your owned clubs.
+
+ ) : (
+
+ You're not a member of any clubs yet. Join a club to track your attendance and connect with other members.
+
+ )}
+
+
navigate('/join')}
+ className="w-full sm:w-auto px-5 py-2 bg-black text-white font-medium rounded-lg hover:bg-gray-900 transition-all flex items-center justify-center gap-2"
+ whileHover={{ scale: 1.03 }}
+ whileTap={{ scale: 0.97 }}
+ >
+
+ Join a Club
+
+ {isAuthenticatedCreator() && (
+
+
+ View Your Clubs as Student
+
+ )}
+
+
+ ) : (
+ <>
+ {/* Welcome header with student name - Updated with monochrome design */}
+
+ >
+ )}
+
+
+
+ {/* Footer */}
+
+
+ );
+};
+
+export default Dashboard;
\ No newline at end of file
diff --git a/src/pages/Entry.tsx b/src/pages/Entry.tsx
new file mode 100644
index 0000000..a40ccb4
--- /dev/null
+++ b/src/pages/Entry.tsx
@@ -0,0 +1,32 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '../contexts/AuthContext';
+
+const Entry: React.FC = () => {
+ const { user, loading } = useAuth();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (loading) return;
+ const stored = localStorage.getItem('attendify_clubs');
+ const clubs = stored ? JSON.parse(stored) : [];
+
+ if (!user) {
+ if (clubs.length > 0) {
+ navigate('/dashboard', { replace: true });
+ } else {
+ navigate('/welcome', { replace: true });
+ }
+ } else {
+ if (clubs.length > 0 && !localStorage.getItem('owner_confirmed')) {
+ navigate('/role-confirm', { replace: true });
+ } else {
+ navigate('/clubs', { replace: true });
+ }
+ }
+ }, [user, loading, navigate]);
+
+ return null;
+};
+
+export default Entry;
diff --git a/src/pages/EventCheckinPage.tsx b/src/pages/EventCheckinPage.tsx
new file mode 100644
index 0000000..0ae450c
--- /dev/null
+++ b/src/pages/EventCheckinPage.tsx
@@ -0,0 +1,1104 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { supabase } from '../utils/supabaseClient';
+import Logo from '../components/Logo';
+import { v4 as uuidv4 } from 'uuid';
+import { calculateDistance } from '../utils/geolocation'; // Import helpers
+import { motion, AnimatePresence } from 'framer-motion';
+import { parseLocalDate } from '../lib/utils';
+import { getCloseMatches } from '../utils/nameMatcher';
+
+// Additional imports for map
+import { MapPin } from 'lucide-react';
+
+interface EventInfo {
+ id: string; // Need event ID for attendance
+ name: string;
+ event_date: string;
+ event_start_time?: string | null;
+ event_end_time?: string | null;
+ club_id: string;
+ club_name?: string;
+ checkin_location_enabled?: boolean;
+ checkin_code_enabled?: boolean;
+ checkin_qr_enabled?: boolean;
+ checkin_only_during_event?: boolean;
+ location_lat?: number | null;
+ location_lng?: number | null;
+ location_radius_meters?: number | null;
+}
+
+interface Club {
+ id: string;
+ name: string;
+ member_name: string;
+}
+
+type LocationPermissionStatus = 'prompt' | 'checking' | 'granted' | 'denied';
+
+// Shared transition for content (blur lingers longer than fade)
+const TAB_TRANSITION = {
+ opacity: { duration: 0.16, ease: [0.4, 0, 0.2, 1] },
+ filter: { duration: 0.28, ease: [0.4, 0, 0.2, 1] }
+};
+
+const tabVariants = {
+ hidden: {
+ opacity: 0,
+ filter: 'blur(16px)',
+ scale: 0.97,
+ y: -20
+ },
+ visible: {
+ opacity: 1,
+ filter: 'blur(0px)',
+ scale: 1,
+ y: 0,
+ transition: {
+ ...TAB_TRANSITION,
+ type: 'spring',
+ damping: 25,
+ stiffness: 300
+ }
+ },
+ exit: {
+ opacity: 0,
+ filter: 'blur(16px)',
+ scale: 0.97,
+ y: -20,
+ transition: {
+ ...TAB_TRANSITION,
+ duration: 0.2
+ }
+ }
+};
+
+function parseEventDateTime(dateString: string, timeString?: string | null): Date {
+ let date = parseLocalDate(dateString);
+
+ if (timeString) {
+ if (timeString.includes('T') || timeString.includes(' ')) {
+ date = new Date(timeString.replace(' ', 'T'));
+ } else {
+ const [h, m] = timeString.split(':').map(Number);
+ date.setHours(h || 0, m || 0, 0, 0);
+ }
+ }
+
+ return date;
+}
+
+// Format time remaining helper function
+const formatTimeRemaining = (dateString: string, timeString?: string | null): string => {
+ const target = parseEventDateTime(dateString, timeString);
+ const now = new Date();
+ const diffMs = target.getTime() - now.getTime();
+
+ // If in the past, return empty string
+ if (diffMs < 0) return '';
+
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+ const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
+ const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
+
+ if (diffDays > 0) {
+ return `${diffDays} day${diffDays > 1 ? 's' : ''} ${diffHours} hour${diffHours > 1 ? 's' : ''}`;
+ } else if (diffHours > 0) {
+ return `${diffHours} hour${diffHours > 1 ? 's' : ''} ${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}`;
+ } else {
+ return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}`;
+ }
+};
+
+const EventCheckinPage: React.FC = () => {
+ const { inviteCode } = useParams<{ inviteCode: string }>();
+ const navigate = useNavigate();
+ const [eventInfo, setEventInfo] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [memberName, setMemberName] = useState('');
+ const [checkinLoading, setCheckinLoading] = useState(false);
+ const [checkinError, setCheckinError] = useState(null);
+ const [, setCheckinSuccess] = useState(false);
+
+ // Location state
+ const [locationPermissionStatus, setLocationPermissionStatus] = useState('prompt');
+ const [locationError, setLocationError] = useState(null); // Specific error for location part
+ const [isVerifyingLocation, setIsVerifyingLocation] = useState(false);
+
+ // New states for enhanced functionalities
+ const [step, setStep] = useState(inviteCode ? 2 : 1); // Start at step 2 if inviteCode is provided
+ const [userClubs, setUserClubs] = useState([]);
+ const [availableEvents, setAvailableEvents] = useState([]);
+ const [selectedClubId, setSelectedClubId] = useState(null);
+ const [showClubDropdown, setShowClubDropdown] = useState(false);
+ const [codeInput, setCodeInput] = useState('');
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [suggestedNames, setSuggestedNames] = useState([]);
+ const [clubMemberNames, setClubMemberNames] = useState([]);
+ const [view, setView] = useState<'events' | 'code'>('events'); // Default to events view
+ const [isChangingName, setIsChangingName] = useState(false); // Flag to show name change UI
+
+ // Fetch member_uuid from localStorage
+ const [memberUuid, setMemberUuid] = useState(null);
+
+ // Add these new state variables to track user location
+ const [userLat, setUserLat] = useState(null);
+ const [userLng, setUserLng] = useState(null);
+
+ // Determine if check-in should be disabled due to time restrictions
+ const eventNotStarted = !!(
+ eventInfo?.checkin_only_during_event &&
+ eventInfo.event_date &&
+ new Date() < parseEventDateTime(eventInfo.event_date, eventInfo.event_start_time)
+ );
+
+ useEffect(() => {
+ const storedMemberId = localStorage.getItem('attendify_member_id');
+ setMemberUuid(storedMemberId);
+
+ // Load club memberships from localStorage
+ const storedClubs = JSON.parse(localStorage.getItem('attendify_clubs') || '[]') as Club[];
+ setUserClubs(storedClubs);
+
+ // If clubs exist, automatically select the first one and set the member name
+ if (storedClubs.length > 0) {
+ // Set member name from the first club (they should all have the same name)
+ setMemberName(storedClubs[0].member_name);
+
+ // If only one club, automatically select it
+ if (storedClubs.length === 1) {
+ setSelectedClubId(storedClubs[0].id);
+ }
+
+ // If multiple clubs are found, show club dropdown in first step
+ setShowClubDropdown(storedClubs.length > 1);
+ }
+ }, []);
+
+ // Load event information if inviteCode is provided
+ useEffect(() => {
+ if (inviteCode) {
+ fetchEventByInviteCode(inviteCode);
+ } else {
+ // If no inviteCode, we're on the /attend route
+ // Only fetch available events if a club is selected
+ if (selectedClubId) {
+ fetchAvailableEvents(selectedClubId);
+ }
+ setLoading(false);
+ }
+ }, [inviteCode, selectedClubId]);
+
+ const fetchEventByInviteCode = async (code: string) => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const { data, error: fetchError } = await supabase
+ .from('events')
+ .select('id, name, event_date, event_start_time, event_end_time, club_id, clubs ( name ), checkin_location_enabled, checkin_code_enabled, checkin_qr_enabled, checkin_only_during_event, location_lat, location_lng, location_radius_meters')
+ .eq('invite_code', code)
+ .single();
+
+ if (fetchError || !data) {
+ setError('Could not load event information. Please check the code or link.');
+ setEventInfo(null);
+ setStep(1); // Go back to step 1 if there's an error
+ } else {
+ // Flatten the result and safely access club name
+ let clubName = 'Unknown Club';
+ if (data.clubs && typeof data.clubs === 'object' && 'name' in data.clubs) {
+ clubName = (data.clubs as { name: string }).name;
+ }
+
+ const flatData: EventInfo = {
+ id: data.id,
+ name: data.name,
+ event_date: data.event_date,
+ event_start_time: data.event_start_time,
+ event_end_time: data.event_end_time,
+ club_id: data.club_id,
+ club_name: clubName,
+ checkin_location_enabled: data.checkin_location_enabled,
+ checkin_code_enabled: data.checkin_code_enabled,
+ checkin_qr_enabled: data.checkin_qr_enabled,
+ checkin_only_during_event: data.checkin_only_during_event,
+ location_lat: data.location_lat,
+ location_lng: data.location_lng,
+ location_radius_meters: data.location_radius_meters
+ };
+ setEventInfo(flatData);
+ await fetchClubMembers(flatData.club_id);
+
+ // Auto-fill member name if this club matches one of the user's saved clubs
+ const matchingClub = userClubs.find(club => club.id === flatData.club_id);
+ if (matchingClub) {
+ setMemberName(matchingClub.member_name);
+ }
+
+ // Check for location requirements
+ if (flatData.checkin_location_enabled) {
+ requestLocationAndCheck(flatData);
+ } else {
+ setLocationPermissionStatus('granted');
+ }
+ }
+ } catch (error: any) {
+ console.error('Error fetching event info:', error);
+ setError(`Failed to load event: ${error.message || 'Please try again.'}`);
+ setStep(1); // Go back to step 1 if there's an error
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchAvailableEvents = async (clubId: string) => {
+ setLoading(true);
+
+ try {
+ const today = new Date();
+ const nextWeek = new Date();
+ nextWeek.setDate(today.getDate() + 7);
+
+ const { data, error } = await supabase
+ .from('events')
+ .select(`
+ id, name, event_date, event_start_time, event_end_time, invite_code, club_id,
+ checkin_location_enabled, checkin_qr_enabled, checkin_code_enabled,
+ checkin_only_during_event, location_lat, location_lng, location_radius_meters
+ `)
+ .eq('club_id', clubId)
+ .gte('event_date', today.toISOString())
+ .lte('event_date', nextWeek.toISOString())
+ .order('event_date', { ascending: true });
+
+ if (error) {
+ console.error('Error fetching events:', error);
+ } else {
+ // Get the club name
+ const { data: clubData } = await supabase
+ .from('clubs')
+ .select('name')
+ .eq('id', clubId)
+ .single();
+
+ const clubName = clubData?.name || 'Unknown Club';
+
+ // Add club_name to each event
+ const eventsWithClub = data.map(event => ({
+ ...event,
+ club_name: clubName
+ }));
+
+ setAvailableEvents(eventsWithClub);
+ }
+ } catch (error) {
+ console.error('Error fetching events:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Function to request and verify location
+ const requestLocationAndCheck = async (event: EventInfo | null = null) => {
+ const eventToCheck = event || eventInfo;
+
+ if (!eventToCheck || !eventToCheck.checkin_location_enabled) {
+ setLocationPermissionStatus('granted');
+ return;
+ }
+
+ if (!navigator.geolocation) {
+ setLocationError('Geolocation is not supported by your browser.');
+ setLocationPermissionStatus('denied');
+ return;
+ }
+
+ if (!eventToCheck.location_lat || !eventToCheck.location_lng || !eventToCheck.location_radius_meters) {
+ setLocationError('Event location data is missing. Cannot verify position.');
+ setLocationPermissionStatus('denied');
+ return;
+ }
+
+ setIsVerifyingLocation(true);
+ setLocationError(null);
+ setLocationPermissionStatus('checking');
+
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ const userLatitude = position.coords.latitude;
+ const userLongitude = position.coords.longitude;
+
+ // Store the user coordinates in state
+ setUserLat(userLatitude);
+ setUserLng(userLongitude);
+
+ const distance = calculateDistance(
+ eventToCheck.location_lat!,
+ eventToCheck.location_lng!,
+ userLatitude,
+ userLongitude
+ );
+
+ if (distance <= eventToCheck.location_radius_meters!) {
+ setLocationPermissionStatus('granted');
+ setLocationError(null);
+ } else {
+ setLocationPermissionStatus('denied');
+ setLocationError(`You must be within ${eventToCheck.location_radius_meters} meters of the event location. You are approximately ${Math.round(distance)} meters away.`);
+ }
+ setIsVerifyingLocation(false);
+ },
+ (error) => {
+ console.error("Geolocation error:", error);
+ setLocationPermissionStatus('denied');
+ switch (error.code) {
+ case error.PERMISSION_DENIED:
+ setLocationError('Location permission denied. Please grant permission in your browser settings and try again.');
+ break;
+ case error.POSITION_UNAVAILABLE:
+ setLocationError('Location information is unavailable.');
+ break;
+ case error.TIMEOUT:
+ setLocationError('The request to get user location timed out.');
+ break;
+ default:
+ setLocationError('An unknown error occurred while getting location.');
+ break;
+ }
+ setIsVerifyingLocation(false);
+ },
+ { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
+ );
+ };
+
+ const handleNameInput = (inputValue: string) => {
+ setMemberName(inputValue);
+
+ if (inputValue.trim().length > 0) {
+ const matches = getCloseMatches(inputValue, clubMemberNames);
+ setSuggestedNames(matches);
+ setShowSuggestions(matches.length > 0);
+ } else {
+ setSuggestedNames([]);
+ setShowSuggestions(false);
+ }
+ };
+
+ const selectSuggestedName = (name: string) => {
+ setMemberName(name);
+ setShowSuggestions(false);
+ };
+
+ const fetchClubMembers = async (clubId: string) => {
+ try {
+ const { data, error } = await supabase
+ .from('members')
+ .select('name')
+ .eq('club_id', clubId);
+ if (!error && data) {
+ setClubMemberNames(data.map(m => m.name));
+ }
+ } catch (err) {
+ console.error('Error fetching club members:', err);
+ }
+ };
+
+ const verifyEventCode = async () => {
+ if (!codeInput.trim()) {
+ setError('Please enter an event code.');
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ await fetchEventByInviteCode(codeInput);
+ setStep(2);
+ } catch (error: any) {
+ console.error('Error verifying event code:', error);
+ setError(`Failed to verify code: ${error.message || 'Please try again.'}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const selectEvent = (event: EventInfo) => {
+ setEventInfo(event);
+
+ fetchClubMembers(event.club_id);
+
+ // Check for location requirements
+ if (event.checkin_location_enabled) {
+ requestLocationAndCheck(event);
+ } else {
+ setLocationPermissionStatus('granted');
+ }
+
+ setStep(2);
+ };
+
+ const handleCheckin = async () => {
+ if (!eventInfo || !memberName.trim()) {
+ setCheckinError('Please enter your name.');
+ return;
+ }
+
+ setCheckinLoading(true);
+ setCheckinError(null);
+
+ try {
+ // Location check
+ if (eventInfo.checkin_location_enabled) {
+ if (isVerifyingLocation) {
+ setCheckinError('Please wait while your location is being verified.');
+ setCheckinLoading(false);
+ return;
+ }
+ if (locationPermissionStatus !== 'granted') {
+ if (!locationError) {
+ setCheckinError('Location not verified. Please grant permission or click retry.');
+ setCheckinLoading(false);
+ return;
+ } else {
+ setCheckinError(locationError);
+ setCheckinLoading(false);
+ return;
+ }
+ }
+ }
+
+ // Time window restriction
+ if (eventInfo.checkin_only_during_event && eventInfo.event_date) {
+ const now = new Date();
+ const start = parseEventDateTime(eventInfo.event_date, eventInfo.event_start_time);
+ if (now < start) {
+ setCheckinError('Check-in is not allowed before the event starts.');
+ setCheckinLoading(false);
+ return;
+ }
+ }
+
+ // Find or create member
+ let memberId: number | null = null;
+ let finalMemberUuid = memberUuid || uuidv4();
+
+ // Try finding member by UUID first
+ if (memberUuid) {
+ const { data: memberByUuid } = await supabase
+ .from('members')
+ .select('id')
+ .eq('club_id', eventInfo.club_id)
+ .eq('member_uuid', memberUuid)
+ .single();
+
+ if (memberByUuid) {
+ memberId = memberByUuid.id;
+ }
+ }
+
+ // If not found by UUID, try finding by name
+ if (!memberId) {
+ const { data: memberByName, error: nameError } = await supabase
+ .from('members')
+ .select('id, member_uuid')
+ .eq('club_id', eventInfo.club_id)
+ .eq('name', memberName.trim())
+ .single();
+
+ if (nameError && nameError.code !== 'PGRST116') {
+ throw nameError;
+ }
+
+ if (memberByName) {
+ memberId = memberByName.id;
+ finalMemberUuid = memberByName.member_uuid || finalMemberUuid;
+
+ if (!memberByName.member_uuid || (memberUuid && memberUuid !== memberByName.member_uuid)) {
+ await supabase.from('members').update({ member_uuid: finalMemberUuid }).eq('id', memberId);
+ localStorage.setItem('attendify_member_id', finalMemberUuid);
+ setMemberUuid(finalMemberUuid);
+ }
+ } else {
+ setCheckinError(`Member '${memberName.trim()}' not found for ${eventInfo.club_name || 'this club'}. Please join the club first.`);
+ setCheckinLoading(false);
+ return;
+ }
+ }
+
+ if (!memberId) {
+ throw new Error('Failed to identify member.');
+ }
+
+ const eventId = eventInfo.id;
+
+ // Check for existing attendance
+ const { data: existingAttendance, error: attendanceCheckError } = await supabase
+ .from('attendance')
+ .select('id')
+ .eq('event_id', eventId)
+ .eq('member_id', memberId)
+ .maybeSingle();
+
+ if (attendanceCheckError) {
+ throw attendanceCheckError;
+ }
+
+ if (existingAttendance) {
+ setCheckinSuccess(true);
+ } else {
+ // Add attendance record
+ const { error: insertAttendanceError } = await supabase
+ .from('attendance')
+ .insert([{
+ event_id: eventId,
+ member_id: memberId
+ }]);
+
+ if (insertAttendanceError) {
+ throw insertAttendanceError;
+ }
+ setCheckinSuccess(true);
+ }
+
+ setStep(3);
+
+ } catch (error: any) {
+ console.error('Error during check-in:', error);
+ setCheckinError(`Check-in failed: ${error.message || 'Please try again.'}`);
+ } finally {
+ setCheckinLoading(false);
+ }
+ };
+
+
+ const formatDate = (dateString: string, timeString?: string | null) => {
+ if (!dateString) return '';
+ const date = parseEventDateTime(dateString, timeString);
+ return date.toLocaleDateString('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ };
+
+ const formatRelativeTime = (dateString: string, timeString?: string | null) => {
+ if (!dateString) return '';
+
+ const date = parseEventDateTime(dateString, timeString);
+ const now = new Date();
+ const diffMs = date.getTime() - now.getTime();
+ const diffHours = diffMs / (1000 * 60 * 60);
+
+ if (diffHours < 0) return 'Past';
+ if (diffHours < 1) return 'Soon';
+ if (diffHours < 24) return 'Today';
+ if (diffHours < 48) return 'Tomorrow';
+ return `In ${Math.floor(diffHours / 24)} days`;
+ };
+
+ // Create a helper function to generate the map URL
+ const getMapUrl = (eventLat: number, eventLng: number, userLat: number | null, userLng: number | null) => {
+ // If we have both coordinates, create a map with both markers and appropriate zoom
+ if (userLat !== null && userLng !== null) {
+ // Show both markers
+ return `https://maps.google.com/maps?q=${eventLat},${eventLng}&markers=color:red|${eventLat},${eventLng}&markers=color:blue|${userLat},${userLng}&z=13&output=embed`;
+ }
+
+ // Default to just showing the event location
+ return `https://maps.google.com/maps?q=${eventLat},${eventLng}&z=15&output=embed`;
+ };
+
+ return (
+
+ {/* Mobile centered logo (hidden on larger screens) */}
+
+
+
+
+ {/* Desktop positioned logo (hidden on mobile) */}
+
+
+
+
+
+ Powered by Attendify
+
+
+
+ {loading && !inviteCode ? (
+
+ Loading...
+
+ ) : error && !inviteCode ? (
+
+ {error}
+
+
+ ) : (
+
+ {step === 1 && (
+
+ Check In
+
+ {memberName && (
+
+ Checking in as:
+ {memberName}
+
+
+ )}
+
+ {isChangingName ? (
+
+
Enter your name
+
+
handleNameInput(e.target.value)}
+ placeholder="Your full name"
+ className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-black focus:border-black bg-white"
+ />
+ {showSuggestions && suggestedNames.length > 0 && (
+
+ {suggestedNames.map((name, index) => (
+
selectSuggestedName(name)}
+ >
+ {name}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ ) : (
+ Enter an event code or select from upcoming events
+ )}
+
+ {userClubs.length === 0 ? (
+
+
You haven't joined any clubs yet. Join a club first to check in to events.
+
+
+
+ ) : (
+ <>
+ {showClubDropdown && (
+
+
+
+
+
+
+
+ )}
+
+ {selectedClubId && (
+ <>
+
+
+
+
+
+ {view === 'events' ? (
+
+ {availableEvents.length > 0 ? (
+ availableEvents.map(event => (
+
+ ))
+ ) : (
+
+
No upcoming events found
+
Try entering an event code instead
+
+ )}
+
+ ) : (
+
+ setCodeInput(e.target.value)}
+ placeholder="Enter event code"
+ className="w-full px-3 py-3 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-black focus:border-black bg-white"
+ />
+
+
+ )}
+ >
+ )}
+
+
+ >
+ )}
+
+ )}
+
+ {step === 2 && eventInfo && (
+
+ Checking in for:
+
+ {eventInfo.name}
+
+ {eventInfo.club_name}
+
+ {formatDate(eventInfo.event_date, eventInfo.event_start_time)}
+
+
+ {/* User name display with option to change */}
+
+ Checking in as:
+ {memberName}
+
+
+
+ {/* Time restriction notice */}
+ {eventInfo.checkin_only_during_event && eventInfo.event_date && (() => {
+ const now = new Date();
+ const start = parseEventDateTime(eventInfo.event_date, eventInfo.event_start_time);
+ if (now < start) {
+ const timeRemaining = formatTimeRemaining(eventInfo.event_date, eventInfo.event_start_time);
+ return (
+
+
+
+
+
Event hasn't started yet
+
Check-in will be available when the event begins
+ {timeRemaining && (
+
Time until event: {timeRemaining}
+ )}
+
+
+
+ );
+ }
+ return null;
+ })()}
+
+ {/* Location restriction notice */}
+ {eventInfo.checkin_location_enabled && eventInfo.location_lat && eventInfo.location_lng && (
+ (locationPermissionStatus === 'prompt' || locationPermissionStatus === 'checking' || locationPermissionStatus === 'denied') && (
+
+
+
+
+
+
+
Location verification needed
+ {locationError && locationPermissionStatus === 'denied' && (
+
{locationError}
+ )}
+
+
+
+ {/* Map embed - use the helper function to create the URL */}
+
+
+
+
+ {locationPermissionStatus === 'denied' && (
+
+
+
+ )}
+
+ )
+ )}
+
+ {isChangingName ? (
+
+
Enter your name
+
+
handleNameInput(e.target.value)}
+ placeholder="Your full name"
+ className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-black focus:border-black bg-white"
+ />
+ {showSuggestions && suggestedNames.length > 0 && (
+
+ {suggestedNames.map((name, index) => (
+
selectSuggestedName(name)}
+ >
+ {name}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ ) : (
+
+ {/* General error message in a more subtle style */}
+ {checkinError && !checkinError.includes('not found for') &&
+ !checkinError.includes('Check-in is not allowed') &&
+ !checkinError.includes('Location') && (
+
+ {checkinError}
+
+ )}
+
+ {/* Member not found error with join button */}
+ {checkinError && checkinError.includes('not found for') && (
+
+
+
Member not found
+
You need to join this club first
+
+
+
+ )}
+
+
+
+
+
+
+ )}
+
+ )}
+
+ {step === 3 && eventInfo && (
+
+
+ Checked In!
+ You've successfully checked in to {eventInfo.name} as {memberName}.
+
+
+
+
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default EventCheckinPage;
\ No newline at end of file
diff --git a/src/pages/EventCheckinQR.tsx b/src/pages/EventCheckinQR.tsx
new file mode 100644
index 0000000..72de1b5
--- /dev/null
+++ b/src/pages/EventCheckinQR.tsx
@@ -0,0 +1,197 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, Link, useNavigate } from 'react-router-dom';
+import { supabase } from '../utils/supabaseClient';
+import { QRCodeCanvas } from 'qrcode.react';
+import { motion } from 'framer-motion';
+import { Copy } from 'lucide-react';
+import Logo from '../components/Logo';
+
+interface EventInfo {
+ name: string;
+ club_id: string;
+ club_name?: string;
+}
+
+// Shared transition for content (blur lingers longer than fade)
+const TAB_TRANSITION = {
+ opacity: { duration: 0.16, ease: [0.4, 0, 0.2, 1] },
+ filter: { duration: 0.28, ease: [0.4, 0, 0.2, 1] }
+};
+
+const tabVariants = {
+ hidden: {
+ opacity: 0,
+ filter: 'blur(16px)',
+ scale: 0.97,
+ y: -20
+ },
+ visible: {
+ opacity: 1,
+ filter: 'blur(0px)',
+ scale: 1,
+ y: 0,
+ transition: {
+ ...TAB_TRANSITION,
+ type: 'spring',
+ damping: 25,
+ stiffness: 300
+ }
+ },
+ exit: {
+ opacity: 0,
+ filter: 'blur(16px)',
+ scale: 0.97,
+ y: -20,
+ transition: {
+ ...TAB_TRANSITION,
+ duration: 0.2
+ }
+ }
+};
+
+const EventCheckinQR: React.FC = () => {
+ const { inviteCode } = useParams<{ inviteCode: string }>();
+ const navigate = useNavigate();
+ const [eventInfo, setEventInfo] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchEventInfo = async () => {
+ if (!inviteCode) {
+ setError('Invite code not found.');
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ // Fetch event details AND club name using the invite code
+ const { data, error: fetchError } = await supabase
+ .from('events')
+ .select('name, club_id, clubs ( name )'
+ )
+ .eq('invite_code', inviteCode)
+ .single();
+
+ if (fetchError || !data) {
+ setError('Could not load event information. Please check the link or code.');
+ setEventInfo(null);
+ } else {
+ // Flatten the result and safely access club name
+ let clubName = 'Unknown Club';
+ if (data.clubs && typeof data.clubs === 'object' && 'name' in data.clubs) {
+ clubName = (data.clubs as { name: string }).name;
+ }
+ const flatData: EventInfo = {
+ name: data.name,
+ club_id: data.club_id,
+ club_name: clubName
+ };
+ setEventInfo(flatData);
+ }
+ setLoading(false);
+ };
+
+ fetchEventInfo();
+ }, [inviteCode]);
+
+ const checkinUrl = eventInfo ? `${window.location.origin}/checkin/${inviteCode}` : '';
+
+ const copyLink = async () => {
+ try {
+ await navigator.clipboard.writeText(checkinUrl);
+ } catch (e) {
+ console.error('Failed to copy link', e);
+ }
+ };
+
+ return (
+
+ {/* Branding */}
+
+
+
+
+ Powered by Attendify
+
+
+ {loading ? (
+
+ Loading QR Code...
+
+ ) : error ? (
+
+ {error}
+
+
+ ) : eventInfo ? (
+
+
+ {eventInfo.name}
+
+ ({eventInfo.club_name})
+
+ Scan the code below with your device to check in
+
+
+
+
+
+ Or go to attendify.app/attend and enter code:
+
+
+
+ {inviteCode}
+
+
+
+
+ Back to Club Details
+
+
+ ) : (
+
Could not display QR code.
+ )}
+
+ );
+};
+
+export default EventCheckinQR;
\ No newline at end of file
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 7956ce0..1ffb20e 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -14,7 +14,7 @@ const Home: React.FC = () => {