691 lines
35 KiB
TypeScript
691 lines
35 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { Club, UserPortalPermissions } from './types';
|
|
import {
|
|
ClubApplication,
|
|
fetchClubApplications,
|
|
approveClubApplication,
|
|
declineClubApplication,
|
|
sanitizePortalError,
|
|
} from '../../lib/club-data';
|
|
|
|
interface FounderApplicationsProps {
|
|
club: Club;
|
|
permissions: UserPortalPermissions;
|
|
callerUid: string;
|
|
isDemoFixture?: boolean;
|
|
}
|
|
|
|
export function FounderApplications({
|
|
club,
|
|
permissions,
|
|
callerUid,
|
|
isDemoFixture = false,
|
|
}: FounderApplicationsProps) {
|
|
const [applications, setApplications] = useState<ClubApplication[]>([]);
|
|
const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'name'>('newest');
|
|
|
|
// Mutation and modal states
|
|
const [mutatingUid, setMutatingUid] = useState<string | null>(null);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [approveModalApplicant, setApproveModalApplicant] = useState<ClubApplication | null>(null);
|
|
const [declineModalApplicant, setDeclineModalApplicant] = useState<ClubApplication | null>(null);
|
|
|
|
// Local member count to reflect approval transitions immediately
|
|
const [localMemberCount, setLocalMemberCount] = useState(club.memberCount);
|
|
|
|
async function loadApplications() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await fetchClubApplications(club.id, callerUid, {
|
|
allowDemoPreview: isDemoFixture,
|
|
});
|
|
setApplications(data);
|
|
if (data.length > 0) {
|
|
setSelectedApplicantUid((prev) => prev ?? data[0].uid);
|
|
}
|
|
} catch (err) {
|
|
setError(sanitizePortalError(err, "Applications couldn't be loaded."));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadApplications();
|
|
}, [club.id, callerUid, isDemoFixture]);
|
|
|
|
// Filtered and sorted applications
|
|
const filteredApplications = useMemo(() => {
|
|
let list = [...applications];
|
|
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase().trim();
|
|
list = list.filter((app) => {
|
|
const nameMatch = app.displayName.toLowerCase().includes(q);
|
|
const handleMatch = app.handle ? app.handle.toLowerCase().includes(q) : false;
|
|
return nameMatch || handleMatch;
|
|
});
|
|
}
|
|
|
|
list.sort((a, b) => {
|
|
if (sortBy === 'newest') {
|
|
return new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime();
|
|
}
|
|
if (sortBy === 'oldest') {
|
|
return new Date(a.requestedAt).getTime() - new Date(b.requestedAt).getTime();
|
|
}
|
|
return a.displayName.localeCompare(b.displayName);
|
|
});
|
|
|
|
return list;
|
|
}, [applications, searchQuery, sortBy]);
|
|
|
|
const selectedApplicant = useMemo(() => {
|
|
if (!selectedApplicantUid) return filteredApplications[0] || null;
|
|
return applications.find((a) => a.uid === selectedApplicantUid) || filteredApplications[0] || null;
|
|
}, [applications, filteredApplications, selectedApplicantUid]);
|
|
|
|
function getMonogram(name: string) {
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
if (parts.length === 0) return 'CA';
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
}
|
|
|
|
function formatDate(isoString: string) {
|
|
try {
|
|
const date = new Date(isoString);
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
});
|
|
} catch {
|
|
return isoString;
|
|
}
|
|
}
|
|
|
|
async function handleApprove(applicant: ClubApplication) {
|
|
if (mutatingUid) return;
|
|
setMutatingUid(applicant.uid);
|
|
setActionError(null);
|
|
|
|
try {
|
|
if (!isDemoFixture) {
|
|
await approveClubApplication(club.id, callerUid, applicant.uid);
|
|
}
|
|
// Remove from list and increment member count
|
|
setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid));
|
|
setLocalMemberCount((c) => c + 1);
|
|
setApproveModalApplicant(null);
|
|
|
|
// Select next applicant if the approved one was selected
|
|
if (selectedApplicantUid === applicant.uid) {
|
|
const remaining = applications.filter((a) => a.uid !== applicant.uid);
|
|
setSelectedApplicantUid(remaining[0]?.uid || null);
|
|
}
|
|
} catch (err) {
|
|
setActionError(sanitizePortalError(err, 'Unable to approve application. Please try again.'));
|
|
} finally {
|
|
setMutatingUid(null);
|
|
}
|
|
}
|
|
|
|
async function handleDecline(applicant: ClubApplication) {
|
|
if (mutatingUid) return;
|
|
setMutatingUid(applicant.uid);
|
|
setActionError(null);
|
|
|
|
try {
|
|
if (!isDemoFixture) {
|
|
await declineClubApplication(club.id, callerUid, applicant.uid);
|
|
}
|
|
// Remove from list (non-destructive in Firestore, memberCount delta 0)
|
|
setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid));
|
|
setDeclineModalApplicant(null);
|
|
|
|
// Select next applicant if the declined one was selected
|
|
if (selectedApplicantUid === applicant.uid) {
|
|
const remaining = applications.filter((a) => a.uid !== applicant.uid);
|
|
setSelectedApplicantUid(remaining[0]?.uid || null);
|
|
}
|
|
} catch (err) {
|
|
setActionError(sanitizePortalError(err, 'Unable to decline application. Please try again.'));
|
|
} finally {
|
|
setMutatingUid(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 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">
|
|
<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">
|
|
Precision, Not Aggression
|
|
</span>
|
|
<span className="font-mono text-xs text-slate-400 tracking-wider">
|
|
// Membership Triage
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<h1 className="font-headline font-bold text-2xl sm:text-3xl text-white tracking-tight">
|
|
Applications
|
|
</h1>
|
|
<span
|
|
className="px-2.5 py-0.5 rounded-full text-xs font-mono font-semibold bg-amber-500/15 border border-amber-500/40 text-amber-400"
|
|
aria-label={`${applications.length} pending applications`}
|
|
>
|
|
{applications.length} PENDING
|
|
</span>
|
|
</div>
|
|
<p className="font-body text-sm text-slate-400 mt-1">Review riders requesting to join this Club.</p>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 2. SUMMARY METRIC CARDS ROW */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{/* Card 1: Pending */}
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-lg relative overflow-hidden">
|
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-amber-400/50 via-transparent to-transparent" />
|
|
<div className="flex items-center justify-between text-slate-400 text-xs font-mono tracking-wider">
|
|
<span>Pending</span>
|
|
<span className="w-2 h-2 rounded-full bg-amber-400 shadow-[0_0_8px_rgba(245,158,11,0.5)]" />
|
|
</div>
|
|
<div className="mt-2 flex items-baseline space-x-2">
|
|
<span className="font-headline font-bold text-2xl sm:text-3xl text-white">{applications.length}</span>
|
|
</div>
|
|
<div className="mt-1 text-[11px] font-mono text-slate-400">Awaiting review</div>
|
|
</div>
|
|
|
|
{/* Card 2: Admission Policy */}
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-lg relative 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="flex items-center justify-between text-slate-400 text-xs font-mono tracking-wider">
|
|
<span>Admission Policy</span>
|
|
<span className="w-2 h-2 rounded-full bg-brand-cyan shadow-[0_0_8px_rgba(0,242,254,0.5)]" />
|
|
</div>
|
|
<div className="mt-2 flex items-baseline space-x-2">
|
|
<span className="font-headline font-bold text-xl sm:text-2xl text-white capitalize font-mono">
|
|
{club.admissionPolicy}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-[11px] font-mono text-slate-400">Club join governance rule</div>
|
|
</div>
|
|
|
|
{/* Card 3: Active Members */}
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-lg relative overflow-hidden">
|
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-emerald-400/50 via-transparent to-transparent" />
|
|
<div className="flex items-center justify-between text-slate-400 text-xs font-mono tracking-wider">
|
|
<span>Active Members</span>
|
|
<span className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(16,185,129,0.5)]" />
|
|
</div>
|
|
<div className="mt-2 flex items-baseline space-x-2">
|
|
<span className="font-headline font-bold text-2xl sm:text-3xl text-emerald-400">{localMemberCount}</span>
|
|
</div>
|
|
<div className="mt-1 text-[11px] font-mono text-slate-400">Current club roster</div>
|
|
</div>
|
|
|
|
{/* Card 4: Reviewer Authority */}
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-lg relative overflow-hidden">
|
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-slate-400/50 via-transparent to-transparent" />
|
|
<div className="flex items-center justify-between text-slate-400 text-xs font-mono tracking-wider">
|
|
<span>Review Authority</span>
|
|
<span className="w-2 h-2 rounded-full bg-slate-400" />
|
|
</div>
|
|
<div className="mt-2 flex items-baseline space-x-2">
|
|
<span className="font-headline font-bold text-xl sm:text-2xl text-slate-200 font-mono">
|
|
{permissions.role === 'owner' ? 'Owner Access' : 'Admin Access'}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-[11px] font-mono text-slate-400">Software administrative rights</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Error Alert */}
|
|
{actionError && (
|
|
<div className="p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center justify-between">
|
|
<span>{actionError}</span>
|
|
<button
|
|
onClick={() => setActionError(null)}
|
|
className="text-slate-400 hover:text-white text-xs font-mono ml-3 p-1"
|
|
aria-label="Dismiss error"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 3. FILTER & SEARCH TOOLBAR */}
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-3.5 flex flex-wrap items-center justify-between gap-3 shadow-md">
|
|
{/* Filter Tab */}
|
|
<div className="flex items-center space-x-1.5">
|
|
<button
|
|
type="button"
|
|
className="px-3.5 py-1.5 rounded-xl bg-brand-cyan/15 border border-brand-cyan/40 text-brand-cyan text-xs font-semibold shadow-sm transition-colors flex items-center space-x-2"
|
|
>
|
|
<span>All Pending</span>
|
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-mono bg-black/40 text-brand-cyan font-bold border border-brand-cyan/30">
|
|
{applications.length}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Controls Right */}
|
|
<div className="flex flex-1 sm:flex-initial items-center space-x-2.5">
|
|
{/* Text Search Bar */}
|
|
<div className="relative flex-1 sm:w-64">
|
|
<input
|
|
type="text"
|
|
placeholder="Search applicants by name or @handle..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full h-9 pl-9 pr-8 rounded-xl bg-surface-container-low/90 border border-brand-border/80 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan transition-colors"
|
|
aria-label="Search applicants"
|
|
/>
|
|
<svg
|
|
className="w-4 h-4 text-slate-500 absolute left-3 top-2.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-2.5 top-2.5 text-slate-500 hover:text-white text-xs p-0.5"
|
|
aria-label="Clear search"
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sort By Dropdown */}
|
|
<div className="flex items-center space-x-1.5 bg-surface-container-low/90 border border-brand-border/80 rounded-xl px-3 h-9 text-xs text-slate-300">
|
|
<span className="text-[10px] font-mono text-slate-500">Sort:</span>
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value as 'newest' | 'oldest' | 'name')}
|
|
className="bg-transparent text-white focus:outline-none cursor-pointer pr-1 text-xs font-medium"
|
|
aria-label="Sort applicants"
|
|
>
|
|
<option value="newest" className="bg-surface-container-high text-white">Newest First</option>
|
|
<option value="oldest" className="bg-surface-container-high text-white">Oldest First</option>
|
|
<option value="name" className="bg-surface-container-high text-white">Name</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 4. MAIN CONTENT AREA */}
|
|
{loading ? (
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-12 text-center text-slate-400 text-xs shadow-lg">
|
|
<span className="material-symbols-outlined animate-spin text-2xl text-brand-cyan mb-2 block">
|
|
progress_activity
|
|
</span>
|
|
Loading pending applications...
|
|
</div>
|
|
) : error ? (
|
|
<div className="p-8 rounded-2xl glass-card border border-brand-border/60 text-center space-y-3 shadow-lg">
|
|
<div className="flex items-center justify-center space-x-1.5 text-xs font-mono font-semibold text-rose-400">
|
|
<span className="material-symbols-outlined text-lg">error</span>
|
|
<span>Error</span>
|
|
</div>
|
|
<p className="text-xs text-slate-400">{error}</p>
|
|
<button
|
|
onClick={() => void loadApplications()}
|
|
className="px-4 py-2 rounded-xl bg-surface-container-high hover:bg-surface-container-highest border border-brand-border/80 text-slate-200 text-xs font-medium transition-colors"
|
|
>
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
) : applications.length === 0 ? (
|
|
/* 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="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">
|
|
<span className="material-symbols-outlined text-2xl">person_add_disabled</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white font-mono">No Pending Applications</h3>
|
|
<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.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : filteredApplications.length === 0 ? (
|
|
/* 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="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">
|
|
<span className="material-symbols-outlined text-2xl">search_off</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white font-mono">No Applications Match</h3>
|
|
<p className="text-xs text-slate-400 mt-1">
|
|
No pending applications match "{searchQuery}".
|
|
</p>
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="text-xs text-brand-cyan hover:underline font-medium inline-flex items-center space-x-1 mt-2"
|
|
>
|
|
<span>Clear Search</span>
|
|
<span>→</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Two-Column Split View */
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
|
{/* 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="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<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">Requested</th>
|
|
<th className="py-3.5 px-4 font-medium">Status</th>
|
|
<th className="py-3.5 px-4 font-medium text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-brand-border/40 text-xs">
|
|
{filteredApplications.map((app) => {
|
|
const isSelected = selectedApplicant?.uid === app.uid;
|
|
const isMutating = mutatingUid === app.uid;
|
|
|
|
return (
|
|
<tr
|
|
key={app.uid}
|
|
onClick={() => setSelectedApplicantUid(app.uid)}
|
|
className={`cursor-pointer transition-colors ${
|
|
isSelected
|
|
? 'bg-brand-cyan/10 border-l-2 border-brand-cyan'
|
|
: 'hover:bg-surface-container-high/40'
|
|
}`}
|
|
>
|
|
<td className="py-3.5 px-4">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="w-8 h-8 rounded-xl bg-brand-cyan/15 border border-brand-cyan/40 flex items-center justify-center font-display font-bold text-xs text-brand-cyan shrink-0">
|
|
{getMonogram(app.displayName)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="font-medium text-white flex items-center gap-1.5 truncate">
|
|
{app.displayName}
|
|
{isSelected && (
|
|
<span className="px-2 py-0.5 rounded-full text-[9px] font-mono bg-brand-cyan/20 text-brand-cyan border border-brand-cyan/40 tracking-wider shrink-0">
|
|
Inspecting
|
|
</span>
|
|
)}
|
|
</div>
|
|
{app.handle && (
|
|
<div className="text-[11px] font-mono text-slate-400 truncate">
|
|
{app.handle}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 font-mono text-[11px] text-slate-300 whitespace-nowrap">
|
|
{formatDate(app.requestedAt)}
|
|
</td>
|
|
<td className="py-3.5 px-4 whitespace-nowrap">
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-mono font-semibold bg-amber-500/15 border border-amber-500/40 text-amber-400">
|
|
Pending
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-right whitespace-nowrap">
|
|
<div
|
|
className="flex items-center justify-end space-x-1.5"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedApplicantUid(app.uid)}
|
|
className="px-3 py-1 rounded-xl bg-surface-container-high hover:bg-surface-container-highest border border-brand-border/80 text-slate-300 hover:text-white text-[11px] font-medium transition-colors"
|
|
>
|
|
Review
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => setApproveModalApplicant(app)}
|
|
className="px-3 py-1 rounded-xl bg-brand-cyan text-obsidian-darkest hover:bg-cyan-300 text-[11px] font-bold shadow-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isMutating ? '...' : 'Approve'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => setDeclineModalApplicant(app)}
|
|
className="px-3 py-1 rounded-xl bg-transparent hover:bg-rose-500/10 border border-brand-border/80 hover:border-rose-500/40 text-slate-400 hover:text-rose-400 text-[11px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Decline
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination / Count Footer */}
|
|
<div className="p-3.5 border-t border-brand-border/60 bg-surface-container-low/40 flex items-center justify-between text-xs text-slate-400">
|
|
<span className="font-mono text-[11px]">
|
|
Showing {filteredApplications.length} of {applications.length} pending applications
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column: Selected Application Inspector Drawer */}
|
|
{selectedApplicant && (
|
|
<div className="lg:col-span-5 xl:col-span-4 space-y-4">
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg relative">
|
|
{/* 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="p-5 sm:p-6 space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="w-11 h-11 rounded-xl bg-brand-cyan/15 border border-brand-cyan/40 flex items-center justify-center font-display font-bold text-base text-brand-cyan">
|
|
{getMonogram(selectedApplicant.displayName)}
|
|
</div>
|
|
<div>
|
|
<h3 className="font-headline font-bold text-base text-white">
|
|
{selectedApplicant.displayName}
|
|
</h3>
|
|
{selectedApplicant.handle && (
|
|
<div className="font-mono text-xs text-slate-400">
|
|
{selectedApplicant.handle}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end space-y-1">
|
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-mono font-semibold bg-amber-500/15 border border-amber-500/40 text-amber-400">
|
|
PENDING
|
|
</span>
|
|
<span className="px-2 py-0.5 rounded-full text-[9px] font-mono text-slate-400 bg-surface-container-high border border-brand-border/60">
|
|
JOIN REQUEST
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-brand-border/40" />
|
|
|
|
{/* Rider Spec Grid */}
|
|
<div className="space-y-2.5 text-xs">
|
|
<div className="flex justify-between items-center py-1 border-b border-brand-border/30">
|
|
<span className="text-slate-400">Requested</span>
|
|
<span className="font-mono text-white text-[11px]">
|
|
{formatDate(selectedApplicant.requestedAt)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center py-1 border-b border-brand-border/30">
|
|
<span className="text-slate-400">Initial Club Rank</span>
|
|
<span className="font-mono text-white text-[11px]">Member (Default)</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center py-1 border-b border-brand-border/30">
|
|
<span className="text-slate-400">Administrative Role</span>
|
|
<span className="font-mono text-white text-[11px]">Member (Non-admin)</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mandatory Privacy Architecture Notice */}
|
|
<div className="p-3.5 rounded-xl bg-surface-container-low/80 border border-brand-cyan/30 space-y-1.5">
|
|
<div className="flex items-center space-x-1.5 text-[11px] font-mono font-semibold text-brand-cyan">
|
|
<span className="material-symbols-outlined text-[16px] shrink-0">shield</span>
|
|
<span>MANDATORY PRIVACY ARCHITECTURE</span>
|
|
</div>
|
|
<p className="text-[11px] text-slate-400 leading-normal">
|
|
Club administrators do not receive rider telemetry, private routes, or live GPS upon application. Club membership alone never grants access to rider location. Location sharing is strictly scoped to mutually joined active Group Rides.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Architecture Note */}
|
|
<div className="p-3 rounded-xl bg-surface-container-high/60 border border-brand-border/60 text-[10px] text-slate-400 font-mono leading-tight">
|
|
ⓘ Club ranks are organizational titles. Administrative roles determine Club management permissions. ClubMember != GroupRideParticipant.
|
|
</div>
|
|
|
|
{/* Primary Actions */}
|
|
<div className="space-y-2 pt-1">
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => setApproveModalApplicant(selectedApplicant)}
|
|
className="w-full h-10 rounded-xl bg-brand-cyan hover:bg-cyan-300 text-obsidian-darkest font-bold text-xs shadow-[0_0_15px_rgba(0,242,254,0.3)] transition-all flex items-center justify-center space-x-1.5 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">check</span>
|
|
<span>{mutatingUid === selectedApplicant.uid ? 'Processing...' : 'Approve Application'}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => setDeclineModalApplicant(selectedApplicant)}
|
|
className="w-full h-10 rounded-xl bg-transparent hover:bg-rose-500/10 border border-brand-border/80 hover:border-rose-500/40 text-slate-400 hover:text-rose-400 font-medium text-xs transition-colors flex items-center justify-center space-x-1.5 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">close</span>
|
|
<span>Decline Application</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* MODAL 1: APPROVE APPLICATION */}
|
|
{approveModalApplicant && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
|
<div className="rounded-2xl glass-card border border-brand-border/80 bg-surface-container-low/95 backdrop-blur-xl p-6 max-w-md w-full shadow-2xl relative">
|
|
<div className="flex items-center justify-between pb-3 mb-4 border-b border-brand-border/60">
|
|
<div className="flex items-center space-x-2">
|
|
<span className="w-2 h-2 rounded-full bg-brand-cyan" />
|
|
<span className="text-xs font-mono tracking-wider text-brand-cyan font-semibold">
|
|
Approval Confirmation
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 className="font-headline font-bold text-base text-white">Approve Application?</h4>
|
|
<p className="text-xs text-slate-300 mt-2 leading-relaxed">
|
|
Approve <span className="text-white font-semibold">{approveModalApplicant.displayName}</span>'s request to join <span className="text-white font-semibold">{club.name}</span>? On approval, this rider becomes an active Club member.
|
|
</p>
|
|
|
|
<div className="mt-3.5 p-3.5 rounded-xl bg-surface-container-high border border-brand-border/60 space-y-2 text-xs">
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-400">Initial Club Rank:</span>
|
|
<span className="font-mono text-white">Member <span className="text-slate-500">(Default)</span></span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-400">Administrative Role:</span>
|
|
<span className="font-mono text-white">Member <span className="text-slate-500">(Default Member, non-admin)</span></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 p-3 rounded-xl bg-surface-container-low/80 border border-brand-border/60 text-[10px] text-slate-400 font-mono leading-tight">
|
|
ⓘ Club ranks are organizational titles. Administrative roles determine Club management permissions. ClubMember != GroupRideParticipant.
|
|
</div>
|
|
|
|
<div className="mt-5 flex items-center justify-end space-x-2.5">
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => setApproveModalApplicant(null)}
|
|
className="px-4 py-2 rounded-xl text-xs text-slate-400 hover:text-white border border-brand-border/80 hover:bg-surface-container-high transition-colors font-mono"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => void handleApprove(approveModalApplicant)}
|
|
className="px-4 py-2 rounded-xl text-xs font-bold text-obsidian-darkest bg-brand-cyan hover:bg-cyan-300 transition-all shadow-[0_0_12px_rgba(0,242,254,0.3)] disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{mutatingUid === approveModalApplicant.uid ? 'Approving...' : 'Approve Member'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* MODAL 2: DECLINE APPLICATION */}
|
|
{declineModalApplicant && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
|
<div className="rounded-2xl glass-card border border-brand-border/80 bg-surface-container-low/95 backdrop-blur-xl p-6 max-w-md w-full shadow-2xl relative">
|
|
<div className="flex items-center justify-between pb-3 mb-4 border-b border-brand-border/60">
|
|
<div className="flex items-center space-x-2">
|
|
<span className="w-2 h-2 rounded-full bg-slate-400" />
|
|
<span className="text-xs font-mono tracking-wider text-slate-300">
|
|
Decline Application
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 className="font-headline font-bold text-base text-white">Decline Application?</h4>
|
|
<p className="text-xs text-slate-300 mt-2 leading-relaxed">
|
|
Decline <span className="text-white font-semibold">{declineModalApplicant.displayName}</span>'s request to join this Club? This removes the pending request. The rider's MotorSignal account remains unaffected.
|
|
</p>
|
|
|
|
<div className="mt-5 flex items-center justify-end space-x-2.5">
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => setDeclineModalApplicant(null)}
|
|
className="px-4 py-2 rounded-xl text-xs text-slate-400 hover:text-white border border-brand-border/80 hover:bg-surface-container-high transition-colors font-mono"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => void handleDecline(declineModalApplicant)}
|
|
className="px-4 py-2 rounded-xl text-xs font-bold text-white bg-rose-500 hover:bg-rose-400 transition-all shadow-[0_0_12px_rgba(244,63,94,0.3)] disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{mutatingUid === declineModalApplicant.uid ? 'Declining...' : 'Decline Application'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|