459 lines
22 KiB
TypeScript
459 lines
22 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
X,
|
||
Users,
|
||
Trophy,
|
||
BarChart3,
|
||
TrendingUp,
|
||
TrendingDown,
|
||
Shield,
|
||
Eye,
|
||
EyeOff,
|
||
Medal,
|
||
Sparkles,
|
||
Target,
|
||
Flag,
|
||
RotateCcw,
|
||
} from 'lucide-react';
|
||
import { useAuth } from '../context/AuthContext';
|
||
import { communityApi } from '../services/api';
|
||
import { PublicGolferStats, CommunityComparisonData, ComparisonMetric } from '../types/auth';
|
||
|
||
interface CommunityComparisonModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onOpenAuthModal?: () => void;
|
||
}
|
||
|
||
export const CommunityComparisonModal: React.FC<CommunityComparisonModalProps> = ({
|
||
isOpen,
|
||
onClose,
|
||
onOpenAuthModal,
|
||
}) => {
|
||
const { user, updateProfile } = useAuth();
|
||
|
||
const [activeTab, setActiveTab] = useState<'compare' | 'leaderboard'>('compare');
|
||
const [bracketFilter, setBracketFilter] = useState<'all' | 'scratch' | 'mid' | 'high'>('all');
|
||
const [data, setData] = useState<CommunityComparisonData | null>(null);
|
||
const [leaderboard, setLeaderboard] = useState<PublicGolferStats[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [updatingPrivacy, setUpdatingPrivacy] = useState(false);
|
||
|
||
const fetchData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const [compRes, leadRes] = await Promise.all([
|
||
communityApi.getComparison(),
|
||
communityApi.getLeaderboard(),
|
||
]);
|
||
setData(compRes);
|
||
setLeaderboard(leadRes.golfers);
|
||
} catch (err) {
|
||
console.error('Failed to load community data:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
fetchData();
|
||
}
|
||
}, [isOpen, user?.id]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
const handleTogglePrivacy = async () => {
|
||
if (!user) {
|
||
if (onOpenAuthModal) onOpenAuthModal();
|
||
return;
|
||
}
|
||
try {
|
||
setUpdatingPrivacy(true);
|
||
const newStatus = !user.isPublic;
|
||
await updateProfile({ isPublic: newStatus });
|
||
await fetchData();
|
||
} catch (err) {
|
||
console.error('Failed to update privacy:', err);
|
||
} finally {
|
||
setUpdatingPrivacy(false);
|
||
}
|
||
};
|
||
|
||
const filteredGolfers = leaderboard.filter((g) => {
|
||
if (bracketFilter === 'scratch') return g.handicapIndex < 5;
|
||
if (bracketFilter === 'mid') return g.handicapIndex >= 5 && g.handicapIndex <= 15;
|
||
if (bracketFilter === 'high') return g.handicapIndex > 15;
|
||
return true;
|
||
});
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/80 backdrop-blur-md animate-fadeIn">
|
||
<div className="relative w-full max-w-4xl bg-stone-900 border border-stone-800 rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[92vh]">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-6 py-5 border-b border-stone-800 bg-stone-950/70">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
|
||
<Users className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-bold text-stone-100 flex items-center gap-2">
|
||
Community Benchmark & Leaderboard
|
||
<span className="text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
||
Live Global Stats
|
||
</span>
|
||
</h2>
|
||
<p className="text-xs text-stone-400">
|
||
Compare your ball-striking, putting, and scoring to golfers worldwide
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-2 text-stone-400 hover:text-stone-200 hover:bg-stone-800/80 rounded-xl transition"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* View Switcher & Privacy Bar */}
|
||
<div className="px-6 py-3 bg-stone-950/40 border-b border-stone-800 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||
<div className="flex bg-stone-950 p-1 rounded-2xl border border-stone-800 w-full sm:w-auto">
|
||
<button
|
||
onClick={() => setActiveTab('compare')}
|
||
className={`flex-1 sm:flex-initial px-4 py-2 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition ${
|
||
activeTab === 'compare'
|
||
? 'bg-emerald-600 text-white shadow-md'
|
||
: 'text-stone-400 hover:text-stone-200'
|
||
}`}
|
||
>
|
||
<BarChart3 className="w-3.5 h-3.5" />
|
||
Benchmark Comparison
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('leaderboard')}
|
||
className={`flex-1 sm:flex-initial px-4 py-2 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition ${
|
||
activeTab === 'leaderboard'
|
||
? 'bg-emerald-600 text-white shadow-md'
|
||
: 'text-stone-400 hover:text-stone-200'
|
||
}`}
|
||
>
|
||
<Trophy className="w-3.5 h-3.5" />
|
||
Public Leaderboard ({leaderboard.length})
|
||
</button>
|
||
</div>
|
||
|
||
{/* Privacy Status & Sync */}
|
||
<div className="flex items-center gap-3 w-full sm:w-auto justify-between sm:justify-end">
|
||
{user ? (
|
||
<button
|
||
onClick={handleTogglePrivacy}
|
||
disabled={updatingPrivacy}
|
||
className={`px-3 py-1.5 rounded-xl text-xs font-medium border flex items-center gap-2 transition ${
|
||
user.isPublic
|
||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||
: 'bg-stone-800/80 border-stone-700 text-stone-400 hover:text-stone-200'
|
||
}`}
|
||
>
|
||
{user.isPublic ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||
<span>
|
||
Stats: <strong className="font-bold">{user.isPublic ? 'Public' : 'Private'}</strong>
|
||
</span>
|
||
<span className="text-[10px] text-stone-500">(Click to change)</span>
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={onOpenAuthModal}
|
||
className="px-3 py-1.5 bg-stone-800 hover:bg-stone-700 border border-stone-700 rounded-xl text-xs text-stone-300 font-medium flex items-center gap-1.5 transition"
|
||
>
|
||
<Sparkles className="w-3 h-3 text-amber-400" />
|
||
Sign in to share your stats
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
onClick={fetchData}
|
||
title="Refresh Stats"
|
||
className="p-1.5 text-stone-400 hover:text-stone-200 hover:bg-stone-800 rounded-lg transition"
|
||
>
|
||
<RotateCcw className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content Body */}
|
||
<div className="p-6 overflow-y-auto space-y-6">
|
||
{loading ? (
|
||
<div className="py-20 flex flex-col items-center justify-center space-y-3">
|
||
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||
<p className="text-xs text-stone-400">Aggregating community scorecard database...</p>
|
||
</div>
|
||
) : activeTab === 'compare' && data ? (
|
||
<div className="space-y-6">
|
||
{/* Executive Summary Cards */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<div className="p-4 bg-stone-950/80 border border-stone-800 rounded-2xl">
|
||
<p className="text-[11px] font-semibold text-stone-400 uppercase tracking-wider">Your Scoring Avg</p>
|
||
<p className="text-2xl font-black font-mono text-stone-100 mt-1">{data.userStats.scoringAverage}</p>
|
||
<p className="text-[11px] text-stone-500 mt-0.5">
|
||
Comm Avg: <span className="font-mono text-stone-300">{data.communityAverage.scoringAverage}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div className="p-4 bg-stone-950/80 border border-stone-800 rounded-2xl">
|
||
<p className="text-[11px] font-semibold text-stone-400 uppercase tracking-wider">Fairway Accuracy</p>
|
||
<p className="text-2xl font-black font-mono text-emerald-400 mt-1">
|
||
{data.userStats.firPercentage}%
|
||
</p>
|
||
<p className="text-[11px] text-stone-500 mt-0.5">
|
||
Comm Avg: <span className="font-mono text-stone-300">{data.communityAverage.firPercentage}%</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div className="p-4 bg-stone-950/80 border border-stone-800 rounded-2xl">
|
||
<p className="text-[11px] font-semibold text-stone-400 uppercase tracking-wider">Greens in Reg (GIR)</p>
|
||
<p className="text-2xl font-black font-mono text-sky-400 mt-1">{data.userStats.girPercentage}%</p>
|
||
<p className="text-[11px] text-stone-500 mt-0.5">
|
||
Comm Avg: <span className="font-mono text-stone-300">{data.communityAverage.girPercentage}%</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div className="p-4 bg-stone-950/80 border border-stone-800 rounded-2xl">
|
||
<p className="text-[11px] font-semibold text-stone-400 uppercase tracking-wider">Putts Per Round</p>
|
||
<p className="text-2xl font-black font-mono text-amber-400 mt-1">{data.userStats.puttsPerRound}</p>
|
||
<p className="text-[11px] text-stone-500 mt-0.5">
|
||
Comm Avg: <span className="font-mono text-stone-300">{data.communityAverage.puttsPerRound}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Benchmark Breakdown Table */}
|
||
<div className="bg-stone-950/60 border border-stone-800 rounded-2xl p-5 space-y-4">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-stone-800/80 pb-3">
|
||
<div>
|
||
<h3 className="text-sm font-bold text-stone-200">Head-to-Head Performance Matrix</h3>
|
||
<p className="text-xs text-stone-400">
|
||
Comparing against global database ({data.communityAverage.totalPublicGolfers} public golfers, {data.communityAverage.totalPublicRounds} rounds)
|
||
</p>
|
||
</div>
|
||
<span className="px-2.5 py-1 bg-stone-800/80 border border-stone-700 rounded-xl text-[11px] text-stone-300 font-medium">
|
||
Handicap Bracket: <strong>{data.handicapBracket}</strong>
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{data.metrics.map((metric: ComparisonMetric) => {
|
||
const isPositive = metric.lowerIsBetter
|
||
? metric.userValue <= metric.communityAverage
|
||
: metric.userValue >= metric.communityAverage;
|
||
|
||
return (
|
||
<div
|
||
key={metric.key}
|
||
className="p-3.5 bg-stone-900/60 border border-stone-800/70 rounded-xl flex flex-col md:flex-row md:items-center justify-between gap-3 hover:border-stone-700/80 transition"
|
||
>
|
||
<div className="md:w-1/3">
|
||
<p className="text-xs font-bold text-stone-200">{metric.label}</p>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<span className="text-[11px] text-stone-400">
|
||
Bracket Avg: <strong className="font-mono text-stone-300">{metric.bracketAverage} {metric.unit}</strong>
|
||
</span>
|
||
<span className="text-stone-600">•</span>
|
||
<span className="text-[11px] text-stone-400">
|
||
Percentile: <strong className="font-mono text-emerald-400">{metric.percentileRank}%</strong>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Comparative Bar Visual */}
|
||
<div className="flex-1 space-y-1.5">
|
||
<div className="flex justify-between text-[11px]">
|
||
<span className="text-stone-400">
|
||
You: <strong className="text-stone-100 font-mono">{metric.userValue} {metric.unit}</strong>
|
||
</span>
|
||
<span className="text-stone-400">
|
||
Community Avg: <strong className="text-stone-300 font-mono">{metric.communityAverage} {metric.unit}</strong>
|
||
</span>
|
||
</div>
|
||
<div className="w-full h-2.5 bg-stone-800 rounded-full overflow-hidden relative">
|
||
<div
|
||
className={`h-full rounded-full transition-all duration-500 ${
|
||
isPositive ? 'bg-emerald-500' : 'bg-amber-500'
|
||
}`}
|
||
style={{ width: `${Math.min(100, Math.max(10, metric.percentileRank))}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status Badge */}
|
||
<div className="md:w-32 flex md:justify-end items-center">
|
||
<span
|
||
className={`px-2.5 py-1 rounded-xl text-[11px] font-bold flex items-center gap-1 border ${
|
||
isPositive
|
||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
||
: 'bg-amber-500/10 border-amber-500/20 text-amber-400'
|
||
}`}
|
||
>
|
||
{isPositive ? (
|
||
<TrendingUp className="w-3.5 h-3.5" />
|
||
) : (
|
||
<TrendingDown className="w-3.5 h-3.5" />
|
||
)}
|
||
{isPositive ? 'Top Tier' : 'Growth Area'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Coaching Insights */}
|
||
<div className="p-4 bg-stone-950/60 border border-stone-800 rounded-2xl flex items-start gap-3">
|
||
<Target className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
|
||
<div className="text-xs space-y-1">
|
||
<p className="font-bold text-stone-200">Automated Coaching Insight from Community Benchmarks</p>
|
||
<p className="text-stone-400 leading-relaxed">
|
||
Based on players in your handicap bracket ({data.handicapBracket}), improving your{' '}
|
||
<strong className="text-stone-200">Greens in Regulation</strong> and{' '}
|
||
<strong className="text-stone-200">Scrambling conversion</strong> represents the highest mathematical
|
||
leverage for cutting 2 to 4 strokes off your 18-hole total.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : activeTab === 'leaderboard' ? (
|
||
<div className="space-y-4">
|
||
{/* Bracket Filter Tabs */}
|
||
<div className="flex flex-wrap gap-2">
|
||
{[
|
||
{ key: 'all', label: 'All Public Golfers' },
|
||
{ key: 'scratch', label: 'Scratch (< 5.0 Hcp)' },
|
||
{ key: 'mid', label: 'Mid-Cap (5–15 Hcp)' },
|
||
{ key: 'high', label: 'High-Cap (15+ Hcp)' },
|
||
].map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setBracketFilter(tab.key as any)}
|
||
className={`px-3 py-1.5 rounded-xl text-xs font-semibold border transition ${
|
||
bracketFilter === tab.key
|
||
? 'bg-emerald-600 border-emerald-500 text-white'
|
||
: 'bg-stone-950 border-stone-800 text-stone-400 hover:text-stone-200 hover:border-stone-700'
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Leaderboard Table */}
|
||
<div className="overflow-x-auto border border-stone-800 rounded-2xl bg-stone-950/60">
|
||
<table className="w-full text-left border-collapse text-xs">
|
||
<thead>
|
||
<tr className="border-b border-stone-800 bg-stone-900/60 text-stone-400 uppercase tracking-wider text-[10px]">
|
||
<th className="py-3 px-4">Rank</th>
|
||
<th className="py-3 px-4">Golfer</th>
|
||
<th className="py-3 px-3 text-center">Handicap</th>
|
||
<th className="py-3 px-3 text-center">Rounds</th>
|
||
<th className="py-3 px-3 text-center">Scoring Avg</th>
|
||
<th className="py-3 px-3 text-center">Best Gross</th>
|
||
<th className="py-3 px-3 text-center">FIR %</th>
|
||
<th className="py-3 px-3 text-center">GIR %</th>
|
||
<th className="py-3 px-3 text-center">Putts/Rnd</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-stone-800/60 text-stone-200">
|
||
{filteredGolfers.map((golfer, idx) => {
|
||
const isCurrentUser = user && (user.id === golfer.id || user.username === golfer.username);
|
||
|
||
return (
|
||
<tr
|
||
key={golfer.id}
|
||
className={`hover:bg-stone-850/50 transition ${
|
||
isCurrentUser ? 'bg-emerald-500/10 font-semibold' : ''
|
||
}`}
|
||
>
|
||
<td className="py-3 px-4 font-mono font-bold text-stone-400">
|
||
{idx === 0 ? (
|
||
<span className="flex items-center gap-1 text-amber-400">
|
||
<Medal className="w-3.5 h-3.5" /> 1
|
||
</span>
|
||
) : idx === 1 ? (
|
||
<span className="flex items-center gap-1 text-slate-300">
|
||
<Medal className="w-3.5 h-3.5" /> 2
|
||
</span>
|
||
) : idx === 2 ? (
|
||
<span className="flex items-center gap-1 text-amber-600">
|
||
<Medal className="w-3.5 h-3.5" /> 3
|
||
</span>
|
||
) : (
|
||
`#${idx + 1}`
|
||
)}
|
||
</td>
|
||
<td className="py-3 px-4">
|
||
<div className="flex items-center gap-2.5">
|
||
{golfer.avatarUrl ? (
|
||
<img
|
||
src={golfer.avatarUrl}
|
||
alt={golfer.name}
|
||
className="w-6 h-6 rounded-full object-cover border border-stone-700"
|
||
/>
|
||
) : (
|
||
<div className="w-6 h-6 rounded-full bg-stone-800 border border-stone-700 flex items-center justify-center text-[10px] font-bold text-stone-300">
|
||
{golfer.name.charAt(0)}
|
||
</div>
|
||
)}
|
||
<div>
|
||
<p className="font-bold text-stone-100 flex items-center gap-1.5">
|
||
{golfer.name}
|
||
{isCurrentUser && (
|
||
<span className="text-[9px] bg-emerald-500 text-stone-950 font-black px-1.5 py-0.2 rounded-md">
|
||
YOU
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-[10px] text-stone-500">{golfer.homeClub}</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="py-3 px-3 text-center font-mono font-bold text-emerald-400">
|
||
{golfer.handicapIndex > 0 ? golfer.handicapIndex : 'Scratch'}
|
||
</td>
|
||
<td className="py-3 px-3 text-center font-mono text-stone-400">{golfer.roundsCount}</td>
|
||
<td className="py-3 px-3 text-center font-mono font-bold text-stone-100">
|
||
{golfer.scoringAverage}
|
||
</td>
|
||
<td className="py-3 px-3 text-center font-mono text-stone-300">{golfer.bestGross}</td>
|
||
<td className="py-3 px-3 text-center font-mono text-stone-300">{golfer.firPercentage}%</td>
|
||
<td className="py-3 px-3 text-center font-mono text-stone-300">{golfer.girPercentage}%</td>
|
||
<td className="py-3 px-3 text-center font-mono text-amber-400 font-semibold">
|
||
{golfer.puttsPerRound}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="px-6 py-4 border-t border-stone-800 bg-stone-950/80 flex items-center justify-between text-xs text-stone-500">
|
||
<div className="flex items-center gap-2">
|
||
<Shield className="w-3.5 h-3.5 text-emerald-500" />
|
||
<span>Anonymous peer comparison enabled. Only public scorecards contribute to leaderboard.</span>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-1.5 bg-stone-800 hover:bg-stone-700 text-stone-200 rounded-xl text-xs font-semibold transition"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|