diff --git a/app/(app)/classes/classes-browse-client.tsx b/app/(app)/classes/classes-browse-client.tsx new file mode 100644 index 0000000..b94fa81 --- /dev/null +++ b/app/(app)/classes/classes-browse-client.tsx @@ -0,0 +1,168 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' +import { CalendarDays } from 'lucide-react' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { Badge } from '@/components/ui/badge' +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import { signUpForClass, cancelMySignup } from '@/lib/actions' +import { effectiveCapacity, SIGNUP_STATUS_LABEL } from '@/lib/classes-logic' +import { SessionAttendance } from '@/components/classes/session-attendance' +import { useRouter } from 'next/navigation' + +type Row = { + id: string + starts_at: string + ends_at: string | null + location: string | null + capacity: number | null + status: string + notes: string | null + classes: { title: string; description: string | null; payment_link: string | null; capacity: number | null } | Array<{ title: string; description: string | null; payment_link: string | null; capacity: number | null }> | null + registered_count: number + my_status: string | null +} + +function classOf(r: Row) { + return Array.isArray(r.classes) ? r.classes[0] : r.classes +} + +export function ClassesBrowseClient({ + sessions, + canRunSessions, +}: { + sessions: unknown[] + canRunSessions: boolean +}) { + const router = useRouter() + const [rows, setRows] = useState(sessions as Row[]) + const [busyId, setBusyId] = useState(null) + const [openAttendance, setOpenAttendance] = useState(null) + + async function onSignUp(r: Row) { + setBusyId(r.id) + const res = await signUpForClass({ sessionId: r.id }) + setBusyId(null) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + const status = (res as { data: { status: string } }).data.status + toast.success(status === 'waitlisted' ? 'Added to the waitlist' : 'You are signed up') + setRows(prev => + prev.map(x => + x.id === r.id ? { ...x, my_status: status, registered_count: x.registered_count + (status === 'registered' ? 1 : 0) } : x, + ), + ) + } + + async function onCancel(r: Row) { + setBusyId(r.id) + const res = await cancelMySignup({ sessionId: r.id }) + setBusyId(null) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + toast.success('Signup cancelled') + setRows(prev => + prev.map(x => + x.id === r.id + ? { ...x, my_status: null, registered_count: Math.max(0, x.registered_count - (r.my_status === 'registered' ? 1 : 0)) } + : x, + ), + ) + } + + return ( + <> + + Classes + + +
+ {rows.length === 0 ? ( + + + + No upcoming classes + Check back later, or ask an organizer to schedule a session. + + + ) : ( +
    + {rows.map(r => { + const c = classOf(r) + const cap = effectiveCapacity(r.capacity, c?.capacity ?? null) + const spots = cap == null ? null : Math.max(0, cap - r.registered_count) + const full = cap != null && r.registered_count >= cap + return ( +
  • +
    +
    +
    + {c?.title ?? 'Class'} + {r.my_status && ( + + {SIGNUP_STATUS_LABEL[r.my_status] ?? r.my_status} + + )} +
    + {c?.description &&

    {c.description}

    } +

    + {new Date(r.starts_at).toLocaleString()} + {r.ends_at ? ` – ${new Date(r.ends_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''} + {r.location ? ` · ${r.location}` : ''} + {cap == null ? ' · open' : ` · ${spots} of ${cap} spots left`} +

    + {r.notes &&

    {r.notes}

    } + {c?.payment_link && ( + payment link + )} +
    +
    + {r.my_status ? ( + + ) : ( + + )} + {canRunSessions && ( + + )} +
    +
    + {canRunSessions && openAttendance === r.id && ( + { setOpenAttendance(null); router.refresh() }} + /> + )} +
  • + ) + })} +
+ )} +
+ + ) +} diff --git a/app/(app)/classes/page.tsx b/app/(app)/classes/page.tsx new file mode 100644 index 0000000..8edaebb --- /dev/null +++ b/app/(app)/classes/page.tsx @@ -0,0 +1,37 @@ +import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import { listUpcomingSessions } from '@/lib/actions' +import { ClassesBrowseClient } from './classes-browse-client' + +export const dynamic = 'force-dynamic' + +export default async function ClassesPage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: member } = await supabase + .from('space_members') + .select('space_id') + .eq('user_id', user.id) + .in('status', ['current', 'unverified', 'late']) + .maybeSingle() + if (!member) redirect('/signup') + + const [{ data: canInstruct }, { data: canManage }] = await Promise.all([ + supabase.rpc('user_has_permission', { uid: user.id, sid: member.space_id, perm: 'classes.instruct' }), + supabase.rpc('user_has_permission', { uid: user.id, sid: member.space_id, perm: 'classes.manage' }), + ]) + + const res = await listUpcomingSessions() + const sessions = 'data' in res ? res.data : [] + + return ( + + ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 0a82ed5..ad61ab9 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -135,6 +135,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa + {isAdmin && ( diff --git a/components/classes/session-attendance.tsx b/components/classes/session-attendance.tsx new file mode 100644 index 0000000..58c116b --- /dev/null +++ b/components/classes/session-attendance.tsx @@ -0,0 +1,113 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { toast } from 'sonner' +import { listSessionSignups, markAttendance, completeSession } from '@/lib/actions' +import { SIGNUP_STATUS_LABEL } from '@/lib/classes-logic' + +type Signup = { + id: string + member_id: string + status: string + attended: boolean + signed_up_at: string + space_members: { display_name: string | null; email: string | null } | null +} + +export function SessionAttendance({ + sessionId, + sessionStatus, + onCompleted, +}: { + sessionId: string + sessionStatus: string + onCompleted: () => void +}) { + const [loading, setLoading] = useState(false) + const [rows, setRows] = useState([]) + const [busy, setBusy] = useState(false) + + const load = useCallback(async () => { + setLoading(true) + const res = await listSessionSignups({ sessionId }) + setLoading(false) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + setRows(((res as { data: Signup[] }).data) ?? []) + }, [sessionId]) + + useEffect(() => { + load() + }, [load]) + + async function toggle(s: Signup) { + const res = await markAttendance({ signupId: s.id, attended: !s.attended }) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + setRows(prev => prev.map(r => (r.id === s.id ? { ...r, attended: !r.attended } : r))) + } + + async function onComplete() { + setBusy(true) + const res = await completeSession({ sessionId }) + setBusy(false) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + const d = (res as { data: { certificatesIssued: number; certificatesSkipped: boolean } }).data + if (d.certificatesSkipped) { + toast.success('Session completed. Certificates were NOT issued (requires the certifications.grant permission).') + } else if (d.certificatesIssued > 0) { + toast.success(`Session completed. ${d.certificatesIssued} certificate(s) issued.`) + } else { + toast.success('Session completed.') + } + onCompleted() + } + + return ( +
+
+

Attendees

+ {sessionStatus === 'scheduled' && ( + + )} +
+ {loading ? ( +

Loading…

+ ) : rows.length === 0 ? ( +

No signups yet.

+ ) : ( +
    + {rows.map(s => ( +
  • +
    + + {s.space_members?.display_name ?? s.space_members?.email ?? 'Member'} + + + {SIGNUP_STATUS_LABEL[s.status] ?? s.status} + +
    + +
  • + ))} +
+ )} +
+ ) +}