From f495ec0cacb95f6c87662fb2288789ee007bc4e5 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 1 Oct 2026 08:31:45 -0400 Subject: [PATCH] feat: add club members and wall management --- .../emulator-tests/club-wall.rules.test.js | 169 ++++++++++++++ firestore.rules | 82 +++++++ .../clubs/[clubId]/manage/members/page.tsx | 9 + .../app/clubs/[clubId]/manage/wall/page.tsx | 9 + .../founder-portal/community-dialog.tsx | 28 +++ .../founder-portal/founder-community-page.tsx | 61 +++++ .../founder-portal/founder-members.tsx | 146 ++++++++++++ .../founder-portal/founder-sidebar.tsx | 8 +- .../founder-portal/founder-wall.tsx | 101 ++++++++ website/lib/club-community.ts | 179 ++++++++++++++ website/lib/club-data.ts | 36 ++- website/tests/club-community-data.test.ts | 173 ++++++++++++++ website/tests/club-community-ui.test.tsx | 218 ++++++++++++++++++ 13 files changed, 1206 insertions(+), 13 deletions(-) create mode 100644 firebase/emulator-tests/club-wall.rules.test.js create mode 100644 website/app/clubs/[clubId]/manage/members/page.tsx create mode 100644 website/app/clubs/[clubId]/manage/wall/page.tsx create mode 100644 website/components/founder-portal/community-dialog.tsx create mode 100644 website/components/founder-portal/founder-community-page.tsx create mode 100644 website/components/founder-portal/founder-members.tsx create mode 100644 website/components/founder-portal/founder-wall.tsx create mode 100644 website/lib/club-community.ts create mode 100644 website/tests/club-community-data.test.ts create mode 100644 website/tests/club-community-ui.test.tsx diff --git a/firebase/emulator-tests/club-wall.rules.test.js b/firebase/emulator-tests/club-wall.rules.test.js new file mode 100644 index 0000000..7992672 --- /dev/null +++ b/firebase/emulator-tests/club-wall.rules.test.js @@ -0,0 +1,169 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest'; +import { assertFails, assertSucceeds, initializeTestEnvironment } from '@firebase/rules-unit-testing'; +import { collection, deleteDoc, doc, documentId, getDoc, getDocs, limit, orderBy, query, setDoc, startAfter, Timestamp, updateDoc, where, writeBatch } from 'firebase/firestore'; +import fs from 'node:fs'; +import path from 'node:path'; + +let env; +const clubId = 'ClubCaseSensitive'; +const postPath = `clubs/${clubId}/wall_posts/post1`; +const profile = (uid, role = 'member', status = 'active') => ({ uid, clubId, role, status, displayRank: role === 'owner' ? 'President' : 'Member' }); +const post = (uid = 'member', role = 'member') => ({ + id: 'post1', clubId, authorUid: uid, authorRole: role, authorDisplayName: uid, + authorDisplayRank: role === 'owner' ? 'President' : 'Member', content: 'Ride briefing', + createdAt: Timestamp.now(), isPinned: false, fistBumpCount: 0, fistBumpUserIds: [], comments: [], +}); +const comment = (uid = 'member', role = 'member') => ({ + id: 'reply1', postId: 'post1', authorUid: uid, authorRole: role, authorDisplayName: uid, + authorDisplayRank: role === 'owner' ? 'President' : 'Member', content: 'See you there', createdAt: new Date().toISOString(), +}); +const dbFor = (uid) => env.authenticatedContext(uid).firestore(); + +beforeAll(async () => { + env = await initializeTestEnvironment({ projectId: 'demo-motorsignal-club-wall', + firestore: { rules: fs.readFileSync(path.resolve(process.cwd(), '../../firestore.rules'), 'utf8') } }); +}); +afterAll(async () => { await env?.cleanup(); }); +beforeEach(async () => { + await env.clearFirestore(); + await env.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs', clubId), { id: clubId, ownerUid: 'owner', memberCount: 4, archivedAt: null, visibility: 'private' }); + for (const [uid, role, status] of [['owner', 'owner', 'active'], ['admin', 'admin', 'active'], ['peer', 'admin', 'active'], + ['member', 'member', 'active'], ['invited', 'member', 'invited'], ['requested', 'member', 'requested'], ['removed', 'member', 'removed']]) { + await setDoc(doc(db, 'clubs', clubId, 'members', uid), profile(uid, role, status)); + } + await setDoc(doc(db, postPath), post()); + }); +}); + +describe('Club wall privacy and author authority', () => { + test.each(['owner', 'admin', 'member'])('%s can point-read and subscribe to the club wall query', async (uid) => { + const db = dbFor(uid); + await assertSucceeds(getDoc(doc(db, postPath))); + await assertSucceeds(getDocs(query(collection(db, 'clubs', clubId, 'wall_posts'), orderBy('createdAt', 'desc'), limit(50)))); + }); + test.each(['outsider', 'invited', 'requested', 'removed'])('%s cannot read or create posts', async (uid) => { + const db = dbFor(uid); + await assertFails(getDoc(doc(db, postPath))); + await assertFails(getDocs(collection(db, 'clubs', clubId, 'wall_posts'))); + await assertFails(setDoc(doc(db, 'clubs', clubId, 'wall_posts', 'post2'), { ...post(uid), id: 'post2' })); + }); + test('signed-out and anonymous accounts cannot access posts', async () => { + for (const db of [env.unauthenticatedContext().firestore(), env.authenticatedContext('member', { firebase: { sign_in_provider: 'anonymous' } }).firestore()]) { + await assertFails(getDoc(doc(db, postPath))); + await assertFails(setDoc(doc(db, 'clubs', clubId, 'wall_posts', 'post2'), { ...post(), id: 'post2' })); + } + }); + test('a banned active member cannot read or mutate the wall', async () => { + await env.withSecurityRulesDisabled((ctx) => setDoc(doc(ctx.firestore(), 'clubs', clubId, 'bans', 'member'), { uid: 'member' })); + const db = dbFor('member'); + await assertFails(getDoc(doc(db, postPath))); + await assertFails(deleteDoc(doc(db, postPath))); + await assertFails(updateDoc(doc(db, postPath), { comments: [comment()] })); + }); + test('archived clubs deny wall access, even for an owner', async () => { + await env.withSecurityRulesDisabled((ctx) => updateDoc(doc(ctx.firestore(), 'clubs', clubId), { archivedAt: Timestamp.now() })); + await assertFails(getDoc(doc(dbFor('owner'), postPath))); + await assertFails(deleteDoc(doc(dbFor('owner'), postPath))); + }); + test('active member can create an unpinned post with the mobile-compatible model', async () => { + await assertSucceeds(setDoc(doc(dbFor('member'), 'clubs', clubId, 'wall_posts', 'post2'), { ...post(), id: 'post2' })); + }); + test.each(['owner', 'admin'])('%s can publish a pinned announcement', async (uid) => { + await assertSucceeds(setDoc(doc(dbFor(uid), 'clubs', clubId, 'wall_posts', 'post2'), { ...post(uid, uid), id: 'post2', isPinned: true })); + }); + test.each([ + { authorUid: 'owner' }, { authorRole: 'owner' }, { authorDisplayRank: 'President' }, + { isPinned: true }, { clubId: 'otherClub' }, { content: ' ' }, { content: 'x'.repeat(5001) }, + { comments: [comment()] }, { fistBumpCount: 10 }, { latitude: 1 }, { createdAt: 'today' }, + ])('rejects forged or malformed create: %j', async (change) => { + await assertFails(setDoc(doc(dbFor('member'), 'clubs', clubId, 'wall_posts', 'post2'), { ...post(), id: 'post2', ...change })); + }); + test('wall access does not grant private ride telemetry or live presence', async () => { + const db = dbFor('admin'); + await assertFails(getDoc(doc(db, 'users', 'member', 'rides', 'privateRide'))); + await assertFails(getDoc(doc(db, 'group_rides', 'privateRide', 'live_presence', 'member'))); + }); +}); + +describe('Replies and moderation preserve existing content and identity', () => { + test('members append replies without rewriting previous comments', async () => { + const ref = doc(dbFor('member'), postPath); + const first = comment(); + await assertSucceeds(updateDoc(ref, { comments: [first] })); + await assertSucceeds(updateDoc(ref, { comments: [first, { ...comment(), id: 'reply2' }] })); + expect((await getDoc(ref)).data().comments).toHaveLength(2); + await assertFails(updateDoc(ref, { comments: [{ ...first, content: 'forged edit' }, { ...comment(), id: 'reply3' }, comment()] })); + await assertFails(updateDoc(ref, { comments: [] })); + }); + test.each([{ authorUid: 'owner' }, { authorRole: 'admin' }, { postId: 'otherPost' }, { content: '' }, { content: 'x'.repeat(2001) }, { latitude: 1 }])('rejects forged reply: %j', async (change) => { + await assertFails(updateDoc(doc(dbFor('member'), postPath), { comments: [{ ...comment(), ...change }] })); + }); + test('reply limit preserves the full existing thread', async () => { + const replies = Array.from({ length: 200 }, (_, i) => ({ ...comment(), id: `reply${i}` })); + await env.withSecurityRulesDisabled((ctx) => updateDoc(doc(ctx.firestore(), postPath), { comments: replies })); + await assertFails(updateDoc(doc(dbFor('member'), postPath), { comments: [...replies, comment()] })); + }); + test.each(['owner', 'admin'])('%s can pin/unpin and delete another member’s post', async (uid) => { + const ref = doc(dbFor(uid), postPath); + await assertSucceeds(updateDoc(ref, { isPinned: true })); + await assertSucceeds(updateDoc(ref, { isPinned: false })); + await assertSucceeds(deleteDoc(ref)); + }); + test('members cannot pin or delete someone else’s post, even with an officer display rank', async () => { + await env.withSecurityRulesDisabled((ctx) => updateDoc(doc(ctx.firestore(), 'clubs', clubId, 'members', 'member'), { displayRank: 'President' })); + const ref = doc(dbFor('member'), postPath); + await assertFails(updateDoc(ref, { isPinned: true })); + }); + test('post author may delete their own post; members cannot delete others', async () => { + await env.withSecurityRulesDisabled((ctx) => setDoc(doc(ctx.firestore(), 'clubs', clubId, 'members', 'otherMember'), profile('otherMember'))); + await assertFails(deleteDoc(doc(dbFor('otherMember'), postPath))); + await assertSucceeds(deleteDoc(doc(dbFor('member'), postPath))); + }); + test.each([{ authorUid: 'owner' }, { createdAt: Timestamp.fromMillis(1) }, { content: 'rewritten' }, { clubId: 'otherClub' }])('even admins cannot rewrite post identity/content: %j', async (change) => { + await assertFails(updateDoc(doc(dbFor('admin'), postPath), change)); + }); + test('mobile fist bump toggles can change only the caller’s own reaction', async () => { + const ref = doc(dbFor('member'), postPath); + await assertSucceeds(updateDoc(ref, { fistBumpCount: 1, fistBumpUserIds: ['member'] })); + await assertFails(updateDoc(ref, { fistBumpCount: 2, fistBumpUserIds: ['member', 'admin'] })); + await assertSucceeds(updateDoc(ref, { fistBumpCount: 0, fistBumpUserIds: [] })); + }); +}); + +describe('Members management remains transactional and preserves hierarchy', () => { + test('roster pagination uses the active-member query and a document ID cursor', async () => { + const db = dbFor('admin'); + const first = await assertSucceeds(getDocs(query(collection(db, 'clubs', clubId, 'members'), where('status', '==', 'active'), orderBy(documentId()), limit(2)))); + const next = await assertSucceeds(getDocs(query(collection(db, 'clubs', clubId, 'members'), where('status', '==', 'active'), orderBy(documentId()), startAfter(first.docs[1].id), limit(2)))); + expect(new Set([...first.docs, ...next.docs].map((item) => item.id)).size).toBe(4); + }); + test.each(['owner', 'admin'])('%s can remove an active member with the matching count decrement', async (uid) => { + const db = dbFor(uid); + const batch = writeBatch(db); + batch.update(doc(db, 'clubs', clubId, 'members', 'member'), { status: 'removed', removedAt: Timestamp.now(), updatedAt: Timestamp.now() }); + batch.update(doc(db, 'clubs', clubId), { memberCount: 3, lastMembershipChangeUid: 'member', updatedAt: Timestamp.now() }); + await assertSucceeds(batch.commit()); + expect((await getDoc(doc(db, 'clubs', clubId, 'members', 'member'))).data().status).toBe('removed'); + }); + test('removal without a count decrement is denied', async () => { + await assertFails(updateDoc(doc(dbFor('admin'), 'clubs', clubId, 'members', 'member'), { status: 'removed' })); + }); + test('admin cannot remove owner or peer admin', async () => { + for (const uid of ['owner', 'peer']) { + const db = dbFor('admin'); + const batch = writeBatch(db); + batch.update(doc(db, 'clubs', clubId, 'members', uid), { status: 'removed' }); + batch.update(doc(db, 'clubs', clubId), { memberCount: 3, lastMembershipChangeUid: uid }); + await assertFails(batch.commit()); + } + }); + test('admin can change cosmetic rank without changing the member role', async () => { + const ref = doc(dbFor('admin'), 'clubs', clubId, 'members', 'member'); + await assertSucceeds(updateDoc(ref, { displayRank: 'Road Captain', updatedAt: Timestamp.now() })); + expect((await getDoc(ref)).data().role).toBe('member'); + await assertFails(updateDoc(ref, { role: 'admin' })); + await assertFails(updateDoc(doc(dbFor('member'), 'clubs', clubId, 'members', 'member'), { displayRank: 'President' })); + }); +}); diff --git a/firestore.rules b/firestore.rules index f51b12b..85c70ca 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1254,6 +1254,88 @@ service cloud.firestore { allow delete: if false; } + // --- CLUB WALL --- + // Uses the existing mobile wall_posts model (embedded comments). + // Membership gives access to club discussions only, never ride presence. + match /wall_posts/{postId} { + function canUseWall() { + return isRegisteredUser() + && getClubDoc().data.archivedAt == null + && !isBanned(request.auth.uid) + && (isCallerClubOwner() || isActiveMember(request.auth.uid)); + } + + function canModerateWall() { + return isCallerClubOwner() || isClubAdmin(request.auth.uid); + } + + function validWallAuthor(data) { + return data.authorUid == request.auth.uid + && data.authorDisplayName is string + && data.authorDisplayName.size() > 0 && data.authorDisplayName.size() <= 100 + && ((isCallerClubOwner() && data.authorRole == 'owner') + || (!isCallerClubOwner() && data.authorRole == memberData(request.auth.uid).role)) + && (!('authorHandle' in data) || (data.authorHandle is string && data.authorHandle.size() <= 100)) + && (!('authorAvatarUrl' in data) || isValidClubImageUrl(data.authorAvatarUrl)) + && (!('authorDisplayRank' in data) || (data.authorDisplayRank is string + && data.authorDisplayRank == memberData(request.auth.uid).displayRank)); + } + + function validWallContent(data, maxLength) { + return data.content is string && data.content.trim().size() > 0 && data.content.size() <= maxLength; + } + + function validPostCreate() { + let data = request.resource.data; + return data.keys().hasOnly(['id', 'clubId', 'authorUid', 'authorDisplayName', + 'authorHandle', 'authorAvatarUrl', 'authorDisplayRank', 'authorRole', + 'content', 'createdAt', 'isPinned', 'fistBumpCount', 'fistBumpUserIds', 'comments']) + && data.id == postId && data.clubId == clubId + && validWallAuthor(data) && validWallContent(data, 5000) + && data.createdAt is timestamp + && data.isPinned is bool && (!data.isPinned || canModerateWall()) + && data.fistBumpCount == 0 && data.fistBumpUserIds == [] && data.comments == []; + } + + function validReplyAppend() { + let old = resource.data.comments; + let next = request.resource.data.comments; + let reply = next[next.size() - 1]; + return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['comments']) + && old is list && next is list && old.size() < 200 + && next.size() == old.size() + 1 && next == old.concat([reply]) + && reply is map + && reply.keys().hasOnly(['id', 'postId', 'authorUid', 'authorDisplayName', + 'authorHandle', 'authorAvatarUrl', 'authorDisplayRank', 'authorRole', 'content', 'createdAt']) + && reply.id is string && reply.id.size() > 0 && reply.id.size() <= 100 + && reply.postId == postId && validWallAuthor(reply) && validWallContent(reply, 2000) + // Mobile serializes embedded comment timestamps as ISO strings. + && reply.createdAt is string && reply.createdAt.size() <= 40 + && reply.createdAt.matches('^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*Z$'); + } + + function validFistBumpToggle() { + let old = resource.data.fistBumpUserIds; + let next = request.resource.data.fistBumpUserIds; + return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['fistBumpCount', 'fistBumpUserIds']) + && next is list && next.size() <= 5000 + && next.size() == next.toSet().size() + && request.resource.data.fistBumpCount == next.size() + && ((!(request.auth.uid in old) && next == old.concat([request.auth.uid])) + || (request.auth.uid in old && next == old.removeAll([request.auth.uid]))); + } + + allow read: if canUseWall(); + allow create: if canUseWall() && validPostCreate(); + allow update: if canUseWall() && ( + (canModerateWall() + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isPinned']) + && request.resource.data.isPinned is bool) + || validReplyAppend() || validFistBumpToggle() + ); + allow delete: if canUseWall() && (canModerateWall() || resource.data.authorUid == request.auth.uid); + } + // --- ADMINISTRATIVE BANS SUBCOLLECTION --- match /bans/{bannedUid} { allow read, write: if isRegisteredUser() && isClubAdmin(request.auth.uid); diff --git a/website/app/clubs/[clubId]/manage/members/page.tsx b/website/app/clubs/[clubId]/manage/members/page.tsx new file mode 100644 index 0000000..7042d75 --- /dev/null +++ b/website/app/clubs/[clubId]/manage/members/page.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { use } from 'react'; +import { FounderCommunityPage } from '../../../../../components/founder-portal/founder-community-page'; + +export default function MembersPage({ params }: { params: Promise<{ clubId: string }> }) { + const { clubId } = use(params); + return ; +} diff --git a/website/app/clubs/[clubId]/manage/wall/page.tsx b/website/app/clubs/[clubId]/manage/wall/page.tsx new file mode 100644 index 0000000..80a2d9d --- /dev/null +++ b/website/app/clubs/[clubId]/manage/wall/page.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { use } from 'react'; +import { FounderCommunityPage } from '../../../../../components/founder-portal/founder-community-page'; + +export default function WallPage({ params }: { params: Promise<{ clubId: string }> }) { + const { clubId } = use(params); + return ; +} diff --git a/website/components/founder-portal/community-dialog.tsx b/website/components/founder-portal/community-dialog.tsx new file mode 100644 index 0000000..896f6e3 --- /dev/null +++ b/website/components/founder-portal/community-dialog.tsx @@ -0,0 +1,28 @@ +'use client'; + +import React, { useEffect, useRef } from 'react'; + +export function CommunityDialog({ title, busy, onClose, children }: { + title: string; busy: boolean; onClose: () => void; children: React.ReactNode; +}) { + const ref = useRef(null); + useEffect(() => { + const dialog = ref.current; + dialog?.showModal(); + return () => dialog?.close(); + }, []); + return ( + { + event.preventDefault(); + if (!busy) onClose(); + }} className="w-[calc(100%_-_2rem)] max-w-lg rounded-2xl border border-brand-border bg-brand-card p-6 text-on-surface backdrop:bg-black/80"> +

