147 lines
9.7 KiB
TypeScript
147 lines
9.7 KiB
TypeScript
'use client';
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { DEFAULT_CLUB_RANKS, fetchClubMembers, sanitizePortalError, updateMemberRank } from '../../lib/club-data';
|
|
import { fetchMemberRoster, removeClubMember } from '../../lib/club-community';
|
|
import type { FounderClubContext } from '../../lib/club-data';
|
|
import type { ClubMember } from './types';
|
|
import { CommunityDialog, communityButton, communityInput } from './community-dialog';
|
|
|
|
export function FounderMembers({ context }: { context: FounderClubContext }) {
|
|
const [members, setMembers] = useState<ClubMember[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [notice, setNotice] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
const [filter, setFilter] = useState('all');
|
|
const [selection, setSelection] = useState<{ member: ClubMember; action: 'rank' | 'remove' } | null>(null);
|
|
const [rank, setRank] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const busyRef = useRef(false);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const roster = context.isDemoFixture
|
|
? await fetchClubMembers(context.club.id, context.currentUser.uid, { allowDemoPreview: true })
|
|
: await fetchMemberRoster(context.club.id);
|
|
setMembers(roster);
|
|
setHasMore(!context.isDemoFixture && roster.length === 50);
|
|
} catch (err) { setError(sanitizePortalError(err, 'Members could not be loaded.')); }
|
|
finally { setLoading(false); }
|
|
}, [context]);
|
|
useEffect(() => { void load(); }, [load]);
|
|
|
|
async function loadMore() {
|
|
if (loadingMore || !hasMore) return;
|
|
setLoadingMore(true);
|
|
setError(null);
|
|
try {
|
|
const roster = await fetchMemberRoster(context.club.id, members[members.length - 1]?.uid);
|
|
setMembers((current) => [...current, ...roster.filter((member) => !current.some((item) => item.uid === member.uid))]);
|
|
setHasMore(roster.length === 50);
|
|
} catch (err) { setError(sanitizePortalError(err, 'More members could not be loaded.')); }
|
|
finally { setLoadingMore(false); }
|
|
}
|
|
|
|
const term = search.trim().toLowerCase();
|
|
const visible = members.filter((member) =>
|
|
(filter === 'all' || member.role === filter) &&
|
|
[member.displayName, member.handle, member.displayRank, member.uid].some((value) => value?.toLowerCase().includes(term)));
|
|
|
|
function canRemove(member: ClubMember) {
|
|
return !context.isDemoFixture && member.uid !== context.currentUser.uid &&
|
|
member.uid !== context.club.ownerUid && member.role !== 'owner' &&
|
|
(context.currentUser.role === 'owner' || member.role !== 'admin');
|
|
}
|
|
|
|
async function confirm(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
if (!selection || busyRef.current || context.isDemoFixture) return;
|
|
busyRef.current = true;
|
|
setBusy(true);
|
|
setActionError(null);
|
|
try {
|
|
if (selection.action === 'rank') {
|
|
await updateMemberRank(context.club.id, selection.member.uid, rank);
|
|
setMembers((current) => current.map((member) => member.uid === selection.member.uid
|
|
? { ...member, displayRank: rank.trim() } : member));
|
|
setNotice('Display rank updated. Administrative access is unchanged.');
|
|
} else {
|
|
await removeClubMember(context.club.id, selection.member.uid);
|
|
setMembers((current) => current.filter((member) => member.uid !== selection.member.uid));
|
|
setNotice('Member removed from the club. Membership history is preserved.');
|
|
}
|
|
setSelection(null);
|
|
} catch (err) { setActionError(sanitizePortalError(err, 'The member could not be updated. Refresh the roster and try again.')); }
|
|
finally { busyRef.current = false; setBusy(false); }
|
|
}
|
|
|
|
return (
|
|
<section className="space-y-6">
|
|
<div className="flex flex-wrap gap-4 items-end justify-between">
|
|
<div><p className="font-mono text-xs text-brand-cyan mb-2">CLUB MANAGEMENT / ROSTER</p>
|
|
<h1 className="font-headline text-3xl font-bold text-white">Members</h1>
|
|
<p className="mt-2 text-on-surface-variant">Your active roster, officer ranks, and membership controls.</p></div>
|
|
<button className={communityButton} disabled={loading || loadingMore} onClick={() => void load()}>Refresh roster</button>
|
|
</div>
|
|
{context.isDemoFixture && <p className="text-brand-cyan">Development preview • changes are disabled.</p>}
|
|
<div className="grid sm:grid-cols-3 gap-3">
|
|
{([['Members loaded', members.length], ['Administrators loaded', members.filter((m) => m.role === 'admin' || m.role === 'owner').length], ['Showing', visible.length]] as const).map(([label, count]) => (
|
|
<div key={label} className="rounded-xl border border-brand-border bg-brand-card p-5">
|
|
<p className="text-xs text-on-surface-variant">{label}</p><p className="font-mono text-3xl text-brand-cyan mt-2">{loading || error ? '—' : count}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{hasMore && <div className="flex flex-wrap gap-3 items-center"><button className={communityButton} disabled={loading || loadingMore} onClick={() => void loadMore()}>{loadingMore ? 'Loading…' : 'Load more members'}</button><p className="text-xs text-outline">Search and counts cover loaded members. Load more to include the rest of your roster.</p></div>}
|
|
<div className="rounded-2xl border border-brand-border bg-brand-card overflow-hidden">
|
|
<div className="p-4 grid gap-4 sm:grid-cols-[1fr_auto] border-b border-brand-border">
|
|
<label className="text-xs text-on-surface-variant">Search roster
|
|
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${communityInput} mt-1`} placeholder="Name, handle, rank, or rider ID" /></label>
|
|
<label className="text-xs text-on-surface-variant">Administrative role
|
|
<select value={filter} onChange={(event) => setFilter(event.target.value)} className={`${communityInput} mt-1`}>
|
|
<option value="all">All roles</option><option value="owner">Owner</option><option value="admin">Admin</option><option value="member">Member</option>
|
|
</select></label>
|
|
</div>
|
|
<p role="status" className="px-4 pt-3 text-secondary">{notice}</p>
|
|
{loading ? <p role="status" className="p-8">Loading members…</p>
|
|
: error ? <p role="alert" className="p-8 text-error">{error}</p>
|
|
: visible.length === 0 ? <p className="p-8 text-on-surface-variant">{members.length ? 'No members match your search.' : 'There are no active members to display.'}</p>
|
|
: <div className="overflow-x-auto"><table className="w-full text-left">
|
|
<thead className="font-mono text-xs text-outline"><tr><th className="p-4">Rider</th><th className="p-4">Display rank</th><th className="p-4">Access</th><th className="p-4">Actions</th></tr></thead>
|
|
<tbody>{visible.map((member) => <tr key={member.uid} className="border-t border-brand-border/60">
|
|
<td className="p-4"><p className="font-semibold text-white">{member.displayName || 'Rider'}</p><p className="text-xs text-outline break-all">{member.handle || member.uid}</p></td>
|
|
<td className="p-4"><span className="inline-block rounded-md border border-brand-cyan/25 bg-brand-cyan/10 px-2 py-1 text-xs text-brand-cyan">{member.displayRank || 'Member'}</span></td>
|
|
<td className="p-4 capitalize">{member.role}</td>
|
|
<td className="p-4"><div className="flex flex-wrap gap-2">
|
|
<button className={communityButton} disabled={context.isDemoFixture} aria-label={`Edit rank for ${member.displayName || member.uid}`} onClick={() => {
|
|
setSelection({ member, action: 'rank' }); setRank(member.displayRank || 'Member'); setActionError(null);
|
|
}}>Edit rank</button>
|
|
{canRemove(member) && <button className={`${communityButton} text-error`} aria-label={`Remove ${member.displayName || member.uid}`} onClick={() => {
|
|
setSelection({ member, action: 'remove' }); setActionError(null);
|
|
}}>Remove</button>}
|
|
</div></td>
|
|
</tr>)}</tbody>
|
|
</table></div>}
|
|
</div>
|
|
<p className="text-xs text-outline">Display ranks are club titles. Owner and Admin roles control administrative access.</p>
|
|
{selection && <CommunityDialog title={selection.action === 'rank' ? 'Edit display rank' : 'Remove member'} busy={busy} onClose={() => setSelection(null)}>
|
|
<form onSubmit={confirm}>
|
|
<p className="mb-4 text-on-surface-variant">{selection.member.displayName || selection.member.uid}</p>
|
|
{selection.action === 'rank' ? <>
|
|
<label className="block mb-3">Display rank<input autoFocus required maxLength={60} list="club-display-ranks" value={rank} onChange={(event) => setRank(event.target.value)} className={`${communityInput} mt-2`} /></label>
|
|
<datalist id="club-display-ranks">{DEFAULT_CLUB_RANKS.map((item) => <option key={item.id} value={item.title} />)}</datalist>
|
|
<p className="text-xs text-outline mb-4">This title does not grant administrative permissions.</p>
|
|
</> : <p className="mb-4">This removes club access. The rider account and membership history will remain.</p>}
|
|
{actionError && <p role="alert" className="mb-4 text-error">{actionError}</p>}
|
|
<button className={`${communityButton} text-brand-cyan`} disabled={busy || (selection.action === 'rank' && !rank.trim())}>{busy ? 'Saving…' : selection.action === 'rank' ? 'Save rank' : 'Confirm removal'}</button>
|
|
</form>
|
|
</CommunityDialog>}
|
|
</section>
|
|
);
|
|
}
|