MotorSignal-App/website/components/founder-portal/founder-members.tsx

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>
);
}