Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions src/pages/EventDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,14 @@ export function EventDetailPage() {
// Check if current user is the organizer
const isOrganizer = event?.organizer_id === user?.id;

// Archived (completed) events are always treated as paid for payment tracking
const isArchived = event ? isEventCompleted(event.datetime, event.end_datetime) : false;
const effectiveIsPaid = isArchived || (event?.is_paid ?? true);
// Event is past its end time
const isEventPast = event ? isEventCompleted(event.datetime, event.end_datetime) : false;
// Event is fully archived only when past AND all participants have settled payment
const allPaid = paymentSummary.total > 0 && paymentSummary.pending === 0;
const isArchived = isEventPast && (!event?.is_paid || allPaid);
// Registration is closed for non-organizers on past events, or for everyone on archived events
const isRegistrationClosed = isArchived || (isEventPast && !isOrganizer);
const effectiveIsPaid = isEventPast || (event?.is_paid ?? true);

// Check if event is at full capacity
const isEventFull =
Expand Down Expand Up @@ -1011,7 +1016,7 @@ export function EventDetailPage() {
>
<Button
onClick={() => {
if (isEventCompleted(event.datetime, event.end_datetime)) return;
if (isRegistrationClosed) return;
if (isEventFull && !userRegistration) return;
if (showRegistrationForm) {
openSignupDrawer();
Expand All @@ -1021,22 +1026,17 @@ export function EventDetailPage() {
handleDirectJoin();
}
}}
disabled={
isEventCompleted(event.datetime, event.end_datetime) ||
submitting ||
(isEventFull && !userRegistration)
}
disabled={isRegistrationClosed || submitting || (isEventFull && !userRegistration)}
className={`w-full text-white shadow-lg drop-shadow-md ${
isEventCompleted(event.datetime, event.end_datetime) ||
(isEventFull && !userRegistration)
isRegistrationClosed || (isEventFull && !userRegistration)
? 'bg-muted-foreground'
: userRegistration && !showRegistrationForm
? 'bg-destructive hover:bg-destructive/90'
: 'bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600'
}`}
size="default"
>
{isEventCompleted(event.datetime, event.end_datetime) ? (
{isRegistrationClosed ? (
<>
<UserX className="h-5 w-5 mr-2" />
Registration Closed
Expand Down
94 changes: 78 additions & 16 deletions src/pages/EventsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,38 +46,86 @@ export function EventsPage() {
const [duplicatingEventId, setDuplicatingEventId] = useState<string | null>(null);
const [paymentFilter, setPaymentFilter] = useState<PaymentFilter>('all');
const [paymentSummaries, setPaymentSummaries] = useState<Map<string, PaymentSummary>>(new Map());
const unsettledJoinedIdsRef = useRef<Set<string>>(new Set());
const isEventDuplicationEnabled = useFeatureFlag('event_duplication');
const hasLoadedRef = useRef(false);

const loadOrganizingEventsCallback = useCallback(async () => {
if (!user) return [];
const allEvents = await eventService.getEventsByOrganizer(user.id);
return allEvents.filter((event) => !isEventCompleted(event.datetime, event.end_datetime));
const activeEvents = allEvents.filter(
(event) => !isEventCompleted(event.datetime, event.end_datetime)
);
const pastEvents = allEvents.filter((event) =>
isEventCompleted(event.datetime, event.end_datetime)
);

// Past paid events with unpaid participants stay in Organizing
const pastPaidEventIds = pastEvents.filter((e) => e.is_paid).map((e) => e.id);
if (pastPaidEventIds.length > 0) {
const summaries = await participantService.getPaymentSummariesBatch(pastPaidEventIds);
const unsettledPastEvents = pastEvents.filter((event) => {
if (!event.is_paid) return false;
const summary = summaries.get(event.id);
return summary && summary.pending > 0;
});
return [...activeEvents, ...unsettledPastEvents];
}

return activeEvents;
}, [user]);

const loadJoinedEventsCallback = useCallback(async () => {
if (!user) return [];
const allEvents = await eventService.getEventsByParticipant(user.id);
return allEvents.filter((event) => !isEventCompleted(event.datetime, event.end_datetime));
const activeEvents = allEvents.filter(
(event) => !isEventCompleted(event.datetime, event.end_datetime)
);
const pastPaidEvents = allEvents.filter(
(event) => isEventCompleted(event.datetime, event.end_datetime) && event.is_paid
);

// Past paid events where the user hasn't paid stay in Joined
if (pastPaidEvents.length > 0) {
const myStatuses = await participantService.getMyPaymentStatusBatch(
user.id,
pastPaidEvents.map((e) => e.id)
);
const unsettledForMe = pastPaidEvents.filter((event) => {
const status = myStatuses.get(event.id);
return status === 'pending';
});
unsettledJoinedIdsRef.current = new Set(unsettledForMe.map((e) => e.id));
return [...activeEvents, ...unsettledForMe];
}

unsettledJoinedIdsRef.current = new Set();
return activeEvents;
}, [user]);

const loadArchivedEventsCallback = useCallback(async () => {
if (!user) return [];
const allEvents = await eventService.getEventsByOrganizer(user.id);
const archived = allEvents.filter((event) =>
const pastEvents = allEvents.filter((event) =>
isEventCompleted(event.datetime, event.end_datetime)
);

// Fetch payment summaries for archived events
if (archived.length > 0) {
const eventIds = archived.map((e) => e.id);
// Fetch payment summaries for past events
if (pastEvents.length > 0) {
const eventIds = pastEvents.map((e) => e.id);
const summaries = await participantService.getPaymentSummariesBatch(eventIds);
setPaymentSummaries(summaries);
} else {
setPaymentSummaries(new Map());

// Only archive events that are fully settled (or not paid events)
return pastEvents.filter((event) => {
if (!event.is_paid) return true;
const summary = summaries.get(event.id);
return !summary || summary.pending === 0;
});
}

return archived;
setPaymentSummaries(new Map());
return pastEvents;
}, [user]);

useEffect(() => {
Expand Down Expand Up @@ -166,7 +214,8 @@ export function EventsPage() {
isLoading: boolean,
showDuplicate: boolean,
emptyState?: { title: string; description: string; icon?: React.ReactNode },
showPaymentStatus?: boolean
showPaymentStatus?: boolean,
showUserUnpaid?: Set<string>
) => {
if (isLoading) {
return <EventListSkeleton count={3} />;
Expand Down Expand Up @@ -248,6 +297,12 @@ export function EventsPage() {
)}
</div>
)}
{showUserUnpaid?.has(event.id) && (
<div className="flex items-center gap-1 text-amber-600">
<Clock className="h-3 w-3" />
<span className="font-medium">Unpaid</span>
</div>
)}
<div className="flex items-center gap-1 text-muted-foreground">
<Users className="h-3 w-3" />
<span className="font-medium">{event.participant_count || 0}</span>
Expand Down Expand Up @@ -300,12 +355,19 @@ export function EventsPage() {
</div>
</div>
<TabsContent value="joined" className="p-3 space-y-3 mt-0">
{renderEventList(joinedEvents, isLoadingJoined, false, {
title: 'Welcome to Roster!',
description:
'Join an event using a link from your organizer, or create your own with the button below.',
icon: <span>🎉</span>,
})}
{renderEventList(
joinedEvents,
isLoadingJoined,
false,
{
title: 'Welcome to Roster!',
description:
'Join an event using a link from your organizer, or create your own with the button below.',
icon: <span>🎉</span>,
},
false,
unsettledJoinedIdsRef.current
)}
{(!joinedEvents || joinedEvents.length === 0) && !isLoadingJoined && (
<Alert className="border-violet-500/30 bg-violet-500/10 [&>svg]:text-violet-500">
<Lightbulb className="h-4 w-4 text-violet-500" />
Expand Down
103 changes: 69 additions & 34 deletions src/pages/GroupDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useAuth } from '@/hooks/useAuth';
import { Calendar, Users, Plus, UsersRound, Share2, Edit, Copy } from 'lucide-react';
import { Calendar, Users, Plus, UsersRound, Share2, Edit, Copy, Clock } from 'lucide-react';
import { TopNav } from '@/components/TopNav';
import {
Select,
Expand All @@ -12,7 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { groupService, eventService, type Group } from '@/services';
import { groupService, eventService, participantService, type Group } from '@/services';
import { useFeatureFlag } from '@/hooks/useFeatureFlags';
import { errorHandler } from '@/lib/errorHandler';
import { useLoadingState } from '@/hooks/useLoadingState';
Expand All @@ -23,6 +23,7 @@ import { DuplicateEventDrawer } from '@/components/DuplicateEventDrawer';

interface GroupEvent extends Tables<'events'> {
participant_count?: number;
_unsettled?: boolean;
}

export function GroupDetailPage() {
Expand All @@ -39,6 +40,7 @@ export function GroupDetailPage() {
const isEventDuplicationEnabled = useFeatureFlag('event_duplication');
const loadingRef = useRef(false);
const hasLoadedRef = useRef(false);
const isAdminRef = useRef(false);
const {
isLoading: eventsLoading,
data: events,
Expand All @@ -61,6 +63,7 @@ export function GroupDetailPage() {
// Check if current user is admin or owner
if (user?.id) {
const adminStatus = await groupService.isGroupAdmin(groupId, user.id);
isAdminRef.current = adminStatus;
setIsAdmin(adminStatus);
}
return true;
Expand All @@ -74,10 +77,35 @@ export function GroupDetailPage() {
}
}, [groupId, navigate, user?.id]);

const loadEventsCallback = useCallback(async () => {
const loadEventsCallback = useCallback(async (): Promise<GroupEvent[]> => {
if (!groupId) return [];
return await groupService.getGroupEvents(groupId);
}, [groupId]);
const allEvents: GroupEvent[] = await groupService.getGroupEvents(groupId);

// Mark past paid events with unsettled payments
const pastPaidEvents = allEvents.filter(
(e) => e.is_paid && isEventCompleted(e.datetime, e.end_datetime)
);
if (pastPaidEvents.length > 0) {
const pastPaidIds = pastPaidEvents.map((e) => e.id);

if (isAdminRef.current) {
// Admins: event is unsettled if any participant has pending payments
const summaries = await participantService.getPaymentSummariesBatch(pastPaidIds);
for (const event of pastPaidEvents) {
const summary = summaries.get(event.id);
event._unsettled = !!(summary && summary.pending > 0);
}
} else if (user?.id) {
// Non-admins: event is unsettled only if their own payment is pending
const myStatuses = await participantService.getMyPaymentStatusBatch(user.id, pastPaidIds);
for (const event of pastPaidEvents) {
event._unsettled = myStatuses.get(event.id) === 'pending';
}
}
}

return allEvents;
Comment thread
michaelchu marked this conversation as resolved.
}, [groupId, user?.id]);

const handleInvite = useCallback(async () => {
if (!group) return;
Expand Down Expand Up @@ -276,34 +304,34 @@ export function GroupDetailPage() {
</Select>
</div>

{eventsLoading ? (
<EventListSkeleton count={3} />
) : !events ||
events.filter((e) =>
{(() => {
if (eventsLoading) return <EventListSkeleton count={3} />;

const filteredEvents = (events || []).filter((e) =>
eventFilter === 'active'
? !isEventCompleted(e.datetime, e.end_datetime)
: isEventCompleted(e.datetime, e.end_datetime)
).length === 0 ? (
<div className="p-6 text-center">
<Calendar className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<h3 className="text-base font-medium mb-2">
{eventFilter === 'active' ? 'No Active Events' : 'No Archived Events'}
</h3>
<p className="text-xs text-muted-foreground">
{eventFilter === 'active'
? 'Create your first event for this group'
: 'Completed events will appear here'}
</p>
</div>
) : (
<div className="divide-y">
{events
.filter((e) =>
eventFilter === 'active'
? !isEventCompleted(e.datetime, e.end_datetime)
: isEventCompleted(e.datetime, e.end_datetime)
)
.map((event) => (
? !isEventCompleted(e.datetime, e.end_datetime) || !!e._unsettled
: isEventCompleted(e.datetime, e.end_datetime) && !e._unsettled
);

if (filteredEvents.length === 0) {
return (
<div className="p-6 text-center">
<Calendar className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<h3 className="text-base font-medium mb-2">
{eventFilter === 'active' ? 'No Active Events' : 'No Archived Events'}
</h3>
<p className="text-xs text-muted-foreground">
{eventFilter === 'active'
? 'Create your first event for this group'
: 'Completed events will appear here'}
</p>
</div>
);
}

return (
<div className="divide-y">
{filteredEvents.map((event) => (
<div key={event.id} className="relative">
<button
onClick={() => navigate(`/signup/${event.id}`)}
Expand All @@ -323,6 +351,12 @@ export function GroupDetailPage() {
</div>
)}
<div className="flex items-center gap-3">
{event._unsettled && !isAdmin && (
<div className="flex items-center gap-1 text-amber-600">
<Clock className="h-3 w-3" />
<span className="font-medium">Unpaid</span>
</div>
)}
<div className="flex items-center gap-1">
<Users className="h-3 w-3" />
<span>{event.participant_count || 0} registered</span>
Expand Down Expand Up @@ -356,8 +390,9 @@ export function GroupDetailPage() {
)}
</div>
))}
</div>
)}
</div>
);
})()}
</div>
</div>

Expand Down
Loading