{title}

+ {children} + +
+ ); +} + +export const communityInput = 'w-full rounded-lg border border-brand-border bg-brand-bg px-3 py-2 text-on-surface focus:outline-none focus:border-brand-cyan'; +export const communityButton = 'rounded-lg border border-brand-border px-4 py-2 text-sm hover:border-brand-cyan focus-visible:outline focus-visible:outline-brand-cyan disabled:opacity-50 disabled:cursor-not-allowed'; diff --git a/website/components/founder-portal/founder-community-page.tsx b/website/components/founder-portal/founder-community-page.tsx new file mode 100644 index 0000000..1647a22 --- /dev/null +++ b/website/components/founder-portal/founder-community-page.tsx @@ -0,0 +1,61 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { onAuthStateChanged } from 'firebase/auth'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { firebaseAuth } from '../../lib/firebase-client'; +import { fetchFounderClubContext, sanitizePortalError, type FounderClubContext } from '../../lib/club-data'; +import { FounderPortalShell } from './founder-portal-shell'; +import { FounderMembers } from './founder-members'; +import { FounderWall } from './founder-wall'; +import { OverviewSkeleton } from './skeleton-state'; +import { SafeErrorState } from './safe-error-state'; + +export function FounderCommunityPage({ clubId, section }: { clubId: string; section: 'members' | 'wall' }) { + const router = useRouter(); + const searchParams = useSearchParams(); + const demo = process.env.NODE_ENV === 'development' && searchParams?.get('preview') === 'demo'; + const [context, setContext] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [access, setAccess] = useState<'sign-in' | 'restricted' | null>(null); + const [retry, setRetry] = useState(0); + + useEffect(() => { + let generation = 0; + let disposed = false; + const unsubscribe = onAuthStateChanged(firebaseAuth, async (user) => { + const current = ++generation; + setContext(null); setLoading(true); setAccess(null); setError(null); + if (!demo && (!user || user.isAnonymous)) { + setAccess('sign-in'); setLoading(false); return; + } + try { + const result = await fetchFounderClubContext(clubId, user?.uid, { allowDemoPreview: demo }); + if (disposed || current !== generation) return; + if (!result.permissions.canManageSettings && !result.isDemoFixture) setAccess('restricted'); + else setContext(result); + } catch (err) { + if (!disposed && current === generation) setError(sanitizePortalError(err)); + } finally { + if (!disposed && current === generation) setLoading(false); + } + }); + return () => { disposed = true; generation++; unsubscribe(); }; + }, [clubId, demo, retry]); + + if (loading) return
; + if (access) return router.push(access === 'sign-in' ? '/login' : '/clubs')} />; + if (error || !context) return setRetry((value) => value + 1)} actionLabel="Return to Clubs" onAction={() => router.push('/clubs')} />; + return { + if (section === 'wall') document.getElementById('wall-post-content')?.focus(); + else router.push(`/clubs/${clubId}/manage/wall${demo ? '?preview=demo' : ''}#wall-post-content`); + }}> + {section === 'members' ? + : } + ; +} diff --git a/website/components/founder-portal/founder-members.tsx b/website/components/founder-portal/founder-members.tsx new file mode 100644 index 0000000..8569f5d --- /dev/null +++ b/website/components/founder-portal/founder-members.tsx @@ -0,0 +1,146 @@ +'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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(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 ( +
+
+

