Merge pull request #6 from bponders12-cell/fix/clubs-management-repair
fix(web): repair clubs management experience
This commit is contained in:
commit
e784c4895d
|
|
@ -3013,6 +3013,120 @@ describe('Social Architecture Security Rules', () => {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('FP-14.3: Club logo/banner HTTPS URL invariant enforced at the storage layer', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||||
|
const db = context.firestore();
|
||||||
|
await setDoc(doc(db, 'clubs/club_image_url_sec'), {
|
||||||
|
id: 'club_image_url_sec',
|
||||||
|
name: 'Image URL Security MC',
|
||||||
|
slug: 'image-url-security-mc',
|
||||||
|
visibility: 'public',
|
||||||
|
admissionPolicy: 'request',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
memberCount: 3,
|
||||||
|
archivedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_image_url_sec/members/alice'), {
|
||||||
|
uid: 'alice',
|
||||||
|
clubId: 'club_image_url_sec',
|
||||||
|
role: 'owner',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_image_url_sec/members/bob'), {
|
||||||
|
uid: 'bob',
|
||||||
|
clubId: 'club_image_url_sec',
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_image_url_sec/members/charlie'), {
|
||||||
|
uid: 'charlie',
|
||||||
|
clubId: 'club_image_url_sec',
|
||||||
|
role: 'member',
|
||||||
|
status: 'active',
|
||||||
|
displayRank: 'President',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const clubDocRef = (db) => doc(db, 'clubs/club_image_url_sec');
|
||||||
|
|
||||||
|
// 1. Owner sets valid https logo/banner -> ALLOW
|
||||||
|
await assertSucceeds(updateDoc(clubDocRef(alice), {
|
||||||
|
logoUrl: 'https://example.com/logo.png',
|
||||||
|
bannerUrl: 'https://cdn.example.com/banner.jpg',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Owner clears logo with null and banner with empty string -> ALLOW
|
||||||
|
await assertSucceeds(updateDoc(clubDocRef(alice), {
|
||||||
|
logoUrl: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
await assertSucceeds(updateDoc(clubDocRef(alice), {
|
||||||
|
bannerUrl: '',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3. Admin sets valid https URLs -> ALLOW
|
||||||
|
await assertSucceeds(updateDoc(clubDocRef(bob), {
|
||||||
|
logoUrl: 'https://example.com/admin-logo.png',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4. Owner http:// URL -> DENY (insecure scheme)
|
||||||
|
await assertFails(updateDoc(clubDocRef(alice), {
|
||||||
|
logoUrl: 'http://evil.example.com/logo.png',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 5. Owner data: URL -> DENY
|
||||||
|
await assertFails(updateDoc(clubDocRef(alice), {
|
||||||
|
logoUrl: 'data:image/png;base64,iVBORw0KGgo=',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 6. Owner blob: URL -> DENY
|
||||||
|
await assertFails(updateDoc(clubDocRef(alice), {
|
||||||
|
bannerUrl: 'blob:https://example.com/fake-uuid',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 7. Owner javascript: URL -> DENY
|
||||||
|
await assertFails(updateDoc(clubDocRef(alice), {
|
||||||
|
bannerUrl: 'javascript:alert(1)',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 8. Owner malformed value -> DENY
|
||||||
|
await assertFails(updateDoc(clubDocRef(alice), {
|
||||||
|
logoUrl: 'not-a-url',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 9. Owner non-string URL value -> DENY
|
||||||
|
await assertFails(updateDoc(clubDocRef(alice), {
|
||||||
|
logoUrl: 42,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 10. Admin http:// URL -> DENY (same invariant for admins)
|
||||||
|
await assertFails(updateDoc(clubDocRef(bob), {
|
||||||
|
bannerUrl: 'http://evil.example.com/banner.jpg',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 11. Regular member writes valid https URL -> DENY (no management authority at all)
|
||||||
|
await assertFails(updateDoc(clubDocRef(charlie), {
|
||||||
|
logoUrl: 'https://example.com/member-logo.png',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
test('FP-15: Club Applications membership request approval and decline security matrix', async () => {
|
test('FP-15: Club Applications membership request approval and decline security matrix', async () => {
|
||||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
|
|
||||||
|
|
@ -701,6 +701,16 @@ service cloud.firestore {
|
||||||
return exists(/databases/$(database)/documents/clubs/$(clubId)/bans/$(uid));
|
return exists(/databases/$(database)/documents/clubs/$(clubId)/bans/$(uid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Club logo/banner URLs: null or empty string clears; otherwise must be
|
||||||
|
// an https:// URL (case-insensitive scheme, matching URL() semantics in the app layer).
|
||||||
|
function isValidClubImageUrl(url) {
|
||||||
|
return url == null
|
||||||
|
|| url == ''
|
||||||
|
|| (url is string
|
||||||
|
&& url.size() <= 2048
|
||||||
|
&& url.matches('(?i)^https://\\S+$'));
|
||||||
|
}
|
||||||
|
|
||||||
function isValidMemberCountIncrement() {
|
function isValidMemberCountIncrement() {
|
||||||
let targetUid = ('lastMembershipChangeUid' in request.resource.data)
|
let targetUid = ('lastMembershipChangeUid' in request.resource.data)
|
||||||
? request.resource.data.lastMembershipChangeUid
|
? request.resource.data.lastMembershipChangeUid
|
||||||
|
|
@ -779,6 +789,8 @@ service cloud.firestore {
|
||||||
&& (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
|
&& (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
|
||||||
&& (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private'])
|
&& (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private'])
|
||||||
&& (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly'])
|
&& (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly'])
|
||||||
|
&& (!('logoUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.logoUrl))
|
||||||
|
&& (!('bannerUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.bannerUrl))
|
||||||
) ||
|
) ||
|
||||||
// Case 3: Admin editing metadata and access settings (cannot change ownerUid, slug, createdAt, archivedAt, memberCount)
|
// Case 3: Admin editing metadata and access settings (cannot change ownerUid, slug, createdAt, archivedAt, memberCount)
|
||||||
(
|
(
|
||||||
|
|
@ -790,6 +802,8 @@ service cloud.firestore {
|
||||||
&& (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
|
&& (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
|
||||||
&& (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private'])
|
&& (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private'])
|
||||||
&& (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly'])
|
&& (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly'])
|
||||||
|
&& (!('logoUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.logoUrl))
|
||||||
|
&& (!('bannerUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.bannerUrl))
|
||||||
) ||
|
) ||
|
||||||
// Case 4: Member count increment/decrement by membership transitions ONLY
|
// Case 4: Member count increment/decrement by membership transitions ONLY
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
fetchFounderClubContext,
|
fetchFounderClubContext,
|
||||||
FounderClubContext,
|
FounderClubContext,
|
||||||
|
fetchClubApplications,
|
||||||
sanitizePortalError,
|
sanitizePortalError,
|
||||||
} from '../../../../../lib/club-data';
|
} from '../../../../../lib/club-data';
|
||||||
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
|
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
|
||||||
|
|
@ -23,6 +24,7 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [context, setContext] = useState<FounderClubContext | null>(null);
|
const [context, setContext] = useState<FounderClubContext | null>(null);
|
||||||
|
const [pendingApplicationsCount, setPendingApplicationsCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [unauthenticated, setUnauthenticated] = useState(false);
|
const [unauthenticated, setUnauthenticated] = useState(false);
|
||||||
|
|
@ -58,6 +60,15 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setContext(ctx);
|
setContext(ctx);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apps = await fetchClubApplications(ctx.club.id, currentUser?.uid, {
|
||||||
|
allowDemoPreview: isDemoPreview,
|
||||||
|
});
|
||||||
|
setPendingApplicationsCount(apps.length);
|
||||||
|
} catch {
|
||||||
|
setPendingApplicationsCount(0);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(sanitizePortalError(err, "Club applications couldn't be loaded."));
|
setError(sanitizePortalError(err, "Club applications couldn't be loaded."));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -71,9 +82,11 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface p-6">
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
<OverviewSkeleton />
|
<OverviewSkeleton />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,14 +138,15 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
|
||||||
club={context.club}
|
club={context.club}
|
||||||
permissions={context.permissions}
|
permissions={context.permissions}
|
||||||
activeNav="applications"
|
activeNav="applications"
|
||||||
pendingApplicationsCount={4}
|
pendingApplicationsCount={pendingApplicationsCount}
|
||||||
upcomingRidesCount={2}
|
upcomingRidesCount={0}
|
||||||
>
|
>
|
||||||
<FounderApplications
|
<FounderApplications
|
||||||
club={context.club}
|
club={context.club}
|
||||||
permissions={context.permissions}
|
permissions={context.permissions}
|
||||||
callerUid={context.currentUser.uid}
|
callerUid={context.currentUser.uid}
|
||||||
isDemoFixture={context.isDemoFixture}
|
isDemoFixture={context.isDemoFixture}
|
||||||
|
onApplicationsCountChange={setPendingApplicationsCount}
|
||||||
/>
|
/>
|
||||||
</FounderPortalShell>
|
</FounderPortalShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,11 @@ export default function ClubFounderProfilePage({ params }: PageProps) {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface p-6">
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
<OverviewSkeleton />
|
<OverviewSkeleton />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,8 +127,8 @@ export default function ClubFounderProfilePage({ params }: PageProps) {
|
||||||
club={context.club}
|
club={context.club}
|
||||||
permissions={context.permissions}
|
permissions={context.permissions}
|
||||||
activeNav="profile"
|
activeNav="profile"
|
||||||
pendingApplicationsCount={4}
|
pendingApplicationsCount={0}
|
||||||
upcomingRidesCount={2}
|
upcomingRidesCount={0}
|
||||||
>
|
>
|
||||||
<FounderProfileEditor
|
<FounderProfileEditor
|
||||||
club={context.club}
|
club={context.club}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, use } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
fetchFounderClubContext,
|
||||||
|
FounderClubContext,
|
||||||
|
sanitizePortalError,
|
||||||
|
} from '../../../../../lib/club-data';
|
||||||
|
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
|
||||||
|
import { RolesAndRanksSummary } from '../../../../../components/founder-portal/roles-and-ranks-summary';
|
||||||
|
import { OverviewSkeleton } from '../../../../../components/founder-portal/skeleton-state';
|
||||||
|
import { SafeErrorState } from '../../../../../components/founder-portal/safe-error-state';
|
||||||
|
import { firebaseAuth } from '../../../../../lib/firebase-client';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ clubId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClubFounderRanksRolesPage({ params }: PageProps) {
|
||||||
|
const { clubId } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [context, setContext] = useState<FounderClubContext | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [unauthenticated, setUnauthenticated] = useState(false);
|
||||||
|
const [unauthorized, setUnauthorized] = useState(false);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setUnauthenticated(false);
|
||||||
|
setUnauthorized(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await firebaseAuth.authStateReady();
|
||||||
|
const currentUser = firebaseAuth.currentUser;
|
||||||
|
const isDemoPreview = searchParams?.get('preview') === 'demo';
|
||||||
|
|
||||||
|
// Unauthenticated Guard: Direct access without demo preview requires authentication
|
||||||
|
if (!isDemoPreview && !currentUser) {
|
||||||
|
setUnauthenticated(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await fetchFounderClubContext(clubId, currentUser?.uid, {
|
||||||
|
allowDemoPreview: isDemoPreview,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authorization Guard: Owner and Admin can access Ranks & Roles; Members cannot
|
||||||
|
if (!isDemoPreview && !ctx.permissions.canManageSettings) {
|
||||||
|
setUnauthorized(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContext(ctx);
|
||||||
|
} catch (err) {
|
||||||
|
setError(sanitizePortalError(err, "Ranks and roles couldn't be loaded."));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [clubId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<OverviewSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthenticated safe error state
|
||||||
|
if (unauthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
|
||||||
|
<SafeErrorState
|
||||||
|
title="Authentication Required"
|
||||||
|
message="Sign in with your rider account to access club ranks and roles."
|
||||||
|
actionLabel="Sign In"
|
||||||
|
onAction={() => router.push('/login')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthorized (Member or non-admin) safe error state
|
||||||
|
if (unauthorized) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
|
||||||
|
<SafeErrorState
|
||||||
|
title="Management Access Restricted"
|
||||||
|
message="You must be an Owner or Admin of this Club to access Ranks & Roles."
|
||||||
|
actionLabel="Return to Clubs"
|
||||||
|
onAction={() => router.push('/clubs')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !context) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
|
||||||
|
<SafeErrorState
|
||||||
|
title="Ranks & Roles Unavailable"
|
||||||
|
message={error ?? "Ranks and roles couldn't be loaded."}
|
||||||
|
actionLabel="Return to Clubs"
|
||||||
|
onAction={() => router.push('/clubs')}
|
||||||
|
onRetry={loadData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FounderPortalShell
|
||||||
|
club={context.club}
|
||||||
|
permissions={context.permissions}
|
||||||
|
activeNav="ranks_roles"
|
||||||
|
pendingApplicationsCount={0}
|
||||||
|
upcomingRidesCount={0}
|
||||||
|
>
|
||||||
|
<div className="max-w-6xl mx-auto space-y-6 pb-12">
|
||||||
|
{/* Header */}
|
||||||
|
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-xs text-brand-cyan tracking-widest bg-brand-cyan/10 px-2.5 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
|
||||||
|
Governance & Authority
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-slate-400 tracking-wider">
|
||||||
|
// {context.club.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-headline font-bold text-2xl sm:text-3xl text-white mt-2 tracking-tight">
|
||||||
|
Ranks & Roles
|
||||||
|
</h1>
|
||||||
|
<p className="font-body text-sm text-slate-400 mt-1">
|
||||||
|
Administrative role authority vs honorary squad rank designations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Roles and Ranks Summary Component */}
|
||||||
|
<RolesAndRanksSummary
|
||||||
|
ownerName={context.ownerProfile?.displayName || context.club.founderName || 'Club Founder'}
|
||||||
|
memberCount={context.club.memberCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FounderPortalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -71,16 +71,19 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface p-6">
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
<OverviewSkeleton />
|
<OverviewSkeleton />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unauthenticated safe error state
|
// Unauthenticated safe error state
|
||||||
if (unauthenticated) {
|
if (unauthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
|
||||||
<SafeErrorState
|
<SafeErrorState
|
||||||
title="Authentication Required"
|
title="Authentication Required"
|
||||||
message="Sign in with your rider account to access club settings."
|
message="Sign in with your rider account to access club settings."
|
||||||
|
|
@ -88,13 +91,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
|
||||||
onAction={() => router.push('/login')}
|
onAction={() => router.push('/login')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unauthorized (Member or non-admin) safe error state
|
// Unauthorized (Member or non-admin) safe error state
|
||||||
if (unauthorized) {
|
if (unauthorized) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
|
||||||
<SafeErrorState
|
<SafeErrorState
|
||||||
title="Management Access Restricted"
|
title="Management Access Restricted"
|
||||||
message="You must be an Owner or Admin of this Club to access Club Settings."
|
message="You must be an Owner or Admin of this Club to access Club Settings."
|
||||||
|
|
@ -102,13 +107,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
|
||||||
onAction={() => router.push('/clubs')}
|
onAction={() => router.push('/clubs')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error or missing context
|
// Error or missing context
|
||||||
if (error || !context) {
|
if (error || !context) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
|
||||||
<SafeErrorState
|
<SafeErrorState
|
||||||
title="Settings Unavailable"
|
title="Settings Unavailable"
|
||||||
message={error ?? "Club settings couldn't be loaded."}
|
message={error ?? "Club settings couldn't be loaded."}
|
||||||
|
|
@ -117,6 +124,7 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
|
||||||
onRetry={loadData}
|
onRetry={loadData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,8 +133,8 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
|
||||||
club={context.club}
|
club={context.club}
|
||||||
permissions={context.permissions}
|
permissions={context.permissions}
|
||||||
activeNav="settings"
|
activeNav="settings"
|
||||||
pendingApplicationsCount={4}
|
pendingApplicationsCount={0}
|
||||||
upcomingRidesCount={2}
|
upcomingRidesCount={0}
|
||||||
>
|
>
|
||||||
<FounderSettingsEditor
|
<FounderSettingsEditor
|
||||||
club={context.club}
|
club={context.club}
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,7 @@ export default function ClubManageDiscoveryPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 self-end sm:self-center font-mono text-xs text-brand-cyan group-hover:translate-x-1 transition-transform shrink-0 font-medium">
|
<div className="flex items-center gap-2 self-end sm:self-center font-mono text-xs text-brand-cyan group-hover:translate-x-1 transition-transform shrink-0 font-medium">
|
||||||
<span>Manage Cockpit</span>
|
<span>Manage Club</span>
|
||||||
<span className="material-symbols-outlined text-[20px]">
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
arrow_forward
|
arrow_forward
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -276,7 +276,7 @@ export default function ClubManageDiscoveryPage() {
|
||||||
<span className="material-symbols-outlined text-[18px] text-brand-cyan">shield</span>
|
<span className="material-symbols-outlined text-[18px] text-brand-cyan">shield</span>
|
||||||
<span>Only clubs where you are an active Owner or Admin appear here.</span>
|
<span>Only clubs where you are an active Owner or Admin appear here.</span>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/clubs" className="font-mono text-xs text-brand-cyan hover:underline">
|
<Link href="/clubs#directory" className="font-mono text-xs text-brand-cyan hover:underline">
|
||||||
All Public Clubs →
|
All Public Clubs →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export function AttentionSection({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3 Triage Cards Grid */}
|
{/* 3 Triage Cards Grid */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||||
{/* Card 1: Membership Applications */}
|
{/* Card 1: Membership Applications */}
|
||||||
<div className="rounded-xl bg-surface-container/60 border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-md">
|
<div className="rounded-xl bg-surface-container/60 border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-md">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -100,9 +100,9 @@ export function AttentionSection({
|
||||||
{pendingApplications.slice(0, 3).map((app) => (
|
{pendingApplications.slice(0, 3).map((app) => (
|
||||||
<div
|
<div
|
||||||
key={app.uid}
|
key={app.uid}
|
||||||
className="p-2.5 rounded-lg bg-surface-container-high border border-brand-border/60 flex items-center justify-between gap-2"
|
className="p-2.5 rounded-lg bg-surface-container-high border border-brand-border/60 flex items-center justify-between gap-2 flex-wrap"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-body text-xs font-semibold text-white truncate">
|
<div className="font-body text-xs font-semibold text-white truncate">
|
||||||
{app.displayName ?? 'New Rider'}
|
{app.displayName ?? 'New Rider'}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,7 +111,7 @@ export function AttentionSection({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||||
{permissions.canApproveApplications && (
|
{permissions.canApproveApplications && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onApproveApplication?.(app.uid)}
|
onClick={() => onApproveApplication?.(app.uid)}
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ export function ClubMasthead({
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-slate-500">Founder:</span>
|
<span className="text-slate-500">Founder:</span>
|
||||||
<span className="text-slate-200 font-medium">
|
<span className="text-slate-200 font-medium">
|
||||||
{club.founderName ?? 'Marcus Vance'}
|
{club.founderName ?? 'Club Founder'}
|
||||||
</span>
|
</span>
|
||||||
{club.founderHandle && (
|
{club.founderHandle && (
|
||||||
<span className="font-mono text-[11px] text-brand-cyan">
|
<span className="font-mono text-[11px] text-brand-cyan">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
import { ClubUpcomingRide, UserPortalPermissions } from './types';
|
import { ClubUpcomingRide, UserPortalPermissions } from './types';
|
||||||
import { EmptyState } from './empty-state';
|
import { EmptyState } from './empty-state';
|
||||||
|
|
||||||
interface UpcomingRidesSectionProps {
|
interface UpcomingRidesSectionProps {
|
||||||
rides: ClubUpcomingRide[];
|
rides: ClubUpcomingRide[];
|
||||||
permissions: UserPortalPermissions;
|
permissions: UserPortalPermissions;
|
||||||
|
clubId?: string;
|
||||||
onPlanRide?: () => void;
|
onPlanRide?: () => void;
|
||||||
onManageRide?: (rideId: string) => void;
|
onManageRide?: (rideId: string) => void;
|
||||||
onViewRoster?: (rideId: string) => void;
|
onViewRoster?: (rideId: string) => void;
|
||||||
|
|
@ -16,16 +19,20 @@ interface UpcomingRidesSectionProps {
|
||||||
export function ClubRideCard({
|
export function ClubRideCard({
|
||||||
ride,
|
ride,
|
||||||
permissions,
|
permissions,
|
||||||
|
clubId,
|
||||||
onManageRide,
|
onManageRide,
|
||||||
onViewRoster,
|
onViewRoster,
|
||||||
onViewRoute,
|
onViewRoute,
|
||||||
}: {
|
}: {
|
||||||
ride: ClubUpcomingRide;
|
ride: ClubUpcomingRide;
|
||||||
permissions: UserPortalPermissions;
|
permissions: UserPortalPermissions;
|
||||||
|
clubId?: string;
|
||||||
onManageRide?: (rideId: string) => void;
|
onManageRide?: (rideId: string) => void;
|
||||||
onViewRoster?: (rideId: string) => void;
|
onViewRoster?: (rideId: string) => void;
|
||||||
onViewRoute?: (routeId?: string) => void;
|
onViewRoute?: (routeId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const params = useParams();
|
||||||
|
const targetClubId = clubId || (typeof params?.clubId === 'string' ? params.clubId : '');
|
||||||
const capacityPercent = ride.capacity ? Math.min(100, Math.round((ride.confirmedCount / ride.capacity) * 100)) : 75;
|
const capacityPercent = ride.capacity ? Math.min(100, Math.round((ride.confirmedCount / ride.capacity) * 100)) : 75;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -144,8 +151,10 @@ export function ClubRideCard({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onViewRoster?.(ride.id)}
|
type="button"
|
||||||
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs font-medium hover:bg-surface-container-high transition-colors cursor-pointer"
|
disabled
|
||||||
|
title="Member roster view is planned for a future release"
|
||||||
|
className="h-9 px-3.5 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-500 font-body text-xs font-medium cursor-not-allowed flex items-center justify-center"
|
||||||
>
|
>
|
||||||
View Roster ({ride.confirmedCount})
|
View Roster ({ride.confirmedCount})
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -166,11 +175,15 @@ export function ClubRideCard({
|
||||||
export function UpcomingRidesSection({
|
export function UpcomingRidesSection({
|
||||||
rides,
|
rides,
|
||||||
permissions,
|
permissions,
|
||||||
|
clubId,
|
||||||
onPlanRide,
|
onPlanRide,
|
||||||
onManageRide,
|
onManageRide,
|
||||||
onViewRoster,
|
onViewRoster,
|
||||||
onViewRoute,
|
onViewRoute,
|
||||||
}: UpcomingRidesSectionProps) {
|
}: UpcomingRidesSectionProps) {
|
||||||
|
const params = useParams();
|
||||||
|
const targetClubId = clubId || (typeof params?.clubId === 'string' ? params.clubId : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4" aria-label="Upcoming Club Rides">
|
<section className="space-y-4" aria-label="Upcoming Club Rides">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -184,13 +197,13 @@ export function UpcomingRidesSection({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rides.length > 0 && (
|
{rides.length > 0 && (
|
||||||
<button
|
<span
|
||||||
onClick={() => onViewRoster?.('all')}
|
title="Full ride schedule is planned for a future release"
|
||||||
className="font-mono text-xs text-brand-cyan hover:underline flex items-center gap-1 cursor-pointer"
|
className="font-mono text-xs text-slate-500 flex items-center gap-1 cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span>Full Ride Schedule</span>
|
<span>Full Ride Schedule</span>
|
||||||
<span className="material-symbols-outlined text-[13px]">arrow_forward</span>
|
<span className="material-symbols-outlined text-[13px] text-slate-600">schedule</span>
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -210,6 +223,7 @@ export function UpcomingRidesSection({
|
||||||
key={ride.id}
|
key={ride.id}
|
||||||
ride={ride}
|
ride={ride}
|
||||||
permissions={permissions}
|
permissions={permissions}
|
||||||
|
clubId={targetClubId}
|
||||||
onManageRide={onManageRide}
|
onManageRide={onManageRide}
|
||||||
onViewRoster={onViewRoster}
|
onViewRoster={onViewRoster}
|
||||||
onViewRoute={onViewRoute}
|
onViewRoute={onViewRoute}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ interface FounderApplicationsProps {
|
||||||
permissions: UserPortalPermissions;
|
permissions: UserPortalPermissions;
|
||||||
callerUid: string;
|
callerUid: string;
|
||||||
isDemoFixture?: boolean;
|
isDemoFixture?: boolean;
|
||||||
|
onApplicationsCountChange?: (count: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FounderApplications({
|
export function FounderApplications({
|
||||||
|
|
@ -22,6 +23,7 @@ export function FounderApplications({
|
||||||
permissions,
|
permissions,
|
||||||
callerUid,
|
callerUid,
|
||||||
isDemoFixture = false,
|
isDemoFixture = false,
|
||||||
|
onApplicationsCountChange,
|
||||||
}: FounderApplicationsProps) {
|
}: FounderApplicationsProps) {
|
||||||
const [applications, setApplications] = useState<ClubApplication[]>([]);
|
const [applications, setApplications] = useState<ClubApplication[]>([]);
|
||||||
const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null);
|
const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null);
|
||||||
|
|
@ -47,6 +49,7 @@ export function FounderApplications({
|
||||||
allowDemoPreview: isDemoFixture,
|
allowDemoPreview: isDemoFixture,
|
||||||
});
|
});
|
||||||
setApplications(data);
|
setApplications(data);
|
||||||
|
onApplicationsCountChange?.(data.length);
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
setSelectedApplicantUid((prev) => prev ?? data[0].uid);
|
setSelectedApplicantUid((prev) => prev ?? data[0].uid);
|
||||||
}
|
}
|
||||||
|
|
@ -122,13 +125,14 @@ export function FounderApplications({
|
||||||
await approveClubApplication(club.id, callerUid, applicant.uid);
|
await approveClubApplication(club.id, callerUid, applicant.uid);
|
||||||
}
|
}
|
||||||
// Remove from list and increment member count
|
// Remove from list and increment member count
|
||||||
setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid));
|
const remaining = applications.filter((a) => a.uid !== applicant.uid);
|
||||||
|
setApplications(remaining);
|
||||||
|
onApplicationsCountChange?.(remaining.length);
|
||||||
setLocalMemberCount((c) => c + 1);
|
setLocalMemberCount((c) => c + 1);
|
||||||
setApproveModalApplicant(null);
|
setApproveModalApplicant(null);
|
||||||
|
|
||||||
// Select next applicant if the approved one was selected
|
// Select next applicant if the approved one was selected
|
||||||
if (selectedApplicantUid === applicant.uid) {
|
if (selectedApplicantUid === applicant.uid) {
|
||||||
const remaining = applications.filter((a) => a.uid !== applicant.uid);
|
|
||||||
setSelectedApplicantUid(remaining[0]?.uid || null);
|
setSelectedApplicantUid(remaining[0]?.uid || null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -148,12 +152,13 @@ export function FounderApplications({
|
||||||
await declineClubApplication(club.id, callerUid, applicant.uid);
|
await declineClubApplication(club.id, callerUid, applicant.uid);
|
||||||
}
|
}
|
||||||
// Remove from list (non-destructive in Firestore, memberCount delta 0)
|
// Remove from list (non-destructive in Firestore, memberCount delta 0)
|
||||||
setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid));
|
const remaining = applications.filter((a) => a.uid !== applicant.uid);
|
||||||
|
setApplications(remaining);
|
||||||
|
onApplicationsCountChange?.(remaining.length);
|
||||||
setDeclineModalApplicant(null);
|
setDeclineModalApplicant(null);
|
||||||
|
|
||||||
// Select next applicant if the declined one was selected
|
// Select next applicant if the declined one was selected
|
||||||
if (selectedApplicantUid === applicant.uid) {
|
if (selectedApplicantUid === applicant.uid) {
|
||||||
const remaining = applications.filter((a) => a.uid !== applicant.uid);
|
|
||||||
setSelectedApplicantUid(remaining[0]?.uid || null);
|
setSelectedApplicantUid(remaining[0]?.uid || null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -164,7 +169,7 @@ export function FounderApplications({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
{/* 1. PAGE HEADER */}
|
{/* 1. PAGE HEADER */}
|
||||||
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
|
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
|
||||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
|
||||||
|
|
@ -352,34 +357,35 @@ export function FounderApplications({
|
||||||
</div>
|
</div>
|
||||||
) : applications.length === 0 ? (
|
) : applications.length === 0 ? (
|
||||||
/* Empty State 1: Default zero-pending queue */
|
/* Empty State 1: Default zero-pending queue */
|
||||||
<div className="p-10 rounded-2xl glass-card border border-brand-border/60 text-center space-y-3 shadow-lg">
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-8 sm:p-12 text-center shadow-xl relative overflow-hidden max-w-2xl mx-auto">
|
||||||
<div className="w-12 h-12 rounded-xl bg-surface-container-high border border-brand-border/60 flex items-center justify-center mx-auto text-slate-400">
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-brand-cyan/10 to-transparent" />
|
||||||
<span className="material-symbols-outlined text-2xl">person_add_disabled</span>
|
<div className="w-14 h-14 rounded-2xl bg-surface-container-high border border-brand-border/80 flex items-center justify-center mx-auto mb-4 text-slate-400 shadow-md">
|
||||||
|
<span className="material-symbols-outlined text-2xl text-slate-300">person_add_disabled</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<h3 className="text-base font-headline font-bold text-white tracking-tight">No Pending Applications</h3>
|
||||||
<h3 className="text-sm font-semibold text-white font-mono">No Pending Applications</h3>
|
<p className="text-xs sm:text-sm text-slate-400 mt-1.5 max-w-md mx-auto leading-relaxed">
|
||||||
<p className="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
|
There are no membership requests waiting for review. New requests will appear here when riders apply to join this Club.
|
||||||
There are no membership requests waiting for review. New requests will appear here when riders apply.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : filteredApplications.length === 0 ? (
|
) : filteredApplications.length === 0 ? (
|
||||||
/* Empty State 2: Filter zero-match */
|
/* Empty State 2: Filter zero-match */
|
||||||
<div className="p-10 rounded-2xl glass-card border border-brand-border/60 text-center space-y-3 shadow-lg">
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-8 sm:p-12 text-center shadow-xl relative overflow-hidden max-w-2xl mx-auto">
|
||||||
<div className="w-12 h-12 rounded-xl bg-surface-container-high border border-brand-border/60 flex items-center justify-center mx-auto text-amber-400">
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-amber-400/50 via-amber-400/10 to-transparent" />
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-surface-container-high border border-brand-border/80 flex items-center justify-center mx-auto mb-4 text-amber-400 shadow-md">
|
||||||
<span className="material-symbols-outlined text-2xl">search_off</span>
|
<span className="material-symbols-outlined text-2xl">search_off</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<h3 className="text-base font-headline font-bold text-white tracking-tight">No Applications Match</h3>
|
||||||
<h3 className="text-sm font-semibold text-white font-mono">No Applications Match</h3>
|
<p className="text-xs sm:text-sm text-slate-400 mt-1.5 max-w-md mx-auto leading-relaxed">
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
|
||||||
No pending applications match "{searchQuery}".
|
No pending applications match "{searchQuery}".
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => setSearchQuery('')}
|
||||||
className="text-xs text-brand-cyan hover:underline font-medium inline-flex items-center space-x-1 mt-2"
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-surface-container-high hover:bg-surface-container-highest border border-brand-border/80 text-xs font-mono text-brand-cyan transition-colors"
|
||||||
>
|
>
|
||||||
<span>Clear Search</span>
|
<span>Clear Search</span>
|
||||||
<span>→</span>
|
<span className="material-symbols-outlined text-sm">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -387,15 +393,15 @@ export function FounderApplications({
|
||||||
/* Two-Column Split View */
|
/* Two-Column Split View */
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||||
{/* Left Column: Dense Application Table */}
|
{/* Left Column: Dense Application Table */}
|
||||||
<div className="lg:col-span-7 xl:col-span-8 rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg flex flex-col">
|
<div className="lg:col-span-7 xl:col-span-7 rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg flex flex-col">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse table-auto">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-brand-border/60 bg-surface-container-high/60 text-[11px] font-mono text-slate-400 tracking-wider">
|
<tr className="border-b border-brand-border/60 bg-surface-container-high/60 text-[11px] font-mono text-slate-400 tracking-wider">
|
||||||
<th className="py-3.5 px-4 font-medium">Rider</th>
|
<th className="py-3.5 px-4 font-medium w-[38%]">Rider</th>
|
||||||
<th className="py-3.5 px-4 font-medium">Requested</th>
|
<th className="py-3.5 px-4 font-medium w-[20%] whitespace-nowrap">Requested</th>
|
||||||
<th className="py-3.5 px-4 font-medium">Status</th>
|
<th className="py-3.5 px-4 font-medium w-[16%] whitespace-nowrap">Status</th>
|
||||||
<th className="py-3.5 px-4 font-medium text-right">Actions</th>
|
<th className="py-3.5 px-4 font-medium w-[26%] text-right whitespace-nowrap">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-brand-border/40 text-xs">
|
<tbody className="divide-y divide-brand-border/40 text-xs">
|
||||||
|
|
@ -490,7 +496,7 @@ export function FounderApplications({
|
||||||
|
|
||||||
{/* Right Column: Selected Application Inspector Drawer */}
|
{/* Right Column: Selected Application Inspector Drawer */}
|
||||||
{selectedApplicant && (
|
{selectedApplicant && (
|
||||||
<div className="lg:col-span-5 xl:col-span-4 space-y-4">
|
<div className="lg:col-span-5 xl:col-span-5 space-y-4">
|
||||||
<div className="rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg relative">
|
<div className="rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg relative">
|
||||||
{/* Top Cyan Accent Line */}
|
{/* Top Cyan Accent Line */}
|
||||||
<div className="h-0.5 w-full bg-gradient-to-r from-brand-cyan via-blue-500 to-transparent" />
|
<div className="h-0.5 w-full bg-gradient-to-r from-brand-cyan via-blue-500 to-transparent" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
Club,
|
Club,
|
||||||
ClubMetrics,
|
ClubMetrics,
|
||||||
|
|
@ -36,6 +37,7 @@ export function FounderOverview({
|
||||||
permissions,
|
permissions,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: FounderOverviewProps) {
|
}: FounderOverviewProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [pendingApps, setPendingApps] = useState<ClubMember[]>(initialApps);
|
const [pendingApps, setPendingApps] = useState<ClubMember[]>(initialApps);
|
||||||
const [rides] = useState<ClubUpcomingRide[]>(initialRides);
|
const [rides] = useState<ClubUpcomingRide[]>(initialRides);
|
||||||
const [metrics, setMetrics] = useState<ClubMetrics>(initialMetrics);
|
const [metrics, setMetrics] = useState<ClubMetrics>(initialMetrics);
|
||||||
|
|
@ -142,9 +144,16 @@ export function FounderOverview({
|
||||||
<UpcomingRidesSection
|
<UpcomingRidesSection
|
||||||
rides={rides}
|
rides={rides}
|
||||||
permissions={permissions}
|
permissions={permissions}
|
||||||
|
clubId={club.slug || club.id}
|
||||||
onPlanRide={() => setActiveModal('plan_ride')}
|
onPlanRide={() => setActiveModal('plan_ride')}
|
||||||
onManageRide={(rideId) => showToast(`Opening management cockpit for Ride ID: ${rideId}`)}
|
onManageRide={(rideId) => showToast(`Opening management cockpit for Ride ID: ${rideId}`)}
|
||||||
onViewRoster={(rideId) => showToast(`Displaying confirmed rider roster for Ride ID: ${rideId}`)}
|
onViewRoster={(rideId) => {
|
||||||
|
if (rideId === 'all') {
|
||||||
|
showToast('Full ride schedule is planned for a future release.');
|
||||||
|
} else {
|
||||||
|
showToast('Member roster details will be available in an upcoming release.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
onViewRoute={(routeId) => showToast(`Opening route navigation map: ${routeId ?? 'default'}`)}
|
onViewRoute={(routeId) => showToast(`Opening route navigation map: ${routeId ?? 'default'}`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ export function FounderPortalShell({
|
||||||
onPostAnnouncement,
|
onPostAnnouncement,
|
||||||
onSwitchRole,
|
onSwitchRole,
|
||||||
onSwitchClub,
|
onSwitchClub,
|
||||||
pendingApplicationsCount = 4,
|
pendingApplicationsCount = 0,
|
||||||
upcomingRidesCount = 2,
|
upcomingRidesCount = 0,
|
||||||
children,
|
children,
|
||||||
}: FounderPortalShellProps) {
|
}: FounderPortalShellProps) {
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -25,25 +25,51 @@ export function FounderProfileEditor({
|
||||||
const [description, setDescription] = useState(club.description || '');
|
const [description, setDescription] = useState(club.description || '');
|
||||||
const [homeRegion, setHomeRegion] = useState(club.homeRegion || '');
|
const [homeRegion, setHomeRegion] = useState(club.homeRegion || '');
|
||||||
const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || '');
|
const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || '');
|
||||||
|
const [logoUrl, setLogoUrl] = useState(club.logoUrl || '');
|
||||||
|
const [bannerUrl, setBannerUrl] = useState(club.bannerUrl || '');
|
||||||
|
|
||||||
// Save states
|
// Save states
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Image URL validation (HTTPS only or empty to clear)
|
||||||
|
function isValidHttpsImageUrl(url: string): boolean {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return true;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
return parsed.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50;
|
const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50;
|
||||||
const isDescValid = description.trim().length <= 500;
|
const isDescValid = description.trim().length <= 500;
|
||||||
const isRegionValid = homeRegion.trim().length <= 100;
|
const isRegionValid = homeRegion.trim().length <= 100;
|
||||||
const isFocusValid = motorcycleFocus.trim().length <= 100;
|
const isFocusValid = motorcycleFocus.trim().length <= 100;
|
||||||
const canSave = isNameValid && isDescValid && isRegionValid && isFocusValid && !isSaving && permissions.canManageSettings;
|
const isLogoUrlValid = isValidHttpsImageUrl(logoUrl);
|
||||||
|
const isBannerUrlValid = isValidHttpsImageUrl(bannerUrl);
|
||||||
|
const canSave =
|
||||||
|
isNameValid &&
|
||||||
|
isDescValid &&
|
||||||
|
isRegionValid &&
|
||||||
|
isFocusValid &&
|
||||||
|
isLogoUrlValid &&
|
||||||
|
isBannerUrlValid &&
|
||||||
|
!isSaving &&
|
||||||
|
permissions.canManageSettings;
|
||||||
|
|
||||||
const isDirty =
|
const isDirty =
|
||||||
trimmedName !== (club.name || '').trim() ||
|
trimmedName !== (club.name || '').trim() ||
|
||||||
description.trim() !== (club.description || '').trim() ||
|
description.trim() !== (club.description || '').trim() ||
|
||||||
homeRegion.trim() !== (club.homeRegion || '').trim() ||
|
homeRegion.trim() !== (club.homeRegion || '').trim() ||
|
||||||
motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim();
|
motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim() ||
|
||||||
|
logoUrl.trim() !== (club.logoUrl || '').trim() ||
|
||||||
|
bannerUrl.trim() !== (club.bannerUrl || '').trim();
|
||||||
|
|
||||||
// Short initials for preview emblem
|
// Short initials for preview emblem
|
||||||
const initials = name
|
const initials = name
|
||||||
|
|
@ -61,6 +87,9 @@ export function FounderProfileEditor({
|
||||||
setSaveStatus('idle');
|
setSaveStatus('idle');
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
const trimmedLogo = logoUrl.trim();
|
||||||
|
const trimmedBanner = bannerUrl.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isDemoFixture) {
|
if (isDemoFixture) {
|
||||||
// Simulated latency in development demo preview mode
|
// Simulated latency in development demo preview mode
|
||||||
|
|
@ -71,6 +100,8 @@ export function FounderProfileEditor({
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
homeRegion: homeRegion.trim(),
|
homeRegion: homeRegion.trim(),
|
||||||
motorcycleFocus: motorcycleFocus.trim(),
|
motorcycleFocus: motorcycleFocus.trim(),
|
||||||
|
logoUrl: trimmedLogo || null,
|
||||||
|
bannerUrl: trimmedBanner || null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!callerUid) {
|
if (!callerUid) {
|
||||||
|
|
@ -81,6 +112,8 @@ export function FounderProfileEditor({
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
homeRegion: homeRegion.trim(),
|
homeRegion: homeRegion.trim(),
|
||||||
motorcycleFocus: motorcycleFocus.trim(),
|
motorcycleFocus: motorcycleFocus.trim(),
|
||||||
|
logoUrl: trimmedLogo,
|
||||||
|
bannerUrl: trimmedBanner,
|
||||||
});
|
});
|
||||||
setSaveStatus('success');
|
setSaveStatus('success');
|
||||||
onProfileUpdated?.({
|
onProfileUpdated?.({
|
||||||
|
|
@ -88,6 +121,8 @@ export function FounderProfileEditor({
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
homeRegion: homeRegion.trim(),
|
homeRegion: homeRegion.trim(),
|
||||||
motorcycleFocus: motorcycleFocus.trim(),
|
motorcycleFocus: motorcycleFocus.trim(),
|
||||||
|
logoUrl: trimmedLogo || null,
|
||||||
|
bannerUrl: trimmedBanner || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -99,7 +134,7 @@ export function FounderProfileEditor({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-12">
|
<div className="max-w-6xl mx-auto space-y-6 pb-12">
|
||||||
{/* 3. PAGE HEADER */}
|
{/* 3. PAGE HEADER */}
|
||||||
<section className="border-b border-brand-border/60 pb-6 flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<section className="border-b border-brand-border/60 pb-6 flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -123,7 +158,7 @@ export function FounderProfileEditor({
|
||||||
{/* 4. TOP 4 METRIC CARDS */}
|
{/* 4. TOP 4 METRIC CARDS */}
|
||||||
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* Card 1: Club Name */}
|
{/* Card 1: Club Name */}
|
||||||
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
|
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
|
||||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Club Name</span>
|
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Club Name</span>
|
||||||
|
|
@ -136,7 +171,7 @@ export function FounderProfileEditor({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card 2: Home Region */}
|
{/* Card 2: Home Region */}
|
||||||
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
|
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
|
||||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Home Region</span>
|
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Home Region</span>
|
||||||
|
|
@ -151,7 +186,7 @@ export function FounderProfileEditor({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card 3: Motorcycle Focus */}
|
{/* Card 3: Motorcycle Focus */}
|
||||||
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
|
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
|
||||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Motorcycle Focus</span>
|
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Motorcycle Focus</span>
|
||||||
|
|
@ -166,7 +201,7 @@ export function FounderProfileEditor({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card 4: Discovery Presence */}
|
{/* Card 4: Discovery Presence */}
|
||||||
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
|
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
|
||||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Discovery Presence</span>
|
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Discovery Presence</span>
|
||||||
|
|
@ -217,7 +252,7 @@ export function FounderProfileEditor({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SECTION 1: PUBLIC CLUB IDENTITY */}
|
{/* SECTION 1: PUBLIC CLUB IDENTITY */}
|
||||||
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl">
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-xl">
|
||||||
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
|
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="material-symbols-outlined text-brand-cyan text-xl">badge</span>
|
<span className="material-symbols-outlined text-brand-cyan text-xl">badge</span>
|
||||||
|
|
@ -306,7 +341,7 @@ export function FounderProfileEditor({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */}
|
{/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */}
|
||||||
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl">
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-xl">
|
||||||
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
|
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="material-symbols-outlined text-brand-cyan text-xl">explore</span>
|
<span className="material-symbols-outlined text-brand-cyan text-xl">explore</span>
|
||||||
|
|
@ -356,8 +391,8 @@ export function FounderProfileEditor({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SECTION 3: LOGO & BANNER (Presentation Only) */}
|
{/* SECTION 3: LOGO & BANNER */}
|
||||||
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl">
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-xl">
|
||||||
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
|
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="material-symbols-outlined text-brand-cyan text-xl">palette</span>
|
<span className="material-symbols-outlined text-brand-cyan text-xl">palette</span>
|
||||||
|
|
@ -371,12 +406,23 @@ export function FounderProfileEditor({
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
{/* Logo Presentation Card */}
|
{/* Logo Presentation Card */}
|
||||||
<div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3">
|
<div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3">
|
||||||
<div className="font-mono text-xs text-slate-400">
|
<div className="flex items-center justify-between font-mono text-xs text-slate-400">
|
||||||
<span>Club Emblem</span>
|
<span>Club Emblem</span>
|
||||||
|
{logoUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLogoUrl('')}
|
||||||
|
disabled={!permissions.canManageSettings || isSaving}
|
||||||
|
className="text-rose-400 hover:text-rose-300 transition-colors text-[11px] font-mono flex items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xs">close</span>
|
||||||
|
<span>Remove</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl flex flex-col items-center justify-center relative overflow-hidden">
|
<div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl flex flex-col items-center justify-center relative overflow-hidden">
|
||||||
{club.logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={club.logoUrl} alt="Club logo" className="max-h-24 max-w-24 object-contain" />
|
<img src={logoUrl} alt="Club logo" className="max-h-24 max-w-24 object-contain rounded-lg" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-20 h-20 rounded-xl border-2 border-brand-cyan bg-surface-container-high flex flex-col items-center justify-center shadow-lg relative">
|
<div className="w-20 h-20 rounded-xl border-2 border-brand-cyan bg-surface-container-high flex flex-col items-center justify-center shadow-lg relative">
|
||||||
<span className="material-symbols-outlined text-brand-cyan text-2xl">two_wheeler</span>
|
<span className="material-symbols-outlined text-brand-cyan text-2xl">two_wheeler</span>
|
||||||
|
|
@ -386,21 +432,50 @@ export function FounderProfileEditor({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400">
|
<div className="space-y-2 pt-1">
|
||||||
<span className="material-symbols-outlined text-xs">info</span>
|
<div className="space-y-1">
|
||||||
<span>Logo and banner editing is not available yet.</span>
|
<input
|
||||||
|
type="url"
|
||||||
|
value={logoUrl}
|
||||||
|
onChange={(e) => setLogoUrl(e.target.value)}
|
||||||
|
disabled={!permissions.canManageSettings || isSaving}
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
className={`w-full bg-surface-container-low/90 border ${
|
||||||
|
!isLogoUrlValid ? 'border-error focus:border-error' : 'border-brand-border/80 focus:border-brand-cyan'
|
||||||
|
} rounded-xl px-3 py-2 text-xs font-mono text-white outline-none transition-colors`}
|
||||||
|
/>
|
||||||
|
{!isLogoUrlValid && (
|
||||||
|
<p className="font-mono text-[10px] text-error">
|
||||||
|
Must be a valid HTTPS URL (e.g. https://example.com/logo.png).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-[10px] text-slate-400">
|
||||||
|
Direct file upload requires Cloud Storage integration (planned for future release). Enter an HTTPS image URL or leave blank.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Banner Presentation Card */}
|
{/* Banner Presentation Card */}
|
||||||
<div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3">
|
<div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3">
|
||||||
<div className="font-mono text-xs text-slate-400">
|
<div className="flex items-center justify-between font-mono text-xs text-slate-400">
|
||||||
<span>Banner Presentation</span>
|
<span>Banner Presentation</span>
|
||||||
|
{bannerUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBannerUrl('')}
|
||||||
|
disabled={!permissions.canManageSettings || isSaving}
|
||||||
|
className="text-rose-400 hover:text-rose-300 transition-colors text-[11px] font-mono flex items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xs">close</span>
|
||||||
|
<span>Remove</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl relative overflow-hidden flex items-center justify-center">
|
<div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl relative overflow-hidden flex items-center justify-center">
|
||||||
{club.bannerUrl ? (
|
{bannerUrl ? (
|
||||||
<img
|
<img
|
||||||
src={club.bannerUrl}
|
src={bannerUrl}
|
||||||
alt="Club banner presentation"
|
alt="Club banner presentation"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
@ -409,21 +484,39 @@ export function FounderProfileEditor({
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(#00f2fe_1px,transparent_1px)] [background-size:16px_16px] opacity-10" />
|
<div className="absolute inset-0 bg-[radial-gradient(#00f2fe_1px,transparent_1px)] [background-size:16px_16px] opacity-10" />
|
||||||
<div className="flex items-center gap-2 font-mono text-xs text-slate-400 z-10">
|
<div className="flex items-center gap-2 font-mono text-xs text-slate-400 z-10">
|
||||||
<span className="material-symbols-outlined text-base text-brand-cyan">image</span>
|
<span className="material-symbols-outlined text-base text-brand-cyan">image</span>
|
||||||
<span className="tracking-wider font-semibold text-white">{club.name}</span>
|
<span className="tracking-wider font-semibold text-white">{name || club.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400">
|
<div className="space-y-2 pt-1">
|
||||||
<span className="material-symbols-outlined text-xs">info</span>
|
<div className="space-y-1">
|
||||||
<span>Logo and banner editing is not available yet.</span>
|
<input
|
||||||
|
type="url"
|
||||||
|
value={bannerUrl}
|
||||||
|
onChange={(e) => setBannerUrl(e.target.value)}
|
||||||
|
disabled={!permissions.canManageSettings || isSaving}
|
||||||
|
placeholder="https://example.com/banner.jpg"
|
||||||
|
className={`w-full bg-surface-container-low/90 border ${
|
||||||
|
!isBannerUrlValid ? 'border-error focus:border-error' : 'border-brand-border/80 focus:border-brand-cyan'
|
||||||
|
} rounded-xl px-3 py-2 text-xs font-mono text-white outline-none transition-colors`}
|
||||||
|
/>
|
||||||
|
{!isBannerUrlValid && (
|
||||||
|
<p className="font-mono text-[10px] text-error">
|
||||||
|
Must be a valid HTTPS URL (e.g. https://example.com/banner.jpg).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-[10px] text-slate-400">
|
||||||
|
Direct file upload requires Cloud Storage integration (planned for future release). Enter an HTTPS image URL or leave blank.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ACTION DOCKED FOOTER */}
|
{/* ACTION DOCKED FOOTER */}
|
||||||
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-xl">
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-xl">
|
||||||
<div className="flex items-center gap-2 font-body text-xs text-slate-300">
|
<div className="flex items-center gap-2 font-body text-xs text-slate-300">
|
||||||
<span className="material-symbols-outlined text-brand-cyan text-base">check_circle</span>
|
<span className="material-symbols-outlined text-brand-cyan text-base">check_circle</span>
|
||||||
<span>Saves public club profile information.</span>
|
<span>Saves public club profile information.</span>
|
||||||
|
|
@ -472,9 +565,9 @@ export function FounderProfileEditor({
|
||||||
<div className="rounded-xl bg-surface-container-low border border-brand-border/60 overflow-hidden shadow-lg">
|
<div className="rounded-xl bg-surface-container-low border border-brand-border/60 overflow-hidden shadow-lg">
|
||||||
{/* Banner Mockup */}
|
{/* Banner Mockup */}
|
||||||
<div className="h-24 bg-surface-container-high relative overflow-hidden">
|
<div className="h-24 bg-surface-container-high relative overflow-hidden">
|
||||||
{club.bannerUrl ? (
|
{bannerUrl ? (
|
||||||
<img
|
<img
|
||||||
src={club.bannerUrl}
|
src={bannerUrl}
|
||||||
alt="Directory banner preview"
|
alt="Directory banner preview"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
@ -488,9 +581,15 @@ export function FounderProfileEditor({
|
||||||
|
|
||||||
<div className="p-4 pt-0 relative">
|
<div className="p-4 pt-0 relative">
|
||||||
{/* Emblem overlapping */}
|
{/* Emblem overlapping */}
|
||||||
<div className="-mt-8 w-14 h-14 rounded-xl bg-surface-container-high border-2 border-brand-cyan flex flex-col items-center justify-center text-brand-cyan shadow-md">
|
<div className="-mt-8 w-14 h-14 rounded-xl bg-surface-container-high border-2 border-brand-cyan flex flex-col items-center justify-center text-brand-cyan shadow-md overflow-hidden">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="Emblem preview" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<span className="material-symbols-outlined text-xl">two_wheeler</span>
|
<span className="material-symbols-outlined text-xl">two_wheeler</span>
|
||||||
<span className="font-mono text-[10px] font-bold leading-none">{initials}</span>
|
<span className="font-mono text-[10px] font-bold leading-none">{initials}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center">
|
<div className="mt-2 flex items-center">
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ export function FounderSettingsEditor({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-12">
|
<div className="max-w-6xl mx-auto space-y-6 pb-12">
|
||||||
{/* 2. HEADER */}
|
{/* 2. HEADER */}
|
||||||
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
|
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
|
||||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
|
||||||
|
|
@ -397,7 +397,7 @@ export function FounderSettingsEditor({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
|
||||||
{/* PUBLIC */}
|
{/* PUBLIC */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -505,7 +505,7 @@ export function FounderSettingsEditor({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
|
||||||
{/* OPEN */}
|
{/* OPEN */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ export function FounderSidebar({
|
||||||
permissions,
|
permissions,
|
||||||
activeNav = 'overview',
|
activeNav = 'overview',
|
||||||
onNavSelect,
|
onNavSelect,
|
||||||
pendingApplicationsCount = 4,
|
pendingApplicationsCount = 0,
|
||||||
upcomingRidesCount = 2,
|
upcomingRidesCount = 0,
|
||||||
isOpenOnMobile = false,
|
isOpenOnMobile = false,
|
||||||
onCloseMobile,
|
onCloseMobile,
|
||||||
}: FounderSidebarProps) {
|
}: FounderSidebarProps) {
|
||||||
|
|
@ -39,17 +39,18 @@ export function FounderSidebar({
|
||||||
.join('') || 'ATL';
|
.join('') || 'ATL';
|
||||||
|
|
||||||
const navItemsTop = [
|
const navItemsTop = [
|
||||||
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary' },
|
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary', disabled: false },
|
||||||
{ id: 'profile', label: 'Club Profile', icon: 'shield' },
|
{ id: 'profile', label: 'Club Profile', icon: 'shield', disabled: false },
|
||||||
{ id: 'members', label: 'Members', icon: 'group', badge: club.memberCount.toString(), badgeType: 'neutral' },
|
{ id: 'members', label: 'Members', icon: 'group', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||||
{
|
{
|
||||||
id: 'applications',
|
id: 'applications',
|
||||||
label: 'Applications',
|
label: 'Applications',
|
||||||
icon: 'person_add',
|
icon: 'person_add',
|
||||||
badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} ALERT` : undefined,
|
badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} PENDING` : undefined,
|
||||||
badgeType: 'error',
|
badgeType: 'error',
|
||||||
|
disabled: false,
|
||||||
},
|
},
|
||||||
{ id: 'ranks_roles', label: 'Ranks & Roles', icon: 'military_tech' },
|
{ id: 'ranks_roles', label: 'Ranks & Roles', icon: 'military_tech', disabled: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const navItemsMiddle = [
|
const navItemsMiddle = [
|
||||||
|
|
@ -57,24 +58,26 @@ export function FounderSidebar({
|
||||||
id: 'rides',
|
id: 'rides',
|
||||||
label: 'Rides',
|
label: 'Rides',
|
||||||
icon: 'route',
|
icon: 'route',
|
||||||
badge: upcomingRidesCount > 0 ? `${upcomingRidesCount} ACTIVE` : undefined,
|
badge: 'SOON',
|
||||||
badgeType: 'secondary',
|
badgeType: 'disabled',
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
{ id: 'routes', label: 'Routes', icon: 'map' },
|
{ id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||||
{ id: 'wall', label: 'Wall', icon: 'forum' },
|
{ id: 'wall', label: 'Wall', icon: 'forum', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||||
{ id: 'chat', label: 'Chat', icon: 'chat' },
|
{ id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||||
{ id: 'stats', label: 'Stats', icon: 'query_stats' },
|
{ id: 'stats', label: 'Stats', icon: 'query_stats', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const navItemsBottom = [
|
const navItemsBottom = [
|
||||||
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: '1', badgeType: 'neutral' },
|
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||||
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
{ id: 'settings', label: 'Settings', icon: 'settings', disabled: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getRouteHref(id: string) {
|
function getRouteHref(id: string) {
|
||||||
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
|
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
|
||||||
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
|
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
|
||||||
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
|
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
|
||||||
|
if (id === 'ranks_roles') return `/clubs/${clubParam}/manage/ranks-roles${queryParam}`;
|
||||||
if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;
|
if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -84,13 +87,43 @@ export function FounderSidebar({
|
||||||
onCloseMobile?.();
|
onCloseMobile?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNavItem(item: { id: string; label: string; icon: string; badge?: string; badgeType?: string }) {
|
function renderNavItem(item: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
badge?: string;
|
||||||
|
badgeType?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
const isActive = activeNav === item.id;
|
const isActive = activeNav === item.id;
|
||||||
const href = getRouteHref(item.id);
|
const href = !item.disabled ? getRouteHref(item.id) : undefined;
|
||||||
const isProfile = item.id === 'profile';
|
const isProfile = item.id === 'profile';
|
||||||
const isSettings = item.id === 'settings';
|
const isSettings = item.id === 'settings';
|
||||||
const isApplications = item.id === 'applications';
|
const isApplications = item.id === 'applications';
|
||||||
const isSubRoute = isProfile || isSettings || isApplications;
|
const isSubRoute = isProfile || isSettings || isApplications || item.id === 'ranks_roles';
|
||||||
|
|
||||||
|
if (item.disabled) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs text-slate-500/60 cursor-not-allowed select-none"
|
||||||
|
title={`${item.label} is planned for a future release`}
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="material-symbols-outlined text-[18px] text-slate-600">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
{item.badge && (
|
||||||
|
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded bg-surface-container text-slate-500 border border-brand-border/40">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
|
|
@ -257,7 +290,7 @@ export function FounderSidebar({
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-body text-xs font-semibold text-on-surface truncate">
|
<span className="font-body text-xs font-semibold text-on-surface truncate">
|
||||||
{club.founderName ?? 'Marcus Vance'}
|
{club.founderName ?? 'Club Founder'}
|
||||||
</span>
|
</span>
|
||||||
<span className="material-symbols-outlined text-[13px] text-brand-cyan shrink-0">
|
<span className="material-symbols-outlined text-[13px] text-brand-cyan shrink-0">
|
||||||
verified
|
verified
|
||||||
|
|
|
||||||
|
|
@ -50,46 +50,52 @@ export function FounderTopBar({
|
||||||
}, [userMenuOpen]);
|
}, [userMenuOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 left-0 lg:left-72 right-0 h-16 bg-surface-container-lowest/95 backdrop-blur-md border-b border-brand-border/60 z-40 px-4 sm:px-6 lg:px-8">
|
<header className="fixed top-0 left-0 lg:left-72 right-0 h-16 bg-surface-container-lowest/95 backdrop-blur-md border-b border-brand-border/60 z-40">
|
||||||
<div className="w-full max-w-[1440px] mx-auto h-full flex items-center justify-between gap-3 sm:gap-4">
|
<div className="w-full max-w-[1440px] mx-auto h-full px-4 sm:px-6 lg:px-8 flex items-center justify-between gap-3 sm:gap-4">
|
||||||
{/* Left: Mobile hamburger + Breadcrumbs + Switch Club */}
|
{/* Left: Mobile hamburger + Breadcrumbs + Switch Club */}
|
||||||
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
|
<div className="flex items-center gap-2.5 sm:gap-4 min-w-0">
|
||||||
{/* Mobile Hamburger Toggle */}
|
{/* Mobile Hamburger Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleMobileSidebar}
|
onClick={onToggleMobileSidebar}
|
||||||
className="lg:hidden text-outline hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors"
|
className="lg:hidden text-outline hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors shrink-0"
|
||||||
aria-label="Open sidebar"
|
aria-label="Open sidebar"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[22px]">menu</span>
|
<span className="material-symbols-outlined text-[22px]">menu</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 font-mono text-xs text-outline shrink-0">
|
<div className="flex items-center gap-1.5 sm:gap-2 font-mono text-xs text-outline min-w-0">
|
||||||
<Link href="/clubs" className="hover:text-on-surface transition-colors">
|
<Link href="/clubs" className="hover:text-on-surface transition-colors shrink-0">
|
||||||
Clubs
|
Clubs
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span className="shrink-0">/</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/clubs/${club.slug || club.id}/manage`}
|
href={`/clubs/${club.slug || club.id}/manage`}
|
||||||
className="text-on-surface font-semibold truncate max-w-[120px] sm:max-w-[180px] md:max-w-none hover:text-brand-cyan transition-colors"
|
className="text-on-surface font-semibold truncate max-w-[110px] sm:max-w-[160px] lg:max-w-[140px] xl:max-w-none hover:text-brand-cyan transition-colors"
|
||||||
>
|
>
|
||||||
{club.name}
|
{club.name}
|
||||||
</Link>
|
</Link>
|
||||||
{activeNav === 'profile' && (
|
{activeNav === 'profile' && (
|
||||||
<>
|
<>
|
||||||
<span className="text-outline-variant">/</span>
|
<span className="text-outline-variant shrink-0">/</span>
|
||||||
<span className="text-brand-cyan font-semibold">Club Profile</span>
|
<span className="text-brand-cyan font-semibold truncate">Club Profile</span>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{activeNav === 'settings' && (
|
|
||||||
<>
|
|
||||||
<span className="text-outline-variant">/</span>
|
|
||||||
<span className="text-brand-cyan font-semibold">Settings</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeNav === 'applications' && (
|
{activeNav === 'applications' && (
|
||||||
<>
|
<>
|
||||||
<span className="text-outline-variant">/</span>
|
<span className="text-outline-variant shrink-0">/</span>
|
||||||
<span data-breadcrumb="APPLICATIONS" className="text-brand-cyan font-semibold">Applications</span>
|
<span data-breadcrumb="APPLICATIONS" className="text-brand-cyan font-semibold truncate">Applications</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{activeNav === 'ranks_roles' && (
|
||||||
|
<>
|
||||||
|
<span className="text-outline-variant shrink-0">/</span>
|
||||||
|
<span className="text-brand-cyan font-semibold truncate">Ranks & Roles</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{activeNav === 'settings' && (
|
||||||
|
<>
|
||||||
|
<span className="text-outline-variant shrink-0">/</span>
|
||||||
|
<span className="text-brand-cyan font-semibold truncate">Settings</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -100,14 +106,14 @@ export function FounderTopBar({
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs hover:bg-surface-container-high transition-colors"
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs hover:bg-surface-container-high transition-colors"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[15px] text-brand-cyan">swap_horiz</span>
|
<span className="material-symbols-outlined text-[15px] text-brand-cyan">swap_horiz</span>
|
||||||
<span className="hidden sm:inline">Switch Club</span>
|
<span className="hidden sm:inline lg:hidden xl:inline">Switch Club</span>
|
||||||
<span className="material-symbols-outlined text-[15px] text-outline">expand_more</span>
|
<span className="material-symbols-outlined text-[15px] text-outline hidden sm:inline lg:hidden xl:inline">expand_more</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Middle: Search Input (desktop/tablet only) */}
|
{/* Middle: Search Input (desktop/tablet only, hidden on lg to prevent crush) */}
|
||||||
<div className="flex-1 max-w-xs mx-2 lg:mx-4 min-w-0 hidden md:block">
|
<div className="flex-1 max-w-xs mx-2 lg:mx-4 min-w-0 hidden md:block lg:hidden xl:block">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]">
|
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]">
|
||||||
search
|
search
|
||||||
|
|
@ -122,11 +128,11 @@ export function FounderTopBar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Actions + Role Switcher preview pill + Notifications + Profile */}
|
{/* Right: Actions + Role Switcher preview pill + Notifications + Profile */}
|
||||||
<div className="flex items-center gap-2 sm:gap-2.5 shrink-0">
|
<div className="flex items-center gap-2 sm:gap-2.5 shrink-0 flex-wrap justify-end">
|
||||||
{/* Authoritative Role Badge (Production) vs Interactive Role Switcher (Development Only) */}
|
{/* Authoritative Role Badge (Production) vs Interactive Role Switcher (Development Only) */}
|
||||||
{process.env.NODE_ENV === 'development' ? (
|
{process.env.NODE_ENV === 'development' ? (
|
||||||
<div
|
<div
|
||||||
className="hidden xl:flex items-center gap-1 px-2 py-0.5 rounded-lg bg-surface-container-high border border-brand-border/60 text-[11px] font-mono text-outline"
|
className="hidden 2xl:flex items-center gap-1 px-2 py-0.5 rounded-lg bg-surface-container-high border border-brand-border/60 text-[11px] font-mono text-outline"
|
||||||
title="Interactive role preview for testing software permissions (Development Only)"
|
title="Interactive role preview for testing software permissions (Development Only)"
|
||||||
>
|
>
|
||||||
<span className="text-outline-variant">Dev Preview:</span>
|
<span className="text-outline-variant">Dev Preview:</span>
|
||||||
|
|
@ -146,7 +152,7 @@ export function FounderTopBar({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-surface-container border border-brand-border/60 text-[11px] font-mono text-outline capitalize font-medium"
|
className="hidden sm:flex lg:hidden xl:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-surface-container border border-brand-border/60 text-[11px] font-mono text-outline capitalize font-medium"
|
||||||
title="Authoritative software role"
|
title="Authoritative software role"
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
|
||||||
|
|
@ -169,15 +175,15 @@ export function FounderTopBar({
|
||||||
{permissions.canPlanRides && (
|
{permissions.canPlanRides && (
|
||||||
<button
|
<button
|
||||||
onClick={onPlanRide}
|
onClick={onPlanRide}
|
||||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-[0_0_12px_rgba(0,242,254,0.25)] transition-all shrink-0"
|
className="flex items-center gap-1.5 px-3 sm:px-3.5 py-1.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-[0_0_12px_rgba(0,242,254,0.25)] transition-all shrink-0"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[15px]">add_road</span>
|
<span className="material-symbols-outlined text-[15px]">add_road</span>
|
||||||
<span className="hidden sm:inline">Plan Club Ride</span>
|
<span className="hidden sm:inline lg:hidden xl:inline">Plan Club Ride</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<div className="relative ml-1 shrink-0">
|
<div className="relative ml-0.5 sm:ml-1 shrink-0">
|
||||||
<button
|
<button
|
||||||
className="w-8 h-8 rounded-xl bg-surface-container border border-brand-border/60 flex items-center justify-center text-outline hover:text-on-surface transition-colors"
|
className="w-8 h-8 rounded-xl bg-surface-container border border-brand-border/60 flex items-center justify-center text-outline hover:text-on-surface transition-colors"
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,10 @@ export function MetricCard({
|
||||||
const selectedSpark = sparklineColors[sparklineVariant] || sparklineColors.secondary;
|
const selectedSpark = sparklineColors[sparklineVariant] || sparklineColors.secondary;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 flex flex-col justify-between transition-all hover:border-brand-cyan/50 hover:shadow-[0_0_20px_rgba(0,242,254,0.12)] overflow-hidden">
|
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between transition-all hover:border-brand-cyan/50 hover:shadow-[0_0_20px_rgba(0,242,254,0.12)] overflow-hidden">
|
||||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
||||||
{/* Top Header Row */}
|
{/* Top Header Row */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||||
<span className="font-mono text-xs tracking-wider text-slate-400 font-medium uppercase">{label}</span>
|
<span className="font-mono text-xs tracking-wider text-slate-400 font-medium uppercase">{label}</span>
|
||||||
{badge}
|
{badge}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,12 +77,12 @@ export function MetricCard({
|
||||||
|
|
||||||
{/* Footer Subtext */}
|
{/* Footer Subtext */}
|
||||||
<div className="flex flex-col gap-1 w-full pt-2 border-t border-brand-border/40">
|
<div className="flex flex-col gap-1 w-full pt-2 border-t border-brand-border/40">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 font-mono text-xs text-slate-400">
|
<div className="flex flex-col gap-1 font-mono text-xs text-slate-400">
|
||||||
<div className="truncate">{subtextLeft}</div>
|
{subtextLeft && <div className="leading-snug">{subtextLeft}</div>}
|
||||||
<div className="text-slate-400 truncate sm:text-right">{subtextRight}</div>
|
{subtextRight && <div className="text-slate-400 leading-snug">{subtextRight}</div>}
|
||||||
</div>
|
</div>
|
||||||
{disclaimer && (
|
{disclaimer && (
|
||||||
<span className="text-[10px] font-mono text-slate-500 truncate" title={disclaimer}>
|
<span className="text-[10px] font-mono text-slate-500 line-clamp-2 leading-relaxed" title={disclaimer}>
|
||||||
{disclaimer}
|
{disclaimer}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -93,7 +93,7 @@ export function MetricCard({
|
||||||
|
|
||||||
export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
||||||
return (
|
return (
|
||||||
<section aria-label="Executive Metrics" className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 lg:gap-5">
|
<section aria-label="Executive Metrics" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 2xl:grid-cols-4 gap-4 lg:gap-5">
|
||||||
{/* 1. MEMBERS */}
|
{/* 1. MEMBERS */}
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Members"
|
label="Members"
|
||||||
|
|
@ -101,12 +101,12 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
||||||
showSparkline={false}
|
showSparkline={false}
|
||||||
badge={
|
badge={
|
||||||
metrics.membersActivePercent != null ? (
|
metrics.membersActivePercent != null ? (
|
||||||
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30">
|
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 shrink-0">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
|
||||||
{metrics.membersActivePercent}% ACTIVE
|
{metrics.membersActivePercent}% ACTIVE
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">ROSTER</span>
|
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60 shrink-0">ROSTER</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
subtextLeft={
|
subtextLeft={
|
||||||
|
|
@ -134,11 +134,11 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
||||||
showSparkline={false}
|
showSparkline={false}
|
||||||
badge={
|
badge={
|
||||||
metrics.pendingApplications > 0 ? (
|
metrics.pendingApplications > 0 ? (
|
||||||
<span className="px-2 py-0.5 rounded-full bg-error-container text-on-error-container font-mono text-[10px] font-semibold border border-error/30">
|
<span className="px-2 py-0.5 rounded-full bg-error-container text-on-error-container font-mono text-[10px] font-semibold border border-error/30 shrink-0">
|
||||||
REQUIRES REVIEW
|
REQUIRES REVIEW
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">ALL CLEARED</span>
|
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60 shrink-0">ALL CLEARED</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
subtextLeft={
|
subtextLeft={
|
||||||
|
|
@ -157,7 +157,7 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
||||||
value={metrics.upcomingRidesCount}
|
value={metrics.upcomingRidesCount}
|
||||||
showSparkline={false}
|
showSparkline={false}
|
||||||
badge={
|
badge={
|
||||||
<span className="font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
|
<span className="font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium shrink-0">
|
||||||
{metrics.upcomingRidesCount > 0 ? 'SCHEDULED' : 'NONE'}
|
{metrics.upcomingRidesCount > 0 ? 'SCHEDULED' : 'NONE'}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
@ -178,11 +178,11 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
||||||
isUnavailable={!metrics.clubMilesAvailable}
|
isUnavailable={!metrics.clubMilesAvailable}
|
||||||
badge={
|
badge={
|
||||||
metrics.clubMilesAvailable ? (
|
metrics.clubMilesAvailable ? (
|
||||||
<span className="font-mono text-[10px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
|
<span className="font-mono text-[10px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium max-w-full text-right leading-tight">
|
||||||
Completed Club Rides only
|
Completed Club Rides only
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">UNAVAILABLE</span>
|
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60 shrink-0">UNAVAILABLE</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
subtextLeft={
|
subtextLeft={
|
||||||
|
|
|
||||||
|
|
@ -609,6 +609,32 @@ export interface UpdateClubProfileInput {
|
||||||
description: string;
|
description: string;
|
||||||
homeRegion: string;
|
homeRegion: string;
|
||||||
motorcycleFocus: string;
|
motorcycleFocus: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
bannerUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared image URL validation for club logo/banner persistence.
|
||||||
|
* Accepts https:// URLs or blank values (clearing). Returns the trimmed
|
||||||
|
* URL, or null when the field should be cleared. Never normalizes an
|
||||||
|
* invalid URL into a valid one; throws on http://, data:, blob:,
|
||||||
|
* javascript:, or malformed values.
|
||||||
|
*/
|
||||||
|
export function validateClubImageUrl(value: string, fieldLabel: string): string | null {
|
||||||
|
const trimmed = (value ?? '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${fieldLabel} must be a valid HTTPS URL.`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'https:') {
|
||||||
|
throw new Error(`${fieldLabel} must use a secure https:// URL.`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -645,6 +671,10 @@ export async function updateClubProfile(
|
||||||
throw new Error('Motorcycle focus must not exceed 100 characters.');
|
throw new Error('Motorcycle focus must not exceed 100 characters.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logoUrl = input.logoUrl !== undefined ? validateClubImageUrl(input.logoUrl, 'Club logo URL') : undefined;
|
||||||
|
const bannerUrl =
|
||||||
|
input.bannerUrl !== undefined ? validateClubImageUrl(input.bannerUrl, 'Club banner URL') : undefined;
|
||||||
|
|
||||||
const clubRef = doc(firestore, 'clubs', clubId);
|
const clubRef = doc(firestore, 'clubs', clubId);
|
||||||
const clubSnap = await getDoc(clubRef);
|
const clubSnap = await getDoc(clubRef);
|
||||||
if (!clubSnap.exists()) {
|
if (!clubSnap.exists()) {
|
||||||
|
|
@ -681,6 +711,8 @@ export async function updateClubProfile(
|
||||||
description,
|
description,
|
||||||
homeRegion,
|
homeRegion,
|
||||||
motorcycleFocus,
|
motorcycleFocus,
|
||||||
|
...(logoUrl !== undefined ? { logoUrl } : {}),
|
||||||
|
...(bannerUrl !== undefined ? { bannerUrl } : {}),
|
||||||
updatedAt: serverTimestamp(),
|
updatedAt: serverTimestamp(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ const {
|
||||||
fetchClubApplications,
|
fetchClubApplications,
|
||||||
approveClubApplication,
|
approveClubApplication,
|
||||||
declineClubApplication,
|
declineClubApplication,
|
||||||
|
validateClubImageUrl,
|
||||||
} = clubData;
|
} = clubData;
|
||||||
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures;
|
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures;
|
||||||
|
|
||||||
|
|
@ -828,7 +829,125 @@ async function runAllTests() {
|
||||||
|
|
||||||
console.log(' PASS: FP-15 Tests A through V verified (Route exists, Owner/Admin access, Member denial, Unauth denial, Bounded query limit 25, Status requested only, Approve requested->active, Decline requested->declined, Default role member, Default rank Member, memberCount +1 atomic, memberCount delta 0 decline, Ban rejection, Stale request protection, Duplicate click guard, Zero rank privilege, Zero private telemetry/contact leak, Production fixture isolation, Sidebar routing & active state, TopBar breadcrumb, Non-destructive history preservation).');
|
console.log(' PASS: FP-15 Tests A through V verified (Route exists, Owner/Admin access, Member denial, Unauth denial, Bounded query limit 25, Status requested only, Approve requested->active, Decline requested->declined, Default role member, Default rank Member, memberCount +1 atomic, memberCount delta 0 decline, Ban rejection, Stale request protection, Duplicate click guard, Zero rank privilege, Zero private telemetry/contact leak, Production fixture isolation, Sidebar routing & active state, TopBar breadcrumb, Non-destructive history preservation).');
|
||||||
|
|
||||||
console.log('--- ALL FOUNDER PORTAL TESTS PASSED (23/23) ---');
|
// =========================================================================
|
||||||
|
// TEST 24: Codex Review Findings — Ranks-Roles Access Guard + HTTPS Image URL Invariant
|
||||||
|
// =========================================================================
|
||||||
|
console.log('Testing: Codex findings — Ranks-Roles access guard & HTTPS image URL invariant...');
|
||||||
|
|
||||||
|
// --- 24A: Ranks & Roles route enforces canManageSettings guard ---
|
||||||
|
const ranksRolesPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/ranks-roles/page.tsx'), 'utf8');
|
||||||
|
|
||||||
|
// Owner/Admin allowed by the established permission model
|
||||||
|
assert.equal(getPortalPermissions('owner', true).canManageSettings, true, 'Owner must have canManageSettings');
|
||||||
|
assert.equal(getPortalPermissions('admin', true).canManageSettings, true, 'Admin must have canManageSettings');
|
||||||
|
|
||||||
|
// Ordinary member denied
|
||||||
|
assert.equal(getPortalPermissions('member', true).canManageSettings, false, 'Member must not have canManageSettings');
|
||||||
|
|
||||||
|
// Unauthenticated denied
|
||||||
|
assert.equal(getPortalPermissions('owner', false).isAuthenticated, false, 'Unauthenticated user must have isAuthenticated = false');
|
||||||
|
assert.equal(getPortalPermissions('owner', false).canManageSettings, false, 'Unauthenticated user must not have canManageSettings');
|
||||||
|
|
||||||
|
// Route source: unauthenticated guard, authorization guard, safe unauthorized state, demo-preview preserved
|
||||||
|
assert(ranksRolesPageSrc.includes("if (!isDemoPreview && !currentUser)"), 'Ranks-Roles page must guard unauthenticated access');
|
||||||
|
assert(ranksRolesPageSrc.includes('title="Authentication Required"'), 'Ranks-Roles page must render Authentication Required safe state');
|
||||||
|
assert(ranksRolesPageSrc.includes('!ctx.permissions.canManageSettings'), 'Ranks-Roles page must enforce canManageSettings gate');
|
||||||
|
assert(ranksRolesPageSrc.includes('setUnauthorized(true)'), 'Ranks-Roles page must set unauthorized state');
|
||||||
|
assert(ranksRolesPageSrc.includes('title="Management Access Restricted"'), 'Ranks-Roles page must render unauthorized safe state');
|
||||||
|
assert(ranksRolesPageSrc.includes('allowDemoPreview: isDemoPreview'), 'Ranks-Roles page must preserve demo-preview behavior');
|
||||||
|
|
||||||
|
// --- 24B: validateClubImageUrl shared data-layer validator ---
|
||||||
|
// Accepted: valid https URLs (returns trimmed value)
|
||||||
|
assert.equal(validateClubImageUrl('https://example.com/logo.png', 'Club logo URL'), 'https://example.com/logo.png');
|
||||||
|
assert.equal(validateClubImageUrl(' https://cdn.example.com/b banner.jpg ', 'Club banner URL'), 'https://cdn.example.com/b banner.jpg');
|
||||||
|
|
||||||
|
// Accepted: blank/empty values clear the field (returns null)
|
||||||
|
assert.equal(validateClubImageUrl('', 'Club logo URL'), null);
|
||||||
|
assert.equal(validateClubImageUrl(' ', 'Club banner URL'), null);
|
||||||
|
|
||||||
|
// Rejected: http://, data:, blob:, javascript:, ftp:, malformed
|
||||||
|
const rejectedUrls = [
|
||||||
|
'http://example.com/logo.png',
|
||||||
|
'data:image/png;base64,iVBORw0KGgo=',
|
||||||
|
'blob:https://example.com/uuid',
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'ftp://example.com/logo.png',
|
||||||
|
'not-a-url',
|
||||||
|
'example.com/logo.png',
|
||||||
|
'https://',
|
||||||
|
];
|
||||||
|
for (const bad of rejectedUrls) {
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
validateClubImageUrl(bad, 'Club logo URL');
|
||||||
|
} catch (err) {
|
||||||
|
threw = true;
|
||||||
|
assert(
|
||||||
|
err.message === 'Club logo URL must be a valid HTTPS URL.' || err.message === 'Club logo URL must use a secure https:// URL.',
|
||||||
|
`Unexpected validation error message for ${bad}: ${err.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(threw, true, `validateClubImageUrl must reject: ${bad}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never silently normalizes an invalid URL into a valid one
|
||||||
|
assert.equal(validateClubImageUrl('https://example.com/x.png', 'Club logo URL'), 'https://example.com/x.png');
|
||||||
|
|
||||||
|
// --- 24C: updateClubProfile invokes validator before Firestore reads ---
|
||||||
|
const updateProfileBody = clubDataSrc.substring(clubDataSrc.indexOf('export async function updateClubProfile'), clubDataSrc.indexOf('export interface UpdateClubAccessSettingsInput'));
|
||||||
|
assert(updateProfileBody.includes('validateClubImageUrl(input.logoUrl'), 'updateClubProfile must validate logoUrl via validateClubImageUrl');
|
||||||
|
assert(updateProfileBody.includes('validateClubImageUrl(input.bannerUrl'), 'updateClubProfile must validate bannerUrl via validateClubImageUrl');
|
||||||
|
assert(updateProfileBody.indexOf('validateClubImageUrl(input.logoUrl') < updateProfileBody.indexOf('getDoc(clubRef)'), 'URL validation must run before Firestore reads');
|
||||||
|
assert(!updateProfileBody.includes('input.logoUrl.trim()'), 'updateClubProfile must not write unvalidated logoUrl');
|
||||||
|
assert(!updateProfileBody.includes('input.bannerUrl.trim()'), 'updateClubProfile must not write unvalidated bannerUrl');
|
||||||
|
|
||||||
|
// Behavioral: invalid URL rejected before any Firestore read (mocked getDoc never resolves a club)
|
||||||
|
let urlThrew = false;
|
||||||
|
try {
|
||||||
|
await updateClubProfile('any-club', 'caller-uid', {
|
||||||
|
name: 'Valid Club Name',
|
||||||
|
description: '',
|
||||||
|
homeRegion: '',
|
||||||
|
motorcycleFocus: '',
|
||||||
|
logoUrl: 'http://evil.example.com/x.png',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
urlThrew = true;
|
||||||
|
assert.equal(err.message, 'Club logo URL must use a secure https:// URL.');
|
||||||
|
}
|
||||||
|
assert.equal(urlThrew, true, 'updateClubProfile must reject http:// logoUrl before touching Firestore');
|
||||||
|
|
||||||
|
// Behavioral: valid input with no URL fields reaches the not-found read (proves validation did not throw)
|
||||||
|
let notFoundThrew = false;
|
||||||
|
try {
|
||||||
|
await updateClubProfile('any-club', 'caller-uid', {
|
||||||
|
name: 'Valid Club Name',
|
||||||
|
description: '',
|
||||||
|
homeRegion: '',
|
||||||
|
motorcycleFocus: '',
|
||||||
|
logoUrl: 'https://example.com/ok.png',
|
||||||
|
bannerUrl: '',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
notFoundThrew = true;
|
||||||
|
assert.equal(err.message, 'Club not found.', `Expected Firestore read after passing validation, got: ${err.message}`);
|
||||||
|
}
|
||||||
|
assert.equal(notFoundThrew, true, 'updateClubProfile with valid URLs must proceed to Firestore read');
|
||||||
|
|
||||||
|
// --- 24D: Firestore rules enforce the same invariant at the storage layer ---
|
||||||
|
const rulesSrc = fs.readFileSync(path.resolve(__dirname, '../../firestore.rules'), 'utf8');
|
||||||
|
assert(rulesSrc.includes('function isValidClubImageUrl(url)'), 'firestore.rules must define isValidClubImageUrl');
|
||||||
|
assert(rulesSrc.includes("url.matches('(?i)^https://\\\\S+$')"), 'firestore.rules must require https:// scheme');
|
||||||
|
const rulesCase2 = rulesSrc.substring(rulesSrc.indexOf('// Case 2: Owner general metadata update'), rulesSrc.indexOf('// Case 3: Admin editing metadata'));
|
||||||
|
const rulesCase3 = rulesSrc.substring(rulesSrc.indexOf('// Case 3: Admin editing metadata'), rulesSrc.indexOf('// Case 4: Member count'));
|
||||||
|
for (const [label, block] of [['Case 2 (owner)', rulesCase2], ['Case 3 (admin)', rulesCase3]]) {
|
||||||
|
assert(block.includes("isValidClubImageUrl(request.resource.data.logoUrl)"), `firestore.rules ${label} must validate logoUrl`);
|
||||||
|
assert(block.includes("isValidClubImageUrl(request.resource.data.bannerUrl)"), `firestore.rules ${label} must validate bannerUrl`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' PASS: Ranks-Roles canManageSettings guard verified (owner/admin allowed, member/unauth denied, demo-preview preserved); HTTPS image URL invariant enforced in app layer and Firestore rules (https accepted, blank clears, http/data/blob/javascript/malformed rejected).');
|
||||||
|
|
||||||
|
console.log('--- ALL FOUNDER PORTAL TESTS PASSED (24/24) ---');
|
||||||
}
|
}
|
||||||
|
|
||||||
runAllTests().catch((err) => {
|
runAllTests().catch((err) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue