676 lines
32 KiB
TypeScript
676 lines
32 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 */}
|
|
<div className="flex items-center justify-between pb-3 border-b border-[#262D3D]/60">
|
|
<div>
|
|
<div className="flex items-center space-x-3">
|
|
<h1 className="font-display font-bold text-2xl text-white tracking-wide">APPLICATIONS</h1>
|
|
<span
|
|
className="px-2 py-0.5 rounded text-xs font-mono font-medium bg-amber-500/10 border border-amber-500/40 text-amber-400"
|
|
aria-label={`${applications.length} pending applications`}
|
|
>
|
|
{applications.length} PENDING
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-slate-400 mt-1">Review riders requesting to join this Club.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4 relative overflow-hidden">
|
|
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
|
|
<span>Pending</span>
|
|
<span className="w-2 h-2 rounded-full bg-amber-400 shadow-[0_0_6px_#f59e0b]" />
|
|
</div>
|
|
<div className="mt-2 flex items-baseline space-x-2">
|
|
<span className="font-display font-bold text-2xl text-white">{applications.length}</span>
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-slate-400">Awaiting review</div>
|
|
</div>
|
|
|
|
{/* Card 2: Admission Policy */}
|
|
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4">
|
|
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
|
|
<span>Admission Policy</span>
|
|
<span className="w-2 h-2 rounded-full bg-[#0088FF]" />
|
|
</div>
|
|
<div className="mt-2 flex items-baseline space-x-2">
|
|
<span className="font-display font-bold text-xl text-white uppercase font-mono">
|
|
{club.admissionPolicy}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-slate-400">Club join governance rule</div>
|
|
</div>
|
|
|
|
{/* Card 3: Active Members */}
|
|
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4">
|
|
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
|
|
<span>Active Members</span>
|
|
<span className="w-2 h-2 rounded-full bg-[#10B981]" />
|
|
</div>
|
|
<div className="mt-2 flex items-baseline space-x-2">
|
|
<span className="font-display font-bold text-2xl text-[#10B981]">{localMemberCount}</span>
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-slate-400">Current club roster</div>
|
|
</div>
|
|
|
|
{/* Card 4: Reviewer Authority */}
|
|
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4">
|
|
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
|
|
<span>Review Authority</span>
|
|
<span className="w-2 h-2 rounded-full bg-slate-500" />
|
|
</div>
|
|
<div className="mt-2 flex items-baseline space-x-2">
|
|
<span className="font-display font-bold text-xl text-slate-300 uppercase font-mono">
|
|
{permissions.role === 'owner' ? 'OWNER ACCESS' : 'ADMIN ACCESS'}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-slate-400">Software administrative rights</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Error Alert */}
|
|
{actionError && (
|
|
<div className="p-3 rounded-md 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"
|
|
aria-label="Dismiss error"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 3. FILTER & SEARCH TOOLBAR */}
|
|
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-3 flex flex-wrap items-center justify-between gap-3">
|
|
{/* Filter Tab */}
|
|
<div className="flex items-center space-x-1.5">
|
|
<button
|
|
type="button"
|
|
className="px-3 py-1.5 rounded-md bg-[#0088FF] text-white text-xs font-semibold shadow-sm transition-colors flex items-center space-x-1.5"
|
|
>
|
|
<span>All Pending</span>
|
|
<span className="px-1.5 py-0.2 rounded text-[10px] font-mono bg-black/30 text-white">
|
|
{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-8 pl-8 pr-8 rounded-md bg-[#141923] border border-[#262D3D] text-xs text-white placeholder-slate-500 focus:outline-none focus:border-[#0088FF]"
|
|
aria-label="Search applicants"
|
|
/>
|
|
<svg
|
|
className="w-3.5 h-3.5 text-slate-500 absolute left-2.5 top-2.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 text-slate-500 hover:text-white text-xs"
|
|
aria-label="Clear search"
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sort By Dropdown */}
|
|
<div className="flex items-center space-x-1 bg-[#141923] border border-[#262D3D] rounded-md px-2.5 h-8 text-xs text-slate-300">
|
|
<span className="text-[10px] font-mono text-slate-500 uppercase">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-[#141923] text-white">Newest First</option>
|
|
<option value="oldest" className="bg-[#141923] text-white">Oldest First</option>
|
|
<option value="name" className="bg-[#141923] text-white">Name</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 4. MAIN CONTENT AREA */}
|
|
{loading ? (
|
|
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-12 text-center text-slate-400 text-xs">
|
|
<span className="material-symbols-outlined animate-spin text-2xl text-[#0088FF] mb-2 block">
|
|
progress_activity
|
|
</span>
|
|
Loading pending applications...
|
|
</div>
|
|
) : error ? (
|
|
<div className="p-6 rounded-lg bg-[#0F131B] border border-[#262D3D] text-center space-y-3">
|
|
<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-3 py-1.5 rounded bg-[#141923] hover:bg-[#181C23] border border-[#262D3D] 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-8 rounded-lg bg-[#0F131B] border border-[#262D3D] text-center space-y-3">
|
|
<div className="w-12 h-12 rounded-full bg-[#141923] border border-[#262D3D] 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 uppercase 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-8 rounded-lg bg-[#0F131B] border border-[#262D3D] text-center space-y-3">
|
|
<div className="w-12 h-12 rounded-full bg-[#141923] border border-[#262D3D] 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 uppercase 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-[#0088FF] 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 bg-[#0F131B] border border-[#262D3D] rounded-lg overflow-hidden flex flex-col">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-[#262D3D] bg-[#141923]/70 text-[11px] font-mono text-slate-400 uppercase tracking-wider">
|
|
<th className="py-3 px-4 font-medium">Rider</th>
|
|
<th className="py-3 px-4 font-medium">Requested</th>
|
|
<th className="py-3 px-4 font-medium">Status</th>
|
|
<th className="py-3 px-4 font-medium text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[#262D3D]/60 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-[#141923] border-l-2 border-[#0088FF]'
|
|
: 'hover:bg-[#141923]/50'
|
|
}`}
|
|
>
|
|
<td className="py-3 px-4">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="w-8 h-8 rounded-md bg-[#0088FF]/20 border border-[#0088FF]/60 flex items-center justify-center font-display font-bold text-xs text-[#00F2FE] 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-1.5 py-0.2 rounded text-[9px] font-mono bg-[#0088FF]/20 text-[#00F2FE] border border-[#0088FF]/40 uppercase 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 px-4 font-mono text-[11px] text-slate-300 whitespace-nowrap">
|
|
{formatDate(app.requestedAt)}
|
|
</td>
|
|
<td className="py-3 px-4 whitespace-nowrap">
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-mono font-medium bg-amber-500/10 border border-amber-500/40 text-amber-400">
|
|
PENDING
|
|
</span>
|
|
</td>
|
|
<td className="py-3 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-2.5 py-1 rounded bg-[#141923] hover:bg-[#181C23] border border-[#262D3D] 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-2.5 py-1 rounded bg-[#0088FF] text-white text-[11px] font-semibold hover:bg-[#0077e6] transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isMutating ? '...' : 'Approve'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => setDeclineModalApplicant(app)}
|
|
className="px-2.5 py-1 rounded bg-transparent hover:bg-rose-500/10 border border-[#262D3D] 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-[#262D3D] bg-[#0F131B] 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="bg-[#141923] border border-[#262D3D] rounded-lg overflow-hidden shadow-lg relative">
|
|
{/* Top Cyan Accent Line */}
|
|
<div className="h-0.5 w-full bg-gradient-to-r from-[#0088FF] via-[#00F2FE] to-transparent" />
|
|
|
|
<div className="p-5 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-lg bg-[#0088FF]/20 border border-[#0088FF]/60 flex items-center justify-center font-display font-bold text-base text-[#00F2FE]">
|
|
{getMonogram(selectedApplicant.displayName)}
|
|
</div>
|
|
<div>
|
|
<h3 className="font-display 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 py-0.5 rounded text-[10px] font-mono font-medium bg-amber-500/15 border border-amber-500/40 text-amber-400">
|
|
PENDING
|
|
</span>
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono text-slate-400 bg-[#0A0E15] border border-[#262D3D]">
|
|
JOIN REQUEST
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-[#262D3D]" />
|
|
|
|
{/* Rider Spec Grid */}
|
|
<div className="space-y-2.5 text-xs">
|
|
<div className="flex justify-between items-center py-1 border-b border-[#262D3D]/50">
|
|
<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-[#262D3D]/50">
|
|
<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-[#262D3D]/50">
|
|
<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 rounded-lg bg-[#0A0E15] border border-[#0088FF]/30 space-y-1.5">
|
|
<div className="flex items-center space-x-1.5 text-[11px] font-mono font-semibold text-[#00F2FE]">
|
|
<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-2.5 rounded bg-[#0A0E15] border border-[#262D3D] 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-9 rounded-md bg-[#0088FF] hover:bg-[#0077e6] text-white font-semibold text-xs shadow transition-colors flex items-center justify-center space-x-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<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-9 rounded-md bg-transparent hover:bg-rose-500/10 border border-[#262D3D] 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"
|
|
>
|
|
<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="bg-[#0F131B] border border-[#0088FF]/50 rounded-lg p-5 max-w-md w-full shadow-2xl relative">
|
|
<div className="flex items-center justify-between pb-2 mb-3 border-b border-[#262D3D]">
|
|
<div className="flex items-center space-x-2">
|
|
<span className="w-2 h-2 rounded-full bg-[#0088FF]" />
|
|
<span className="text-xs font-mono uppercase tracking-wider text-[#00F2FE]">
|
|
APPROVAL CONFIRMATION
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 className="font-display 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 p-3 rounded bg-[#141923] border border-[#262D3D] space-y-1.5 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-2.5 p-2 rounded bg-[#0A0E15] border border-[#262D3D] 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-4 flex items-center justify-end space-x-2">
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => setApproveModalApplicant(null)}
|
|
className="px-3 py-1.5 rounded text-xs text-slate-400 hover:text-white border border-[#262D3D] hover:bg-[#141923] transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => void handleApprove(approveModalApplicant)}
|
|
className="px-3 py-1.5 rounded text-xs font-semibold text-white bg-[#0088FF] hover:bg-[#0077e6] transition-colors shadow-sm disabled:opacity-50"
|
|
>
|
|
{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="bg-[#0F131B] border border-[#262D3D] rounded-lg p-5 max-w-md w-full shadow-2xl relative">
|
|
<div className="flex items-center justify-between pb-2 mb-3 border-b border-[#262D3D]">
|
|
<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 uppercase tracking-wider text-slate-300">
|
|
DECLINE APPLICATION
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 className="font-display 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-4 flex items-center justify-end space-x-2">
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => setDeclineModalApplicant(null)}
|
|
className="px-3 py-1.5 rounded text-xs text-slate-400 hover:text-white border border-[#262D3D] hover:bg-[#141923] transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(mutatingUid)}
|
|
onClick={() => void handleDecline(declineModalApplicant)}
|
|
className="px-3 py-1.5 rounded text-xs font-medium text-slate-300 hover:text-rose-400 border border-[#262D3D] hover:border-rose-500/40 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
|
|
>
|
|
{mutatingUid === declineModalApplicant.uid ? 'Declining...' : 'Decline Application'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|