Files
PinPoint-Golf/src/components/CommunityComparisonModal.tsx
T

459 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};