CLUB MANAGEMENT / ROSTER

+

Members

+

Your active roster, officer ranks, and membership controls.

+ +
+ {context.isDemoFixture &&

Development preview • changes are disabled.

} +
+ {([['Members loaded', members.length], ['Administrators loaded', members.filter((m) => m.role === 'admin' || m.role === 'owner').length], ['Showing', visible.length]] as const).map(([label, count]) => ( +
+

{label}

{loading || error ? '—' : count}

+
+ ))} +
+ {hasMore &&

Search and counts cover loaded members. Load more to include the rest of your roster.

} +
+
+ + +
+

{notice}

+ {loading ?

Loading members…

+ : error ?

{error}

+ : visible.length === 0 ?

{members.length ? 'No members match your search.' : 'There are no active members to display.'}

+ :
+ + {visible.map((member) => + + + + + )} +
RiderDisplay rankAccessActions

{member.displayName || 'Rider'}

{member.handle || member.uid}

{member.displayRank || 'Member'}{member.role}
+ + {canRemove(member) && } +
} +
+

Display ranks are club titles. Owner and Admin roles control administrative access.

+ {selection && setSelection(null)}> +
+

{selection.member.displayName || selection.member.uid}

+ {selection.action === 'rank' ? <> + + {DEFAULT_CLUB_RANKS.map((item) => +

This title does not grant administrative permissions.

+ :

This removes club access. The rider account and membership history will remain.

} + {actionError &&

{actionError}

} + +
+
} +
+ ); +} diff --git a/website/components/founder-portal/founder-sidebar.tsx b/website/components/founder-portal/founder-sidebar.tsx index 5bd75ab..7e4b596 100644 --- a/website/components/founder-portal/founder-sidebar.tsx +++ b/website/components/founder-portal/founder-sidebar.tsx @@ -41,7 +41,7 @@ export function FounderSidebar({ const navItemsTop = [ { id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary', disabled: false }, { id: 'profile', label: 'Club Profile', icon: 'shield', disabled: false }, - { id: 'members', label: 'Members', icon: 'group', badge: 'SOON', badgeType: 'disabled', disabled: true }, + { id: 'members', label: 'Members', icon: 'group', disabled: false }, { id: 'applications', label: 'Applications', @@ -63,7 +63,7 @@ export function FounderSidebar({ disabled: false, }, { id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true }, - { id: 'wall', label: 'Wall', icon: 'forum', badge: 'SOON', badgeType: 'disabled', disabled: true }, + { id: 'wall', label: 'Wall', icon: 'forum', disabled: false }, { id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true }, { id: 'stats', label: 'Stats', icon: 'query_stats', badge: 'SOON', badgeType: 'disabled', disabled: true }, ]; @@ -76,6 +76,8 @@ export function FounderSidebar({ function getRouteHref(id: string) { if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`; if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`; + if (id === 'members') return `/clubs/${clubParam}/manage/members${queryParam}`; + if (id === 'wall') return `/clubs/${clubParam}/manage/wall${queryParam}`; if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`; if (id === 'ranks_roles') return `/clubs/${clubParam}/manage/ranks-roles${queryParam}`; if (id === 'rides') return `/clubs/${clubParam}/manage/rides${queryParam}`; @@ -102,7 +104,7 @@ export function FounderSidebar({ const isSettings = item.id === 'settings'; const isApplications = item.id === 'applications'; const isRides = item.id === 'rides'; - const isSubRoute = isProfile || isSettings || isApplications || isRides || item.id === 'ranks_roles'; + const isSubRoute = isProfile || isSettings || isApplications || isRides || item.id === 'ranks_roles' || item.id === 'members' || item.id === 'wall'; if (item.disabled) { return ( diff --git a/website/components/founder-portal/founder-wall.tsx b/website/components/founder-portal/founder-wall.tsx new file mode 100644 index 0000000..150cc87 --- /dev/null +++ b/website/components/founder-portal/founder-wall.tsx @@ -0,0 +1,101 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import type { FounderClubContext } from '../../lib/club-data'; +import { sanitizePortalError } from '../../lib/club-data'; +import { createWallPost, deleteWallPost, replyToWallPost, setWallPostPinned, subscribeClubWall, type WallPost } from '../../lib/club-community'; +import { CommunityDialog, communityButton, communityInput } from './community-dialog'; + +function postDate(value: string): string { + if (!value) return 'Sending…'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? 'Date unavailable' : date.toLocaleString(); +} + +export function FounderWall({ context }: { context: FounderClubContext }) { + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [actionError, setActionError] = useState(null); + const [content, setContent] = useState(''); + const [pinned, setPinned] = useState(false); + const [replies, setReplies] = useState>({}); + const [busy, setBusy] = useState(null); + const busyRef = useRef(false); + const [removing, setRemoving] = useState(null); + const [retry, setRetry] = useState(0); + const [notice, setNotice] = useState(''); + + useEffect(() => { + setLoading(true); setLoadError(null); setPosts([]); + if (context.isDemoFixture) { setLoading(false); return; } + return subscribeClubWall(context.club.id, (items) => { setPosts(items); setLoading(false); }, (err) => { + setPosts([]); setLoading(false); setLoadError(sanitizePortalError(err, 'The club wall could not be loaded.')); + }); + }, [context, retry]); + + async function perform(key: string, action: () => Promise, after: () => void) { + if (busyRef.current || context.isDemoFixture) return; + busyRef.current = true; setBusy(key); setActionError(null); setNotice(''); + try { await action(); after(); setNotice('Changes saved.'); } + catch (err) { setActionError(sanitizePortalError(err, 'Your changes could not be saved. Please try again.')); } + finally { busyRef.current = false; setBusy(null); } + } + const disabled = context.isDemoFixture || !!busy || loading || !!loadError; + + return ( +
+

CLUB MANAGEMENT / COMMUNITY

+

Club Wall

+

Leadership announcements and conversations with your club.

+ {context.isDemoFixture &&

Development preview • changes are disabled.

} +
{ + event.preventDefault(); + void perform('compose', () => createWallPost(context.club.id, content, pinned), () => { setContent(''); setPinned(false); }); + }}> +