Admin Dashboardsrc/pages/AdminPortal.jsx import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import Seo from '@/components/Seo';
import { Shield, Users, Video, Radio, BarChart3, BookOpen, UserCheck, Menu, X, FolderOpen, Download, Film, Upload, Trash2, LayoutGrid, Inbox, Trophy, ClipboardList } from 'lucide-react';
import { Button } from '@/components/ui/button';
import AdminFeatureAccess from '../components/admin/AdminFeatureAccess';
import AdminTeams from '../components/admin/AdminTeams';
import AdminStatGame from '../components/admin/AdminStatGame';
import AdminLiveStream from '../components/admin/AdminLiveStream';
import AdminStats from '../components/admin/AdminStats';
import AdminFilm from '../components/admin/AdminFilm';
import AdminHowTo from '../components/admin/AdminHowTo';
import AdminPortfolios from '../components/admin/AdminPortfolios';
import AdminCoachVerification from '../components/admin/AdminCoachVerification';
import AdminTeamSiteDashboard from '../components/admin/AdminTeamSiteDashboard';
import AdminPlayerExport from '../components/admin/AdminPlayerExport';
import AdminTopPlayersExport from '../components/admin/AdminTopPlayersExport';
import AdminTeamSitePortfolios from '../components/admin/AdminTeamSitePortfolios';
import AdminGeorgiaFilmPush from '../components/admin/AdminGeorgiaFilmPush';
import AdminCsvImport from '../components/admin/AdminCsvImport';
import AdminIdentityImport from '../components/admin/AdminIdentityImport';
import CleanSlateReset from '../components/admin/CleanSlateReset';
import AdminUpdateRequests from '../components/admin/AdminUpdateRequests';
import AdminTournamentInquiries from '../components/admin/AdminTournamentInquiries';
import GeorgiaTeamSite from './GeorgiaTeamSite';
const NAV_TABS = [
{ id: 'admin', label: 'Admin', icon: Shield, color: 'text-orange-400' },
{ id: 'hub', label: 'HUB', icon: LayoutGrid, color: 'text-blue-400' },
{ id: 'portfolios', label: 'Portfolios', icon: FolderOpen, color: 'text-emerald-400' },
{ id: 'export', label: 'URL Export', icon: Download, color: 'text-teal-400' },
{ id: 'top300', label: 'Top 300', icon: Trophy, color: 'text-yellow-400' },
{ id: 'team_sites', label: 'Team Sites', icon: Video, color: 'text-orange-400' },
{ id: 'team_portfolios', label: 'Team Rosters', icon: Users, color: 'text-indigo-400' },
{ id: 'film_push', label: 'Film → Portfolios', icon: Film, color: 'text-orange-400' },
{ id: 'data_cleanup', label: 'Data Cleanup & Import', icon: Trash2, color: 'text-red-400' },
{ id: 'update_requests', label: 'Update Requests', icon: Inbox, color: 'text-amber-400' },
{ id: 'tournament_inquiries', label: 'Tournament Inquiries', icon: ClipboardList, color: 'text-orange-400' }];
const HUB_TABS = [
{ id: 'teams', label: 'Teams', icon: Users },
{ id: 'stat_game', label: 'Stat Game', icon: BarChart3 },
{ id: 'live_stream', label: 'Live Stream', icon: Radio },
{ id: 'stats', label: 'Stats', icon: BarChart3 },
{ id: 'film', label: 'Film', icon: Video },
{ id: 'how_to', label: 'How-To', icon: BookOpen },
{ id: 'coaches', label: 'Coaches', icon: UserCheck },
];
export default function AdminPortal() {
const [activeTab, setActiveTab] = useState('admin');
const [hubSubTab, setHubSubTab] = useState('teams');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [passwordInput, setPasswordInput] = useState('');
const [passwordError, setPasswordError] = useState(false);
const [unlocked, setUnlocked] = useState(() => sessionStorage.getItem('admin_unlocked') === 'true');
const renderHubContent = () => {
switch (hubSubTab) {
case 'teams': return <AdminTeams />;
case 'stat_game': return <AdminStatGame />;
case 'live_stream': return <AdminLiveStream />;
case 'stats': return <AdminStats />;
case 'film': return <AdminFilm />;
case 'how_to': return <AdminHowTo />;
case 'coaches': return <AdminCoachVerification />;
default: return <AdminTeams />;
}
};
const renderTab = () => {
switch (activeTab) {
case 'admin':return <AdminFeatureAccess />;
case 'hub':return (
<div>
<div className="flex items-center gap-1 mb-4 pb-3 border-b border-slate-800 overflow-x-auto">
{HUB_TABS.map(sub => {
const SubIcon = sub.icon;
const isActive = hubSubTab === sub.id;
return (
<button key={sub.id} onClick={() => setHubSubTab(sub.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-semibold whitespace-nowrap transition-all ${
isActive ? 'bg-blue-500 text-white' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}>
<SubIcon className="w-3.5 h-3.5" />{sub.label}
</button>
);
})}
</div>
{renderHubContent()}
</div>
);
case 'portfolios':return <AdminPortfolios currentUser={null} />;
case 'export':return <AdminPlayerExport />;
case 'top300':return <AdminTopPlayersExport />;
case 'team_sites':return <AdminTeamSiteDashboard />;
case 'team_portfolios':return <AdminTeamSitePortfolios />;
case 'film_push':return <AdminGeorgiaFilmPush />;
case 'data_cleanup':return (<div className="space-y-8"><CleanSlateReset /><AdminIdentityImport /><AdminCsvImport /></div>);
case 'update_requests':return <AdminUpdateRequests />;
case 'tournament_inquiries':return <AdminTournamentInquiries />;
default:return <AdminFeatureAccess />;
}
};
if (!unlocked) {
return (
<div className="min-h-screen bg-slate-950 text-white flex items-center justify-center px-4">
<div className="w-full max-w-sm bg-slate-900 rounded-2xl border border-slate-800 p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center">
<Shield className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-white font-black text-sm">Admin Portal</p>
<p className="text-slate-400 text-xs">Enter password to continue</p>
</div>
</div>
<form onSubmit={(e) => {
e.preventDefault();
if (passwordInput === '1029384756') {
sessionStorage.setItem('admin_unlocked', 'true');
setUnlocked(true);
} else {
setPasswordError(true);
setPasswordInput('');
}
}}>
<input
type="password"
value={passwordInput}
onChange={(e) => {setPasswordInput(e.target.value);setPasswordError(false);}}
placeholder="Password"
autoFocus
className={`w-full bg-slate-800 border rounded-lg px-4 py-3 text-white text-sm focus:outline-none focus:border-orange-500 mb-3 ${passwordError ? 'border-red-500' : 'border-slate-700'}`} />
{passwordError && <p className="text-red-400 text-xs mb-3">Incorrect password</p>}
<button type="submit" className="w-full bg-orange-500 hover:bg-orange-600 text-white font-bold py-3 rounded-lg text-sm transition-all">
Unlock
</button>
</form>
</div>
</div>);
}
return (
<div className="min-h-screen bg-slate-950 text-white flex flex-col">
<Seo noindex title="Admin Portal" path="/admin-portal" />
{/* Top Nav */}
<header className="bg-slate-900 border-b border-slate-800 sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center h-14 gap-4">
{/* Logo */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center">
<Shield className="w-4 h-4 text-white" />
</div>
<div className="hidden sm:block">
<p className="text-sm font-black text-white leading-tight">goaio.live</p>
<p className="text-[10px] text-slate-400 leading-tight">Stats-By-Video Platform</p>
</div>
</div>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-1 flex-1 ml-4">
{NAV_TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-semibold transition-all ${
isActive ?
tab.id === 'admin' ? 'bg-orange-500 text-white' :
tab.id === 'hub' ? 'bg-blue-500 text-white' :
tab.id === 'live_stream' ? 'bg-red-500 text-white' :
tab.id === 'how_to' ? 'bg-yellow-500 text-black' :
'bg-slate-700 text-white' :
'text-slate-400 hover:text-white hover:bg-slate-800'}`
}>
<Icon className="w-3.5 h-3.5" />
{tab.label}
</button>);
})}
</nav>
{/* Right side */}
<div className="flex items-center gap-2 ml-auto">
<button
onClick={() => {sessionStorage.removeItem('admin_unlocked');setUnlocked(false);}}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors px-2 py-1">
Lock</button>
<button className="md:hidden text-slate-400 hover:text-white"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
{/* Mobile menu */}
{mobileMenuOpen &&
<div className="md:hidden pb-3 flex flex-wrap gap-2">
{NAV_TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button key={tab.id}
onClick={() => {setActiveTab(tab.id);setMobileMenuOpen(false);}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
isActive ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white'}`
}>
<Icon className="w-3.5 h-3.5" />{tab.label}
</button>);
})}
</div>
}
</div>
</header>
{/* Page Content */}
<main className="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
{renderTab()}
</main>
</div>);
}src/components/admin/AdminCoachVerification.jsx import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Clock, Mail, School, User, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
export default function AdminCoachVerification() {
const queryClient = useQueryClient();
const [verifying, setVerifying] = useState(null);
const { data: coaches = [], isLoading } = useQuery({
queryKey: ['coach-profiles'],
queryFn: () => base44.entities.CoachProfile.list('-created_date', 50),
});
const { data: teams = [] } = useQuery({
queryKey: ['hoop-teams'],
queryFn: () => base44.entities.HoopTeam.list(),
});
const teamMap = Object.fromEntries(teams.map(t => [t.id, t]));
const verifyMutation = useMutation({
mutationFn: async ({ id, is_verified }) => {
return base44.entities.CoachProfile.update(id, { is_verified });
},
onSuccess: () => {
queryClient.invalidateQueries(['coach-profiles']);
setVerifying(null);
},
});
const pending = coaches.filter(c => c.has_paid && !c.is_verified);
const verified = coaches.filter(c => c.is_verified);
const unpaid = coaches.filter(c => !c.has_paid);
const CoachRow = ({ coach, showActions }) => {
const team = coach.team_id ? teamMap[coach.team_id] : null;
return (
<div className="flex items-start gap-4 p-4 rounded-xl border" style={{ background: '#0f172a', borderColor: showActions ? 'rgba(251,146,60,0.3)' : 'rgba(255,255,255,0.06)' }}>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center shrink-0">
<User className="w-5 h-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-bold text-white">{coach.coach_name}</p>
{coach.is_verified && <span className="text-xs px-2 py-0.5 rounded-full bg-green-500/10 border border-green-500/25 text-green-400 font-bold">Verified</span>}
{coach.has_paid && !coach.is_verified && <span className="text-xs px-2 py-0.5 rounded-full bg-orange-500/10 border border-orange-500/25 text-orange-400 font-bold">Pending Review</span>}
{!coach.has_paid && <span className="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-400 font-bold">Not Paid</span>}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-xs text-slate-400">
<span className="flex items-center gap-1"><Mail className="w-3 h-3" /> {coach.email}</span>
<span className="flex items-center gap-1"><School className="w-3 h-3" /> {coach.high_school}</span>
{team && <span className="flex items-center gap-1">🏀 {team.name}</span>}
</div>
{coach.team_id && (
<a href={`/coach-dashboard?team_id=${coach.team_id}`} target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs mt-2 hover:underline" style={{ color: '#FF6A00' }}>
<ExternalLink className="w-3 h-3" /> View Team Dashboard
</a>
)}
</div>
{showActions && (
<div className="flex gap-2 shrink-0">
<Button size="sm" onClick={() => verifyMutation.mutate({ id: coach.id, is_verified: true })}
className="bg-green-600 hover:bg-green-700 text-white text-xs">
<CheckCircle2 className="w-3.5 h-3.5 mr-1" /> Approve
</Button>
</div>
)}
</div>
);
};
if (isLoading) return <div className="text-slate-400 text-sm">Loading...</div>;
return (
<div className="space-y-8">
{/* Summary */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'Pending Review', val: pending.length, color: 'text-orange-400' },
{ label: 'Verified', val: verified.length, color: 'text-green-400' },
{ label: 'Total Signups', val: coaches.length, color: 'text-white' },
].map(s => (
<div key={s.label} className="bg-slate-900 rounded-xl border border-slate-800 p-4 text-center">
<p className={`font-black text-3xl ${s.color}`}>{s.val}</p>
<p className="text-xs text-slate-500 mt-0.5">{s.label}</p>
</div>
))}
</div>
{/* Pending */}
{pending.length > 0 && (
<div>
<h2 className="text-white font-bold mb-3 flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-400" /> Pending Verification ({pending.length})
</h2>
<div className="space-y-2">
{pending.map(c => <CoachRow key={c.id} coach={c} showActions={true} />)}
</div>
</div>
)}
{/* Verified */}
{verified.length > 0 && (
<div>
<h2 className="text-white font-bold mb-3 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-400" /> Verified Coaches ({verified.length})
</h2>
<div className="space-y-2">
{verified.map(c => <CoachRow key={c.id} coach={c} showActions={false} />)}
</div>
</div>
)}
{pending.length === 0 && verified.length === 0 && (
<div className="text-center py-16 rounded-2xl border border-dashed border-slate-700">
<CheckCircle2 className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<p className="text-slate-400">No coach signups yet.</p>
</div>
)}
</div>
);
}src/components/admin/AdminCsvImport.jsx import React, { useState, useRef } from 'react';
import { base44 } from '@/api/base44Client';
import { Upload, FileText, Play, AlertCircle, CheckCircle2, Download, Loader2, XCircle, AlertTriangle, Link2, FileSpreadsheet, Users, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export default function AdminCsvImport() {
const [file, setFile] = useState(null);
const [fileUrl, setFileUrl] = useState('');
const [csvText, setCsvText] = useState('');
const [dryRun, setDryRun] = useState(true);
const [skipPublished, setSkipPublished] = useState(true);
const [createMissing, setCreateMissing] = useState(true);
const [updateExisting, setUpdateExisting] = useState(true);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [activeView, setActiveView] = useState('summary');
const fileInputRef = useRef(null);
const handleFileSelect = (e) => {
const f = e.target.files[0];
if (!f) return;
setFile(f);
setFileUrl('');
const reader = new FileReader();
reader.onload = (ev) => setCsvText(ev.target.result);
reader.readAsText(f);
};
const handleUrlMode = () => {
setFile(null);
setCsvText('');
};
const handleRun = async () => {
setLoading(true);
setError(null);
setResult(null);
try {
const payload = {
dry_run: dryRun,
skip_published: skipPublished,
create_missing: createMissing,
update_existing: updateExisting,
csv_filename: file?.name || (fileUrl ? fileUrl.split('/').pop() : 'unknown.csv')
};
if (fileUrl) {
payload.file_url = fileUrl;
} else if (csvText) {
payload.csv_text = csvText;
} else {
setError('Please upload a file or paste a CSV URL.');
setLoading(false);
return;
}
const res = await base44.functions.invoke('importPlayerStatsCSV', payload);
setResult(res.data);
} catch (err) {
setError(err?.response?.data?.error || err?.message || 'Import failed');
}
setLoading(false);
};
const downloadErrorLog = () => {
if (!result?.preview?.errors?.length) return;
const headers = ['Row', 'Player Name', 'Team (Original)', 'Team (Cleaned)', 'Error', 'Close Matches / Conflicting Records'];
const rows = result.preview.errors.map(e => [
e.row || '',
e.player_name || '',
e.team || '',
e.cleaned_team || '',
e.error || '',
e.close_matches ? e.close_matches.map(m => `${m.name} (${m.team})`).join('; ') :
(e.matching_records ? e.matching_records.map(m => `${m.name} (${m.team})`).join('; ') : '')
]);
const csv = [headers.join(','), ...rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `player_stats_import_errors_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-black text-white mb-1">Import Player Statistics from CSV</h2>
<p className="text-gray-400 text-sm">
Upload a CSV of per-game player stats. The system matches rows to existing Player Portfolios by name + team,
applies boxscores, and <strong className="text-emerald-400">creates new portfolios</strong> for unmatched players.{' '}
<span className="text-yellow-400">Published/Premium portfolios are skipped by default for safety.</span>
</p>
</div>
{/* Upload / URL section */}
<div className="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div className="flex gap-2 mb-3">
<button onClick={() => fileInputRef.current?.click()}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 border-dashed transition-all text-sm font-medium
${file ? 'border-green-500/40 bg-green-500/5 text-green-400' : 'border-slate-700 text-gray-400 hover:border-orange-500/40 hover:text-orange-400'}`}>
{file ? <><CheckCircle2 className="w-4 h-4" /> {file.name}</> : <><Upload className="w-4 h-4" /> Upload CSV File</>}
</button>
<button onClick={handleUrlMode}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 border-dashed transition-all text-sm font-medium
${fileUrl ? 'border-green-500/40 bg-green-500/5 text-green-400' : 'border-slate-700 text-gray-400 hover:border-orange-500/40 hover:text-orange-400'}`}>
<Link2 className="w-4 h-4" /> Paste CSV URL
</button>
</div>
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={handleFileSelect} className="hidden" />
{fileUrl !== '' || (!file && csvText === '') ? (
<Input
placeholder="https://...csv"
value={fileUrl}
onChange={e => setFileUrl(e.target.value)}
className="bg-slate-800 border-slate-700 text-white text-sm"
/>
) : null}
{/* Options */}
<div className="flex flex-wrap gap-4 pt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={dryRun} onChange={e => setDryRun(e.target.checked)}
className="w-4 h-4 accent-orange-500" />
<span className="text-sm text-gray-300">Dry Run <span className="text-gray-500">(preview only)</span></span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={updateExisting} onChange={e => setUpdateExisting(e.target.checked)}
className="w-4 h-4 accent-orange-500" />
<span className="text-sm text-gray-300">Update Existing Portfolios <span className="text-gray-500">(apply stats to matched players)</span></span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={createMissing} onChange={e => setCreateMissing(e.target.checked)}
className="w-4 h-4 accent-orange-500" />
<span className="text-sm text-gray-300">Create New Portfolios <span className="text-gray-500">(build for unmatched players)</span></span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={skipPublished} onChange={e => setSkipPublished(e.target.checked)}
className="w-4 h-4 accent-orange-500" />
<span className="text-sm text-gray-300">Skip Published/Premium <span className="text-gray-500">(safety)</span></span>
</label>
</div>
{/* Run button */}
<Button onClick={handleRun} disabled={loading || (!file && !fileUrl && !csvText)}
className="w-full bg-orange-500 hover:bg-orange-600 text-black font-bold"
size="lg">
{loading ? (
<><Loader2 className="w-4 h-4 animate-spin mr-2" /> Processing{dryRun ? ' Dry Run' : ''}... (this may take 30-60s for large files)</>
) : (
<><Play className="w-4 h-4 mr-2" /> {dryRun ? 'Run Dry Run (Preview)' : 'Apply Updates to Portfolios'}</>
)}
</Button>
{error && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
</div>
{/* Results */}
{result && (
<div className="space-y-4">
{/* Summary banner */}
<div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div className="flex items-center gap-2 mb-4">
{result.dry_run ? (
<span className="px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">DRY RUN</span>
) : (
<span className="px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest bg-green-500/20 text-green-400 border border-green-500/30">APPLIED</span>
)}
<span className="text-gray-500 text-xs">{result.import_log_id ? `Log ID: ${result.import_log_id}` : ''}</span>
</div>
<p className="text-gray-300 text-sm mb-4">{result.summary}</p>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-3">
{[
{ label: 'Total Rows', value: result.total_rows, icon: FileSpreadsheet, color: 'text-white' },
{ label: 'Unique Players', value: result.unique_players, icon: Users, color: 'text-blue-400' },
{ label: 'Matched', value: result.matched, icon: CheckCircle2, color: 'text-green-400' },
{ label: result.dry_run ? 'Would Update' : 'Updated', value: result.dry_run ? result.matched : result.updated, icon: Play, color: 'text-orange-400' },
{ label: result.dry_run ? 'Would Create' : 'Created', value: result.created || 0, icon: Sparkles, color: 'text-emerald-400' },
{ label: 'Unmatched', value: result.unmatched, icon: XCircle, color: 'text-red-400' },
{ label: 'Ambiguous', value: result.ambiguous, icon: AlertTriangle, color: 'text-yellow-400' },
{ label: 'Skipped', value: result.skipped, icon: AlertCircle, color: 'text-gray-400' },
].map(s => (
<div key={s.label} className="bg-slate-800/50 rounded-lg p-3 text-center">
<s.icon className={`w-4 h-4 mx-auto mb-1 ${s.color}`} />
<div className={`text-xl font-black ${s.color}`}>{s.value}</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide mt-0.5">{s.label}</div>
</div>
))}
</div>
</div>
{/* Tab selector */}
<div className="flex gap-2">
{[
{ id: 'matched', label: `Matched (${result.matched})`, icon: CheckCircle2, color: 'text-green-400' },
{ id: 'created', label: `Created (${result.created || 0})`, icon: Sparkles, color: 'text-emerald-400' },
{ id: 'errors', label: `Errors (${result.errors})`, icon: XCircle, color: 'text-red-400' },
{ id: 'skipped', label: `Skipped (${result.skipped})`, icon: AlertCircle, color: 'text-gray-400' },
].map(t => (
<button key={t.id} onClick={() => setActiveView(t.id)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all
${activeView === t.id ? 'bg-slate-800 text-white' : 'text-gray-500 hover:text-gray-300'}`}>
<t.icon className={`w-3.5 h-3.5 ${t.color}`} />
{t.label}
</button>
))}
{result.preview?.errors?.length > 0 && (
<button onClick={downloadErrorLog}
className="ml-auto flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-slate-800 text-orange-400 hover:bg-slate-700 transition-all">
<Download className="w-3.5 h-3.5" /> Download Error Log
</button>
)}
</div>
{/* Tab content */}
<div className="bg-slate-900 rounded-xl border border-slate-800 p-4 max-h-[500px] overflow-y-auto">
{activeView === 'matched' && (
<div className="space-y-2">
{result.preview?.matched?.length > 0 ? result.preview.matched.map((m, i) => (
<div key={i} className="flex items-center gap-3 py-2 px-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-all">
<CheckCircle2 className="w-4 h-4 text-green-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-white text-sm font-medium">{m.player_name}</span>
<span className="text-gray-500 text-xs ml-2">CSV: "{m.csv_name}" · Team: {m.team}</span>
</div>
<span className="text-orange-400 text-xs font-bold shrink-0">{m.games} games</span>
</div>
)) : <p className="text-gray-500 text-sm text-center py-8">No matched players.</p>}
{result.matched > 30 && <p className="text-gray-600 text-xs text-center pt-2">Showing first 30 of {result.matched} matches. Full list saved in ImportLog.</p>}
</div>
)}
{activeView === 'created' && (
<div className="space-y-2">
{result.preview?.created?.length > 0 ? result.preview.created.map((c, i) => (
<div key={i} className="flex items-center gap-3 py-2 px-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-all">
<Sparkles className="w-4 h-4 text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-white text-sm font-medium">{c.player_name}</span>
<span className="text-gray-500 text-xs ml-2">Team: {c.team}{c.jersey ? ` · #${c.jersey}` : ''}</span>
</div>
<span className="text-emerald-400 text-xs font-bold shrink-0">{c.games} games</span>
</div>
)) : <p className="text-gray-500 text-sm text-center py-8">No new portfolios to create.</p>}
{result.created > 30 && <p className="text-gray-600 text-xs text-center pt-2">Showing first 30 of {result.created} created. Full list saved in ImportLog.</p>}
</div>
)}
{activeView === 'errors' && (
<div className="space-y-2">
{result.preview?.errors?.length > 0 ? result.preview.errors.map((e, i) => (
<div key={i} className="py-2 px-3 rounded-lg bg-slate-800/50">
<div className="flex items-start gap-2">
{e.error?.includes('Ambiguous') ? <AlertTriangle className="w-4 h-4 text-yellow-400 shrink-0 mt-0.5" /> :
e.error?.includes('Missing') ? <AlertCircle className="w-4 h-4 text-orange-400 shrink-0 mt-0.5" /> :
<XCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-white text-sm font-medium">{e.player_name || 'Unknown'}</span>
{e.row && <span className="text-gray-600 text-xs">Row {e.row}</span>}
<span className="text-gray-500 text-xs">Team: {e.cleaned_team || e.team || '—'}</span>
{e.original_team && e.original_team !== e.cleaned_team && (
<span className="text-gray-600 text-xs">(orig: "{e.original_team}")</span>
)}
</div>
<p className="text-gray-400 text-xs mt-0.5">{e.error}</p>
{e.close_matches?.length > 0 && (
<p className="text-blue-400 text-xs mt-1">
Close matches: {e.close_matches.map(m => `${m.name} (${m.team})`).join(', ')}
</p>
)}
{e.matching_records?.length > 0 && (
<p className="text-yellow-400 text-xs mt-1">
Conflicting: {e.matching_records.map(m => `${m.name} (${m.team})`).join(', ')}
</p>
)}
</div>
</div>
</div>
)) : <p className="text-gray-500 text-sm text-center py-8">No errors! 🎉</p>}
{result.errors > 30 && <p className="text-gray-600 text-xs text-center pt-2">Showing first 30 of {result.errors} errors. Full list saved in ImportLog.</p>}
</div>
)}
{activeView === 'skipped' && (
<div className="space-y-2">
{result.preview?.skipped?.length > 0 ? result.preview.skipped.map((s, i) => (
<div key={i} className="flex items-center gap-3 py-2 px-3 rounded-lg bg-slate-800/50">
<AlertCircle className="w-4 h-4 text-gray-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-white text-sm font-medium">{s.player_name}</span>
<span className="text-gray-500 text-xs ml-2">{s.reason}</span>
</div>
</div>
)) : <p className="text-gray-500 text-sm text-center py-8">No skipped portfolios.</p>}
</div>
)}
</div>
{/* Apply button (if dry run had matches) */}
{result.dry_run && (result.matched > 0 || result.created > 0) && (
<div className="bg-green-500/10 border border-green-500/20 rounded-xl p-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-400 shrink-0" />
<p className="text-green-400 text-sm">
{result.matched > 0 && result.created > 0 ? (
<><strong>{result.matched}</strong> to update and <strong>{result.created}</strong> new portfolios to create</>
) : result.matched > 0 ? (
<><strong>{result.matched}</strong> matched for update</>
) : (
<><strong>{result.created}</strong> new portfolios to create</>
)} from <strong>{result.total_rows}</strong> rows of stats. Review above, then apply when ready.
</p>
</div>
<Button onClick={() => { setDryRun(false); setTimeout(handleRun, 100); }}
className="bg-green-600 hover:bg-green-700 text-white font-bold shrink-0"
disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4 mr-1" />}
Apply
</Button>
</div>
)}
</div>
)}
</div>
);
}src/components/admin/AdminFeatureAccess.jsx import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Shield, BarChart3, Video, Radio, Users, BookOpen, UserCheck } from 'lucide-react';
const FEATURES = [
{ id: 'stat_game', label: 'Stat Game (Tagging)', desc: 'Tag game events with video timestamps', icon: BarChart3, color: 'text-green-400' },
{ id: 'film_sessions', label: 'Film Sessions', desc: 'Create and view film sessions', icon: Video, color: 'text-cyan-400' },
{ id: 'live_streaming', label: 'Live Streaming', desc: 'Create and manage live streams', icon: Radio, color: 'text-red-400' },
{ id: 'stats_analytics', label: 'Stats & Analytics', desc: 'View player/game stats', icon: BarChart3, color: 'text-purple-400' },
{ id: 'team_management', label: 'Team Management', desc: 'Create and manage teams & rosters', icon: Users, color: 'text-blue-400' },
{ id: 'player_portfolios', label: 'Player Portfolios', desc: 'View and manage athlete recruiting profiles', icon: UserCheck, color: 'text-orange-400' },
{ id: 'how_to_guide', label: 'How-To Guide', desc: 'Access platform guides', icon: BookOpen, color: 'text-yellow-400' },
];
function Toggle({ checked, onChange }) {
return (
<button
onClick={onChange}
className={`relative w-10 h-5 rounded-full transition-colors ${checked ? 'bg-green-500' : 'bg-slate-600'}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${checked ? 'translate-x-5' : ''}`} />
</button>
);
}
export default function AdminFeatureAccess() {
const queryClient = useQueryClient();
const { data: permissions = [] } = useQuery({
queryKey: ['admin-permissions'],
queryFn: () => base44.entities.AdminPermission.list(),
});
const getPermission = (featureId) =>
permissions.find(p => p.feature === featureId) || {
feature: featureId, players_visible: true, parents_visible: true,
coaches_visible: true, scorers_visible: true
};
const toggleMutation = useMutation({
mutationFn: async ({ featureId, field, value }) => {
const existing = permissions.find(p => p.feature === featureId);
if (existing) {
await base44.entities.AdminPermission.update(existing.id, { [field]: value });
} else {
await base44.entities.AdminPermission.create({
feature: featureId, players_visible: true, parents_visible: true,
coaches_visible: true, scorers_visible: true, [field]: value
});
}
},
onSuccess: () => queryClient.invalidateQueries(['admin-permissions']),
});
const [activeTab, setActiveTab] = useState('feature_access');
return (
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
<Shield className="w-5 h-5 text-orange-400" />
</div>
<div>
<h1 className="text-2xl font-black text-white">Admin Portal</h1>
<p className="text-slate-400 text-sm">Manage feature access and user roles</p>
</div>
</div>
<div className="flex gap-3 mb-6">
<button
onClick={() => setActiveTab('feature_access')}
className={`px-4 py-2 rounded-lg text-sm font-semibold flex items-center gap-2 transition-all ${activeTab === 'feature_access' ? 'bg-orange-500 text-white' : 'bg-slate-800 text-slate-400 hover:text-white'}`}
>
<Shield className="w-4 h-4" /> Feature Access
</button>
<button
onClick={() => setActiveTab('users_roles')}
className={`px-4 py-2 rounded-lg text-sm font-semibold flex items-center gap-2 transition-all ${activeTab === 'users_roles' ? 'bg-orange-500 text-white' : 'bg-slate-800 text-slate-400 hover:text-white'}`}
>
<Users className="w-4 h-4" /> Users & Roles
</button>
</div>
{activeTab === 'feature_access' && (
<div>
<p className="text-slate-400 text-sm mb-4">
Control which features are visible to Players and Parents. Coaches, Scorers, and Filmers always have full access.
</p>
<div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
{/* Header row */}
<div className="grid grid-cols-[1fr_100px_100px] gap-4 px-5 py-3 border-b border-slate-800 bg-slate-800/50">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Feature</span>
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider text-center">Players</span>
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider text-center">Parents</span>
</div>
{FEATURES.map((feature, idx) => {
const Icon = feature.icon;
const perm = getPermission(feature.id);
return (
<div key={feature.id}
className={`grid grid-cols-[1fr_100px_100px] gap-4 px-5 py-4 items-center ${idx < FEATURES.length - 1 ? 'border-b border-slate-800' : ''}`}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-slate-800 flex items-center justify-center">
<Icon className={`w-4 h-4 ${feature.color}`} />
</div>
<div>
<p className="text-white text-sm font-semibold">{feature.label}</p>
<p className="text-slate-500 text-xs">{feature.desc}</p>
</div>
</div>
<div className="flex flex-col items-center gap-1">
<Toggle
checked={perm.players_visible}
onChange={() => toggleMutation.mutate({ featureId: feature.id, field: 'players_visible', value: !perm.players_visible })}
/>
<span className={`text-[10px] font-semibold ${perm.players_visible ? 'text-green-400' : 'text-slate-500'}`}>
{perm.players_visible ? 'Visible' : 'Hidden'}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<Toggle
checked={perm.parents_visible}
onChange={() => toggleMutation.mutate({ featureId: feature.id, field: 'parents_visible', value: !perm.parents_visible })}
/>
<span className={`text-[10px] font-semibold ${perm.parents_visible ? 'text-green-400' : 'text-slate-500'}`}>
{perm.parents_visible ? 'Visible' : 'Hidden'}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
{activeTab === 'users_roles' && (
<div className="bg-slate-900 rounded-xl border border-slate-800 p-8 text-center">
<Users className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<p className="text-slate-400">User role management is handled through the Base44 admin panel.</p>
<p className="text-slate-500 text-sm mt-2">Roles: admin, coach, scorer, player, parent</p>
</div>
)}
</div>
);
}src/components/admin/AdminFilm.jsx import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery } from '@tanstack/react-query';
import { Video, ExternalLink, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
export default function AdminFilm() {
const navigate = useNavigate();
const { data: games = [] } = useQuery({ queryKey: ['hoop-games'], queryFn: () => base44.entities.HoopGame.list('-game_date', 50) });
const { data: teams = [] } = useQuery({ queryKey: ['hoop-teams'], queryFn: () => base44.entities.HoopTeam.list() });
const gamesWithFilm = games.filter(g => g.video_url);
const getTeam = (id) => teams.find(t => t.id === id);
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-black text-white">Film Sessions</h1>
<p className="text-slate-400 text-sm">Create and organize video clips for review</p>
</div>
<Button onClick={() => navigate('/hoop-tagging')} className="bg-cyan-500 hover:bg-cyan-600 gap-2">
<Play className="w-4 h-4" /> Open Tagger
</Button>
</div>
{gamesWithFilm.length === 0 ? (
<div className="bg-slate-900/50 rounded-xl border border-dashed border-slate-700 p-16 text-center">
<Video className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<p className="text-slate-400 mb-2">No clips created yet</p>
<p className="text-slate-500 text-sm mb-6">Upload game film in the Stat Game tab to start tagging</p>
<Button onClick={() => navigate('/hoop-tagging')} className="bg-cyan-500 hover:bg-cyan-600">
Create Your First Clip
</Button>
</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{gamesWithFilm.map(game => {
const team = getTeam(game.team_id);
return (
<div key={game.id} className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden hover:border-slate-600 transition-all group">
<div className="aspect-video bg-slate-800 relative">
<video src={game.video_url} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => navigate(`/hoop-tagging?id=${game.id}`)}
className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center backdrop-blur-sm hover:bg-white/30">
<Play className="w-5 h-5 text-white" />
</button>
</div>
</div>
<div className="p-4">
<p className="text-white font-bold text-sm">{team?.name} vs {game.opponent}</p>
<p className="text-slate-400 text-xs mt-1">
{game.game_date ? format(new Date(game.game_date), 'MMM d, yyyy') : '—'}
</p>
<div className="flex gap-2 mt-3">
<Button size="sm" onClick={() => navigate(`/hoop-tagging?id=${game.id}`)}
className="flex-1 bg-cyan-500 hover:bg-cyan-600 text-xs">
Open in Tagger
</Button>
<button onClick={() => window.open(game.video_url, '_blank')}
className="p-2 rounded-lg bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700 transition-all">
<ExternalLink className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}src/components/admin/AdminGeorgiaFilmPush.jsx import { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { Film, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
const normalize = (name) =>
name?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
export default function AdminGeorgiaFilmPush() {
const [teams, setTeams] = useState([]);
const [loading, setLoading] = useState(true);
const [pushing, setPushing] = useState(null); // team_slug currently being pushed
const [results, setResults] = useState({}); // team_slug -> result
useEffect(() => {
const load = async () => {
const games = await base44.entities.GeorgiaGame.list();
// Collect unique team names from all games
const teamNames = new Set();
games.forEach((g) => {
if (g.team1) teamNames.add(g.team1);
if (g.team2) teamNames.add(g.team2);
});
// Build { name, slug, game_count, film_count }
const teamList = Array.from(teamNames)
.map((name) => {
const slug = normalize(name);
const teamGames = games.filter(
(g) => normalize(g.team1) === slug || normalize(g.team2) === slug
);
return {
name,
slug,
game_count: teamGames.length,
film_count: teamGames.filter((g) => g.embed_link?.trim()).length,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
setTeams(teamList);
setLoading(false);
};
load();
}, []);
const handlePush = async (team) => {
setPushing(team.slug);
try {
const res = await base44.functions.invoke('pushGameFilmToPlayers', { team_slug: team.slug });
setResults((prev) => ({ ...prev, [team.slug]: res.data }));
} catch (e) {
setResults((prev) => ({ ...prev, [team.slug]: { error: e.message } }));
}
setPushing(null);
};
if (loading) {
return (
<div className="flex items-center gap-2 text-slate-400 py-8">
<Loader2 className="w-4 h-4 animate-spin" /> Loading teams…
</div>
);
}
return (
<div className="space-y-4">
<div>
<h2 className="text-white font-black text-lg">Push Game Film to Player Portfolios</h2>
<p className="text-slate-400 text-sm mt-1">
For each team, this creates a boxscore entry on every matching Player portfolio with the
full game film link in the <strong>Film</strong> field. Already-pushed games are skipped
(idempotent).
</p>
</div>
<div className="space-y-2">
{teams.map((team) => {
const result = results[team.slug];
const isPushing = pushing === team.slug;
return (
<div
key={team.slug}
className="flex items-center gap-4 bg-slate-900 border border-slate-800 rounded-xl px-4 py-3"
>
{/* Team info */}
<div className="flex-1 min-w-0">
<p className="text-white font-semibold text-sm truncate">{team.name}</p>
<p className="text-slate-500 text-xs">
{team.game_count} game{team.game_count !== 1 ? 's' : ''} ·{' '}
{team.film_count} with film
</p>
</div>
{/* Result */}
{result && !result.error && (
<div className="flex items-center gap-1.5 text-xs text-emerald-400 shrink-0">
<CheckCircle2 className="w-3.5 h-3.5" />
{result.entries_pushed} pushed · {result.players_updated} players
</div>
)}
{result?.error && (
<div className="flex items-center gap-1.5 text-xs text-red-400 shrink-0">
<AlertCircle className="w-3.5 h-3.5" />
{result.error}
</div>
)}
{/* Push button */}
<button
onClick={() => handlePush(team)}
disabled={isPushing || team.film_count === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
style={{ background: 'rgba(255,106,0,0.15)', color: '#FF6A00', border: '1px solid rgba(255,106,0,0.3)' }}
>
{isPushing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Film className="w-3.5 h-3.5" />
)}
{isPushing ? 'Pushing…' : 'Push Film'}
</button>
</div>
);
})}
</div>
</div>
);
}src/components/admin/AdminGeorgiaGames.jsx import { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { Edit2, Trash2, Save, X, Loader2, Plus } from 'lucide-react';
const ORANGE = '#FF6A00';
export default function AdminGeorgiaGames() {
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState(null);
const [editData, setEditData] = useState({});
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(null);
const [creatingNew, setCreatingNew] = useState(false);
const [newGameData, setNewGameData] = useState({
team1: '',
team2: '',
date: '',
court: '',
game_number: '',
video_id: '',
embed_link: '',
team_stats: {},
});
useEffect(() => {
base44.entities.GeorgiaGame.list().then(data => {
setGames(data || []);
setLoading(false);
});
}, []);
const startEdit = (game) => {
setEditingId(game.id);
setEditData({
date: game.date || '',
court: game.court || '',
game_number: game.game_number || '',
video_id: game.video_id || '',
embed_link: game.embed_link || '',
team_stats: game.team_stats || {},
});
};
const cancelEdit = () => {
setEditingId(null);
setEditData({});
};
const saveGame = async (gameId) => {
setSaving(true);
try {
await base44.entities.GeorgiaGame.update(gameId, editData);
setGames(prev => prev.map(g => g.id === gameId ? { ...g, ...editData } : g));
setEditingId(null);
setEditData({});
} catch (e) {
alert('Error saving: ' + e.message);
}
setSaving(false);
};
const deleteGame = async (gameId) => {
setDeleting(gameId);
try {
await base44.entities.GeorgiaGame.delete(gameId);
setGames(prev => prev.filter(g => g.id !== gameId));
} catch (e) {
alert('Error deleting: ' + e.message);
}
setDeleting(null);
};
const createNewGame = async () => {
if (!newGameData.team1 || !newGameData.team2 || !newGameData.embed_link) {
alert('Team1, Team2, and Embed Link are required');
return;
}
setSaving(true);
try {
const created = await base44.entities.GeorgiaGame.create({
team1: newGameData.team1,
team2: newGameData.team2,
date: newGameData.date || null,
court: newGameData.court || null,
game_number: newGameData.game_number || null,
video_id: newGameData.video_id || null,
embed_link: newGameData.embed_link,
team_stats: newGameData.team_stats,
});
setGames(prev => [...prev, created]);
setCreatingNew(false);
setNewGameData({
team1: '',
team2: '',
date: '',
court: '',
game_number: '',
video_id: '',
embed_link: '',
team_stats: {},
});
} catch (e) {
alert('Error creating game: ' + e.message);
}
setSaving(false);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-black text-white">Georgia Games</h1>
<p className="text-slate-400 text-sm mt-1">Manage game films, dates, courts, and embed links</p>
</div>
<button
onClick={() => setCreatingNew(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold text-white transition-all hover:opacity-90"
style={{ background: ORANGE }}
>
<Plus className="w-4 h-4" />
New Game
</button>
</div>
{/* Create New Game Modal */}
{creatingNew && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-slate-900 border border-slate-800 rounded-xl p-6 max-w-sm mx-4 max-h-[90vh] overflow-y-auto">
<h2 className="text-white font-black text-lg mb-4">Create New Game</h2>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-400 font-semibold">Team 1 *</label>
<input
type="text"
value={newGameData.team1}
onChange={e => setNewGameData(prev => ({ ...prev, team1: e.target.value }))}
placeholder="e.g., Alpharetta"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Team 2 *</label>
<input
type="text"
value={newGameData.team2}
onChange={e => setNewGameData(prev => ({ ...prev, team2: e.target.value }))}
placeholder="e.g., Chamblee"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Date</label>
<input
type="date"
value={newGameData.date}
onChange={e => setNewGameData(prev => ({ ...prev, date: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Court</label>
<input
type="text"
value={newGameData.court}
onChange={e => setNewGameData(prev => ({ ...prev, court: e.target.value }))}
placeholder="e.g., UGA Coliseum"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Game #</label>
<input
type="text"
value={newGameData.game_number}
onChange={e => setNewGameData(prev => ({ ...prev, game_number: e.target.value }))}
placeholder="e.g., G001"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Video ID</label>
<input
type="text"
value={newGameData.video_id}
onChange={e => setNewGameData(prev => ({ ...prev, video_id: e.target.value }))}
placeholder="YouTube/Vimeo ID"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Embed Link *</label>
<input
type="text"
value={newGameData.embed_link}
onChange={e => setNewGameData(prev => ({ ...prev, embed_link: e.target.value }))}
placeholder="Full embed URL"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-slate-400 font-semibold">STL (Team 1)</label>
<input
type="number"
value={newGameData.team_stats?.team1_stl || ''}
onChange={e => setNewGameData(prev => ({ ...prev, team_stats: { ...prev.team_stats, team1_stl: e.target.value } }))}
placeholder="0"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">STL (Team 2)</label>
<input
type="number"
value={newGameData.team_stats?.team2_stl || ''}
onChange={e => setNewGameData(prev => ({ ...prev, team_stats: { ...prev.team_stats, team2_stl: e.target.value } }))}
placeholder="0"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-slate-400 font-semibold">BLK (Team 1)</label>
<input
type="number"
value={newGameData.team_stats?.team1_blk || ''}
onChange={e => setNewGameData(prev => ({ ...prev, team_stats: { ...prev.team_stats, team1_blk: e.target.value } }))}
placeholder="0"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">BLK (Team 2)</label>
<input
type="number"
value={newGameData.team_stats?.team2_blk || ''}
onChange={e => setNewGameData(prev => ({ ...prev, team_stats: { ...prev.team_stats, team2_blk: e.target.value } }))}
placeholder="0"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
</div>
</div>
<div className="flex gap-2 mt-6">
<button
onClick={createNewGame}
disabled={saving}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg text-xs font-bold text-white disabled:opacity-50"
style={{ background: ORANGE }}
>
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
Create
</button>
<button
onClick={() => setCreatingNew(false)}
disabled={saving}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg text-xs font-bold text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 disabled:opacity-50"
>
<X className="w-3 h-3" />
Cancel
</button>
</div>
</div>
</div>
)}
{loading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: ORANGE }} />
</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{games.map(game => (
<div
key={game.id}
className="rounded-xl border p-4 transition-all"
style={{
background: editingId === game.id ? 'rgba(255,106,0,0.05)' : '#0a0a0a',
borderColor: editingId === game.id ? 'rgba(255,106,0,0.3)' : 'rgba(255,255,255,0.07)',
}}
>
{editingId === game.id ? (
// Edit mode
<div className="space-y-3">
<div>
<label className="text-xs text-slate-400 font-semibold">Date</label>
<input
type="date"
value={editData.date}
onChange={e => setEditData(prev => ({ ...prev, date: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Court</label>
<input
type="text"
value={editData.court}
onChange={e => setEditData(prev => ({ ...prev, court: e.target.value }))}
placeholder="e.g., UGA Coliseum"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Game #</label>
<input
type="text"
value={editData.game_number}
onChange={e => setEditData(prev => ({ ...prev, game_number: e.target.value }))}
placeholder="e.g., G001"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Video ID</label>
<input
type="text"
value={editData.video_id}
onChange={e => setEditData(prev => ({ ...prev, video_id: e.target.value }))}
placeholder="YouTube/Vimeo ID"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Embed Link</label>
<input
type="text"
value={editData.embed_link}
onChange={e => setEditData(prev => ({ ...prev, embed_link: e.target.value }))}
placeholder="Full embed URL"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-slate-400 font-semibold">STL (Team 1)</label>
<input
type="number"
value={editData.team_stats?.team1_stl || ''}
onChange={e => setEditData(prev => ({ ...prev, team_stats: { ...prev.team_stats, team1_stl: e.target.value } }))}
placeholder="0"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">STL (Team 2)</label>
<input
type="number"
value={editData.team_stats?.team2_stl || ''}
onChange={e => setEditData(prev => ({ ...prev, team_stats: { ...prev.team_stats, team2_stl: e.target.value } }))}
placeholder="0"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-slate-400 font-semibold">BLK (Team 1)</label>
<input
type="number"
value={editData.team_stats?.team1_blk || ''}
onChange={e => setEditData(prev => ({ ...prev, team_stats: { ...prev.team_stats, team1_blk: e.target.value } }))}
placeholder="0"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">BLK (Team 2)</label>
<input
type="number"
value={editData.team_stats?.team2_blk || ''}
onChange={e => setEditData(prev => ({ ...prev, team_stats: { ...prev.team_stats, team2_blk: e.target.value } }))}
placeholder="0"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500"
/>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => saveGame(game.id)}
disabled={saving}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg text-xs font-bold text-white disabled:opacity-50"
style={{ background: ORANGE }}
>
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
Save
</button>
<button
onClick={cancelEdit}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg text-xs font-bold text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700"
>
<X className="w-3 h-3" />
Cancel
</button>
</div>
</div>
) : (
// View mode
<div>
<p className="text-white font-bold text-sm mb-2">
{game.team1} vs {game.team2}
</p>
<div className="space-y-1 text-xs text-slate-400 mb-3">
{game.date && <p>📅 {game.date}</p>}
{game.court && <p>🏀 {game.court}</p>}
{game.game_number && <p>#{game.game_number}</p>}
</div>
<div className="flex gap-2">
<button
onClick={() => startEdit(game)}
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-bold text-white transition-all hover:opacity-90"
style={{ background: ORANGE }}
>
<Edit2 className="w-3 h-3" />
Edit
</button>
<button
onClick={() => deleteGame(game.id)}
disabled={deleting === game.id}
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-bold text-red-400 hover:text-red-300 bg-red-950/20 hover:bg-red-950/30 disabled:opacity-50 transition-all"
>
{deleting === game.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Trash2 className="w-3 h-3" />}
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
{!loading && games.length === 0 && (
<div className="text-center py-12">
<p className="text-slate-500">No games found</p>
</div>
)}
</div>
);
}src/components/admin/AdminGeorgiaLocks.jsx import { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { Lock, Unlock, Search } from 'lucide-react';
import { Link } from 'react-router-dom';
function teamSlug(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
const ALL_TEAMS = [
'Alpharetta', 'Apalachee', 'Baldwin', 'Beach', 'Berkmar', 'BEST Academy', 'Bowdon',
'Bradwell Institute', 'Brookwood', 'Brunswick', 'Buford', 'Burke County', 'Butler',
'Campbell', 'Carrollton', 'Cartersville', 'Cedar Grove', 'Cedar Shoals', 'Centennial',
'Chamblee', 'Chapel Hill', 'Cherokee', 'Christian Heritage', 'Columbia', 'Cross Creek',
'Dacula', 'Darlington School', 'Douglas County', 'East Coweta', 'East Forsyth',
'East Paulding', 'Eastside', 'ELCA', 'Franklin County', 'Gainesville', 'Alexander',
'Grayson', 'Greater Atlanta Christian', 'Habersham Central', 'Harlem', 'Hebron Christian',
'Hillgrove', 'Holy Innocents', 'Jonesboro', 'Kell', 'Lanier', 'Lassiter', 'Lee County',
'Lovett School', 'Madison County', 'Mill Creek', 'Milton', 'Mitchell County',
'Mountain View', 'New Manchester', 'Newton', 'North Atlanta', 'North Gwinnett',
'North Oconee', 'North Springs', 'Osborne', 'Parkview', 'Pebblebrook', 'Peachtree Ridge',
'Salem', 'Sandy Creek', 'Spalding', 'Sprayberry', 'St. Pius X', 'Starrs Mill',
'Sumter County', 'Thomson', 'Tucker', 'Vidalia', 'Walton', 'Ware County', 'West Forsyth',
'Westlake', 'Westminster', 'Wheeler', 'Whitefield Academy', 'Whitfield', 'Woodstock',
'Woodward Academy'
];
export default function AdminGeorgiaLocks() {
const [lockedTeams, setLockedTeams] = useState([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
try {
const perms = await base44.asServiceRole.entities.AdminPermission.filter({ feature: 'georgia_locks' });
if (perms?.length) {
setLockedTeams(perms[0].locked_teams || ALL_TEAMS.map(t => teamSlug(t)));
} else {
// Default: all teams locked
setLockedTeams(ALL_TEAMS.map(t => teamSlug(t)));
await base44.asServiceRole.entities.AdminPermission.create({
feature: 'georgia_locks',
locked_teams: ALL_TEAMS.map(t => teamSlug(t))
});
}
} catch (e) {
console.error(e);
setLockedTeams(ALL_TEAMS.map(t => teamSlug(t)));
}
setLoading(false);
};
load();
}, []);
const toggleLock = async (slug) => {
const isLocked = lockedTeams.includes(slug);
const updated = isLocked
? lockedTeams.filter(t => t !== slug)
: [...lockedTeams, slug];
setLockedTeams(updated);
try {
const existing = await base44.asServiceRole.entities.AdminPermission.filter({ feature: 'georgia_locks' });
if (existing?.length) {
await base44.asServiceRole.entities.AdminPermission.update(existing[0].id, { locked_teams: updated });
} else {
await base44.asServiceRole.entities.AdminPermission.create({ feature: 'georgia_locks', locked_teams: updated });
}
} catch (e) {
console.error('Toggle failed:', e);
setLockedTeams(lockedTeams);
}
};
const filtered = ALL_TEAMS
.filter(t => t.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
if (loading) return <div className="text-slate-400">Loading...</div>;
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-black text-white">Georgia Team Access</h1>
<p className="text-slate-400 text-sm mt-1">{ALL_TEAMS.length} teams · {lockedTeams.length} locked</p>
</div>
</div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-600" />
<input
type="text"
placeholder="Search teams..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-3 rounded-xl text-sm text-white bg-white/5 border border-white/10 focus:outline-none focus:border-orange-500/50 placeholder-slate-600"
/>
</div>
{/* Team cards grid */}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map(team => {
const slug = teamSlug(team);
const isLocked = lockedTeams.includes(slug);
return (
<div key={slug} className="flex flex-col rounded-xl border overflow-hidden" style={{ background: '#0a0a0a', borderColor: 'rgba(255,255,255,0.07)' }}>
{/* Top row: team info */}
<div className="flex items-center gap-4 px-4 py-4">
<div className="w-10 h-10 rounded-full flex items-center justify-center font-barlow font-black text-lg shrink-0" style={{ background: 'rgba(255,106,0,0.1)', color: '#FF6A00' }}>
{team[0]}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-white text-sm truncate">{team}</p>
<p className="text-xs text-slate-600">{isLocked ? 'Locked' : 'Public'}</p>
</div>
<button
onClick={() => toggleLock(slug)}
className="flex items-center justify-center w-9 h-9 rounded-lg transition-all shrink-0"
style={{ background: isLocked ? 'rgba(255,59,48,0.15)' : 'rgba(0,255,133,0.15)' }}>
{isLocked ? (
<Lock className="w-4 h-4" style={{ color: '#FF3B30' }} />
) : (
<Unlock className="w-4 h-4" style={{ color: '#00FF85' }} />
)}
</button>
</div>
{/* Bottom CTA */}
<div className="px-4 py-2.5 text-xs text-slate-600 text-center" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<Link to={`/georgia-team/${slug}`} className="hover:text-white transition-colors">View team page →</Link>
</div>
</div>
);
})}
</div>
{filtered.length === 0 && (
<div className="text-center py-16">
<p className="text-slate-500">No teams match "{search}"</p>
</div>
)}
</div>
);
}src/components/admin/AdminGeorgiaStats.jsx import { useState, useRef } from 'react';
import { base44 } from '@/api/base44Client';
import { CheckCircle2, AlertCircle, Loader2, Play, Upload, X, FileText, Plus } from 'lucide-react';
// Hardcoded pre-loaded PDFs
const PRELOADED_PDFS = [
{ team: 'Alpharetta', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/f2317d93a_Alpharetta-GAAlpharetta.pdf' },
{ team: 'Apalachee', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/b7a649c43_Apalachee-GAApalachee-GA.pdf' },
{ team: 'Baldwin', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/6c2bbf8b0_Baldwin-GABaldwin-GA.pdf' },
{ team: 'Beach', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/5612924c7_Beach-GABeach-GA.pdf' },
{ team: 'Berkmar', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/80d5a4f1d_Berkmar-GA.pdf' },
{ team: 'BEST Academy', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/fd2362526_BESTAcademy-GA.pdf' },
{ team: 'Bowdon', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/dbc511061_Bowdon-GA.pdf' },
{ team: 'Bradwell Institute', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/a46e277da_BradwellInstitute-GA.pdf' },
{ team: 'Brookwood', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/292f4a13c_Brookwood-GA.pdf' },
{ team: 'Brunswick', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/b2b65f488_Brunswick-GABrunswick-GA-Incomplete.pdf' },
{ team: 'Buford', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/ee4f0414c_Buford-GA.pdf' },
{ team: 'Burke County', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/5fb6087d1_BurkeCounty-GA.pdf' },
{ team: 'Butler', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/705464a32_Butler-GA.pdf' },
{ team: 'Campbell', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/52766de6e_Campbell-GA.pdf' },
{ team: 'Carrollton', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/22fa42e58_Carrollton-GA.pdf' },
{ team: 'Cartersville', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/2882396d8_Cartersville-GA.pdf' },
{ team: 'Cedar Grove', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/861e39aad_CedarGrove-GA.pdf' },
{ team: 'Cedar Shoals', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/4acfd6815_CedarShoals-GA.pdf' },
{ team: 'Centennial', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/98008562e_Centennial-GA.pdf' },
{ team: 'Chamblee', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/978b25704_Chamblee-GA.pdf' },
{ team: 'Chapel Hill', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/8000e8104_ChapelHill-GA.pdf' },
{ team: 'Cherokee', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/545888faf_Cherokee-GA.pdf' },
{ team: 'Christian Heritage', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/6ff10f03d_ChristianHeritage-GA.pdf' },
{ team: 'Columbia', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/f431182bd_Columbia-GAColumbia.pdf' },
{ team: 'Cross Creek', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/dfcbcb148_CrossCreek-GA.pdf' },
{ team: 'Dacula', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/9363ae547_Dacula-GADacula.pdf' },
{ team: 'Darlington School', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/3aab12695_DarlingtonSchool-GA.pdf' },
{ team: 'Douglas County', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/34b7a20c2_DouglasCounty-GA.pdf' },
{ team: 'East Coweta', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/afed171ce_EastCoweta-GA.pdf' },
{ team: 'East Forsyth', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/143e58965_EastForsyth-GA.pdf' },
{ team: 'East Paulding', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/5923d634a_EastPaulding-GA.pdf' },
{ team: 'Eastside', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/13811eacb_Eastside-GA.pdf' },
{ team: 'ELCA', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/4b8b426bd_ELCA-GA.pdf' },
{ team: 'Franklin County', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/f75d6ad22_FranklinCounty-GA.pdf' },
{ team: 'Gainesville', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/c939f747b_Gainesville-GA.pdf' },
{ team: 'Alexander', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/cd5b80f6a_GamebyGameStats_AlexanderGA.pdf' },
{ team: 'Grayson', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/eaf1f2bc3_Grayson-GA.pdf' },
{ team: 'Greater Atlanta Christian', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/b0e3c395c_GreaterAtlantaChristian-GA.pdf' },
{ team: 'Habersham Central', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/36c311238_HabershamCentral-GA.pdf' },
{ team: 'Harlem', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/d52c96db9_Harlem-GA.pdf' },
{ team: 'Hebron Christian', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/c572549c5_HebronChristian-GA.pdf' },
{ team: 'Hillgrove', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/cdbfdec23_Hillgrove-GA.pdf' },
{ team: 'Holy Innocents', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/42559a2f7_HolyInnocents-GA.pdf' },
{ team: 'Jonesboro', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/8601c91af_Jonesboro-GA.pdf' },
{ team: 'Kell', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/1525bba10_Kell-GAKell.pdf' },
{ team: 'Lanier', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/78a925d04_Lanier-GA.pdf' },
{ team: 'Lassiter', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/d56d59925_Lassiter-GA.pdf' },
{ team: 'Lee County', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/7d1447caf_LeeCounty-GA.pdf' },
{ team: 'Lovett School', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/2d5babf54_LovettSchool-GA.pdf' },
{ team: 'Madison County', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/3cc0c2563_MadisonCounty-GA.pdf' },
];
// Guess team name from filename: "Alpharetta-GAAlpharetta.pdf" → "Alpharetta"
function guessTeamName(filename) {
const base = filename.replace(/\.pdf$/i, '').replace(/^.*_/, '');
const cleaned = base.replace(/-GA.*$/i, '').replace(/GamebyGameStats_/i, '').replace(/[-_]/g, ' ').trim();
return cleaned;
}
export default function AdminGeorgiaStats() {
const [tab, setTab] = useState('preloaded'); // 'preloaded' | 'upload'
const [statuses, setStatuses] = useState({});
const [running, setRunning] = useState(false);
const [currentTeam, setCurrentTeam] = useState('');
const [syncRunning, setSyncRunning] = useState(false);
const [syncResult, setSyncResult] = useState(null);
// Upload tab state
const [uploadQueue, setUploadQueue] = useState([]); // [{id, file, teamName, url, uploadStatus}]
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef();
// ── Preloaded tab logic ──
const importTeam = async (pdf) => {
setStatuses(prev => ({ ...prev, [pdf.team]: { status: 'loading' } }));
const res = await base44.functions.invoke('importGeorgiaStats', { pdf_url: pdf.url, team_name: pdf.team, admin_secret: 'goaio-admin-2026' });
const data = res.data;
const updated = data?.results?.filter(r => r.status === 'updated').length || 0;
const notFound = data?.results?.filter(r => r.status === 'not_found').length || 0;
setStatuses(prev => ({
...prev,
[pdf.team]: { status: data?.error ? 'error' : 'done', updated, notFound, error: data?.error }
}));
};
const importAll = async () => {
setRunning(true);
for (const pdf of PRELOADED_PDFS) {
if (statuses[pdf.team]?.status === 'done') continue;
setCurrentTeam(pdf.team);
await importTeam(pdf);
await new Promise(r => setTimeout(r, 800));
}
setCurrentTeam('');
setRunning(false);
};
const doneCount = Object.values(statuses).filter(s => s.status === 'done').length;
const errorCount = Object.values(statuses).filter(s => s.status === 'error').length;
// ── Upload tab logic ──
const handleFiles = (files) => {
const pdfs = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.endsWith('.pdf'));
const newItems = pdfs.map(file => ({
id: Math.random().toString(36).slice(2),
file,
teamName: guessTeamName(file.name),
url: null,
uploadStatus: 'pending', // pending | uploading | ready | importing | done | error
result: null,
error: null,
}));
setUploadQueue(prev => [...prev, ...newItems]);
};
const removeFromQueue = (id) => setUploadQueue(prev => prev.filter(i => i.id !== id));
const updateItem = (id, patch) => setUploadQueue(prev => prev.map(i => i.id === id ? { ...i, ...patch } : i));
const uploadAndImportAll = async () => {
setRunning(true);
for (const item of uploadQueue) {
if (item.uploadStatus === 'done') continue;
// Step 1: upload
updateItem(item.id, { uploadStatus: 'uploading' });
let fileUrl;
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file: item.file });
fileUrl = file_url;
updateItem(item.id, { url: fileUrl, uploadStatus: 'importing' });
} catch (e) {
updateItem(item.id, { uploadStatus: 'error', error: 'Upload failed: ' + e.message });
continue;
}
// Step 2: import
try {
const res = await base44.functions.invoke('importGeorgiaStats', { pdf_url: fileUrl, team_name: item.teamName, admin_secret: 'goaio-admin-2026' });
const data = res.data;
const updated = data?.results?.filter(r => r.status === 'updated').length || 0;
const notFound = data?.results?.filter(r => r.status === 'not_found').length || 0;
updateItem(item.id, {
uploadStatus: data?.error ? 'error' : 'done',
result: { updated, notFound },
error: data?.error || null,
});
} catch (e) {
updateItem(item.id, { uploadStatus: 'error', error: 'Import failed: ' + e.message });
}
await new Promise(r => setTimeout(r, 500));
}
setRunning(false);
};
const pendingUploadCount = uploadQueue.filter(i => i.uploadStatus !== 'done').length;
const syncPlayers = async () => {
setSyncRunning(true);
setSyncResult(null);
try {
const res = await base44.functions.invoke('addPlayersToTeamRosters', {});
setSyncResult(res.data);
} catch (e) {
setSyncResult({ error: e.message });
}
setSyncRunning(false);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-black text-white">Georgia Stats Import</h1>
<p className="text-slate-400 text-sm mt-1">Import game stats from Hoopsalytics PDFs</p>
</div>
<div className="flex gap-2">
<button onClick={syncPlayers} disabled={syncRunning}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold disabled:opacity-50"
style={{ background: '#00FF85', color: '#000' }}>
{syncRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
{syncRunning ? 'Syncing Players...' : 'Sync Players to Teams'}
</button>
<button onClick={() => {
setSyncRunning(true);
base44.functions.invoke('populateTeamStats', {}).then(res => {
setSyncResult(res.data);
setSyncRunning(false);
}).catch(e => {
setSyncResult({ error: e.message });
setSyncRunning(false);
});
}} disabled={syncRunning}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold disabled:opacity-50"
style={{ background: '#FF6A00', color: '#000' }}>
{syncRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
{syncRunning ? 'Syncing Stats...' : 'Sync Stats to Teams'}
</button>
</div>
</div>
{syncResult && (
<div className={`mb-6 p-4 rounded-lg text-sm font-semibold ${syncResult.error ? 'text-red-400 bg-red-950' : 'text-green-400 bg-green-950'}`}>
{syncResult.error ? `Error: ${syncResult.error}` : `✓ ${syncResult.added} players added to rosters ${syncResult.errors?.length > 0 ? `(${syncResult.errors.length} errors)` : ''}`}
</div>
)}
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-white/10">
{[{ id: 'preloaded', label: 'Pre-loaded PDFs' }, { id: 'upload', label: 'Upload New PDFs' }].map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-2.5 text-sm font-bold border-b-2 -mb-px transition-all ${tab === t.id ? 'text-white border-orange-500' : 'text-slate-500 border-transparent hover:text-slate-300'}`}>
{t.label}
</button>
))}
</div>
{/* ── PRELOADED TAB ── */}
{tab === 'preloaded' && (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-slate-400 text-sm">{PRELOADED_PDFS.length} teams · {doneCount} imported{errorCount > 0 ? ` · ${errorCount} errors` : ''}</p>
<button onClick={importAll} disabled={running}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold disabled:opacity-50"
style={{ background: '#FF6A00', color: '#000' }}>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? `Importing ${currentTeam}...` : 'Import All'}
</button>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{PRELOADED_PDFS.map(pdf => {
const s = statuses[pdf.team];
return (
<div key={pdf.team} className="flex items-center justify-between px-4 py-3 rounded-xl border"
style={{ background: '#0a0a0a', borderColor: s?.status === 'done' ? 'rgba(0,255,133,0.2)' : s?.status === 'error' ? 'rgba(255,59,48,0.2)' : 'rgba(255,255,255,0.06)' }}>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold text-sm">{pdf.team}</p>
{s?.status === 'done' && <p className="text-xs text-green-400">{s.updated} updated{s.notFound > 0 ? `, ${s.notFound} unmatched` : ''}</p>}
{s?.status === 'error' && <p className="text-xs text-red-400">{s.error}</p>}
</div>
<div className="ml-3 shrink-0">
{s?.status === 'loading' && <Loader2 className="w-4 h-4 animate-spin text-orange-400" />}
{s?.status === 'done' && <CheckCircle2 className="w-4 h-4 text-green-400" />}
{s?.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
{!s && (
<button onClick={() => importTeam(pdf)}
className="text-xs px-2 py-1 rounded font-bold text-slate-500 hover:text-white"
style={{ background: 'rgba(255,255,255,0.05)' }}>
Import
</button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── UPLOAD TAB ── */}
{tab === 'upload' && (
<div>
{/* Drop zone */}
<div
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={e => { e.preventDefault(); setDragging(false); handleFiles(e.dataTransfer.files); }}
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all mb-6"
style={{ borderColor: dragging ? '#FF6A00' : 'rgba(255,255,255,0.12)', background: dragging ? 'rgba(255,106,0,0.05)' : 'rgba(255,255,255,0.02)' }}>
<Upload className="w-8 h-8 mx-auto mb-3 text-slate-500" />
<p className="text-white font-semibold text-sm">Drop PDF files here or click to browse</p>
<p className="text-slate-500 text-xs mt-1">Accepts multiple PDFs — team names auto-detected from filename</p>
<input ref={fileInputRef} type="file" accept=".pdf" multiple className="hidden" onChange={e => handleFiles(e.target.files)} />
</div>
{uploadQueue.length > 0 && (
<>
<div className="flex items-center justify-between mb-3">
<p className="text-slate-400 text-sm">{uploadQueue.length} files queued</p>
<div className="flex gap-2">
<button onClick={() => setUploadQueue([])} disabled={running}
className="text-xs px-3 py-1.5 rounded-lg font-bold text-slate-500 hover:text-white disabled:opacity-40"
style={{ background: 'rgba(255,255,255,0.05)' }}>
Clear All
</button>
<button onClick={uploadAndImportAll} disabled={running || pendingUploadCount === 0}
className="flex items-center gap-2 px-4 py-1.5 rounded-lg text-sm font-bold disabled:opacity-50"
style={{ background: '#FF6A00', color: '#000' }}>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? 'Importing...' : `Import All (${pendingUploadCount})`}
</button>
</div>
</div>
<div className="space-y-2">
{uploadQueue.map(item => (
<div key={item.id} className="flex items-center gap-3 px-4 py-3 rounded-xl border"
style={{ background: '#0a0a0a', borderColor: item.uploadStatus === 'done' ? 'rgba(0,255,133,0.2)' : item.uploadStatus === 'error' ? 'rgba(255,59,48,0.2)' : 'rgba(255,255,255,0.06)' }}>
<FileText className="w-4 h-4 text-slate-500 shrink-0" />
<div className="flex-1 min-w-0">
<input
value={item.teamName}
onChange={e => updateItem(item.id, { teamName: e.target.value })}
disabled={item.uploadStatus !== 'pending'}
className="bg-transparent text-white font-semibold text-sm w-full focus:outline-none border-b border-transparent focus:border-orange-500 transition-colors disabled:opacity-60"
placeholder="Team name..."
/>
<p className="text-xs text-slate-600 truncate">{item.file.name}</p>
{item.uploadStatus === 'done' && <p className="text-xs text-green-400">{item.result?.updated} games updated{item.result?.notFound > 0 ? `, ${item.result.notFound} unmatched` : ''}</p>}
{item.uploadStatus === 'error' && <p className="text-xs text-red-400">{item.error}</p>}
</div>
<div className="shrink-0 flex items-center gap-2">
{item.uploadStatus === 'uploading' && <span className="text-xs text-slate-400">Uploading...</span>}
{item.uploadStatus === 'importing' && <span className="text-xs text-orange-400">Importing...</span>}
{(item.uploadStatus === 'uploading' || item.uploadStatus === 'importing') && <Loader2 className="w-4 h-4 animate-spin text-orange-400" />}
{item.uploadStatus === 'done' && <CheckCircle2 className="w-4 h-4 text-green-400" />}
{item.uploadStatus === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
{item.uploadStatus === 'pending' && (
<button onClick={() => removeFromQueue(item.id)} className="text-slate-600 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
}src/components/admin/AdminGeorgiaTeams.jsx import { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { Edit2, Save, X, Loader2, Plus } from 'lucide-react';
const ORANGE = '#FF6A00';
export default function AdminGeorgiaTeams() {
const [teams, setTeams] = useState([]);
const [coachProfiles, setCoachProfiles] = useState([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState(null);
const [editData, setEditData] = useState({});
const [saving, setSaving] = useState(false);
const [creatingNew, setCreatingNew] = useState(false);
const [newCoachData, setNewCoachData] = useState({
coach_name: '',
coach_first_name: '',
coach_last_name: '',
email: '',
phone: '',
high_school: '',
password: '',
});
useEffect(() => {
Promise.all([
base44.entities.HoopTeam.list(),
base44.entities.CoachProfile.list(),
]).then(([t, c]) => {
setTeams(t || []);
setCoachProfiles(c || []);
setLoading(false);
});
}, []);
const startEdit = (id) => {
const coach = coachProfiles.find(c => c.id === id);
if (coach) {
setEditingId(id);
setEditData({
coach_name: coach.coach_name || '',
coach_first_name: coach.coach_first_name || coach.coach_name?.split(' ')[0] || '',
coach_last_name: coach.coach_last_name || coach.coach_name?.split(' ').slice(1).join(' ') || '',
email: coach.email || '',
phone: coach.phone || '',
high_school: coach.high_school || '',
password: coach.password || '',
});
}
};
const saveCoach = async (id) => {
setSaving(true);
try {
await base44.entities.CoachProfile.update(id, editData);
setCoachProfiles(prev =>
prev.map(c => c.id === id ? { ...c, ...editData } : c)
);
setEditingId(null);
setEditData({});
} catch (e) {
alert('Error saving: ' + e.message);
}
setSaving(false);
};
const createNewCoach = async () => {
if (!newCoachData.email || !newCoachData.high_school) {
alert('Email and High School are required');
return;
}
setSaving(true);
try {
const created = await base44.entities.CoachProfile.create({
coach_name: newCoachData.coach_name || `${newCoachData.coach_first_name} ${newCoachData.coach_last_name}`.trim(),
coach_first_name: newCoachData.coach_first_name,
coach_last_name: newCoachData.coach_last_name,
email: newCoachData.email,
phone: newCoachData.phone || '',
high_school: newCoachData.high_school,
password: newCoachData.password || '',
is_verified: true,
});
setCoachProfiles(prev => [...prev, created]);
setCreatingNew(false);
setNewCoachData({
coach_name: '',
coach_first_name: '',
coach_last_name: '',
email: '',
phone: '',
high_school: '',
password: '',
});
} catch (e) {
alert('Error creating coach: ' + e.message);
}
setSaving(false);
};
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: ORANGE }} />
</div>
);
}
const teamsMap = {};
teams.forEach(t => {
teamsMap[t.name.toLowerCase()] = t;
});
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-black text-white">Georgia Team Coaches</h1>
<p className="text-slate-400 text-sm mt-1">Manage coach info for team pages</p>
</div>
<button
onClick={() => setCreatingNew(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold text-white transition-all hover:opacity-90"
style={{ background: ORANGE }}
>
<Plus className="w-4 h-4" />
Add Coach
</button>
</div>
{/* Create modal */}
{creatingNew && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-slate-900 border border-slate-800 rounded-xl p-6 max-w-sm mx-4 max-h-[90vh] overflow-y-auto">
<h2 className="text-white font-black text-lg mb-4">Add New Coach</h2>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-slate-400 font-semibold">First Name</label>
<input
type="text"
value={newCoachData.coach_first_name}
onChange={e => setNewCoachData(prev => ({ ...prev, coach_first_name: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Last Name</label>
<input
type="text"
value={newCoachData.coach_last_name}
onChange={e => setNewCoachData(prev => ({ ...prev, coach_last_name: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Email *</label>
<input
type="email"
value={newCoachData.email}
onChange={e => setNewCoachData(prev => ({ ...prev, email: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Phone</label>
<input
type="tel"
value={newCoachData.phone}
onChange={e => setNewCoachData(prev => ({ ...prev, phone: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">High School / Program *</label>
<input
type="text"
value={newCoachData.high_school}
onChange={e => setNewCoachData(prev => ({ ...prev, high_school: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Team Page Password (optional)</label>
<input
type="text"
value={newCoachData.password || ''}
onChange={e => setNewCoachData(prev => ({ ...prev, password: e.target.value }))}
placeholder="Leave blank for no password"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
</div>
<div className="flex gap-2 mt-6">
<button
onClick={createNewCoach}
disabled={saving}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg text-xs font-bold text-white disabled:opacity-50"
style={{ background: ORANGE }}
>
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
Add
</button>
<button
onClick={() => setCreatingNew(false)}
disabled={saving}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg text-xs font-bold text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 disabled:opacity-50"
>
<X className="w-3 h-3" />
Cancel
</button>
</div>
</div>
</div>
)}
{/* Coach list */}
<div className="grid gap-3">
{coachProfiles.length === 0 ? (
<div className="text-center py-12 text-slate-500">No coaches yet</div>
) : (
coachProfiles.map(coach => {
const team = teamsMap[coach.high_school?.toLowerCase()];
return (
<div
key={coach.id}
className="rounded-xl border p-4 transition-all"
style={{
background: editingId === coach.id ? 'rgba(255,106,0,0.05)' : '#0a0a0a',
borderColor: editingId === coach.id ? 'rgba(255,106,0,0.3)' : 'rgba(255,255,255,0.07)',
}}
>
{editingId === coach.id ? (
// Edit mode
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-slate-400 font-semibold">First Name</label>
<input
type="text"
value={editData.coach_first_name}
onChange={e => setEditData(prev => ({ ...prev, coach_first_name: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Last Name</label>
<input
type="text"
value={editData.coach_last_name}
onChange={e => setEditData(prev => ({ ...prev, coach_last_name: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Email</label>
<input
type="email"
value={editData.email}
onChange={e => setEditData(prev => ({ ...prev, email: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Phone</label>
<input
type="tel"
value={editData.phone}
onChange={e => setEditData(prev => ({ ...prev, phone: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">High School</label>
<input
type="text"
value={editData.high_school}
onChange={e => setEditData(prev => ({ ...prev, high_school: e.target.value }))}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-slate-400 font-semibold">Password (optional)</label>
<input
type="text"
value={editData.password || ''}
onChange={e => setEditData(prev => ({ ...prev, password: e.target.value }))}
placeholder="Leave blank for no password"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
/>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => saveCoach(coach.id)}
disabled={saving}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg text-xs font-bold text-white disabled:opacity-50"
style={{ background: ORANGE }}
>
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
Save
</button>
<button
onClick={() => setEditingId(null)}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg text-xs font-bold text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700"
>
<X className="w-3 h-3" />
Cancel
</button>
</div>
</div>
) : (
// View mode
<div className="flex items-center justify-between">
<div>
<p className="text-white font-bold text-sm">{coach.coach_name}</p>
<p className="text-xs text-slate-400">{coach.email}</p>
<p className="text-xs text-slate-500">{coach.high_school}{coach.phone ? ` · ${coach.phone}` : ''}</p>
{coach.password && <p className="text-xs text-orange-400 mt-1">🔒 Password: {coach.password}</p>}
</div>
<button
onClick={() => startEdit(coach.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-bold text-white transition-all hover:opacity-90"
style={{ background: ORANGE }}
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
)}
</div>
);
})
)}
</div>
</div>
);
}src/components/admin/AdminHowTo.jsx import React, { useState } from 'react';
import { BookOpen, Users, BarChart3, Radio, Video, UserCheck, ChevronDown, ChevronRight } from 'lucide-react';
const STEPS = [
{
step: 1, icon: Users, color: 'bg-blue-500', label: 'Build Your Team',
desc: 'Go to Teams tab → New Team. Add players to your roster with jersey numbers and positions. Players can link their own profiles.',
},
{
step: 2, icon: BarChart3, color: 'bg-green-500', label: 'Schedule & Tag Games',
desc: 'Go to Stat Game → New Game. Upload game film, then use the Tagger to log every event with video timestamps. Stats auto-populate to player portfolios.',
},
{
step: 3, icon: Radio, color: 'bg-red-500', label: 'Live Stream with Fundraising',
desc: 'Go to Live Stream → New Stream. Enable fundraising to charge fans for access. Share the unique stream code with your broadcaster. Streams save automatically to AWS.',
},
{
step: 4, icon: Video, color: 'bg-cyan-500', label: 'Review Film Sessions',
desc: 'All uploaded game film appears in the Film tab. Open any game in the Tagger for collaborative review. Share clips with players and coaches.',
},
{
step: 5, icon: UserCheck, color: 'bg-orange-500', label: 'Build Player Portfolios',
desc: 'Stats tagged from games automatically populate each player\'s portfolio. Add highlight videos, scouting reports, and bio info. Publish profiles for college recruiters.',
},
];
const TIPS = [
{ color: 'text-orange-400', title: 'Multi-Camera Streaming', desc: 'Connect phones/tablets on the same WiFi. Use unique stream codes to link devices and switch camera angles in real-time.' },
{ color: 'text-blue-400', title: 'Athlete Profiles', desc: 'Athletes own and control their profiles even when linked to teams. Stats from all tagged games automatically populate the profile.' },
{ color: 'text-green-400', title: 'Live Stats Panel', desc: 'Stats display in a separate panel next to video — not overlaid on stream. Multiple people can collaborate on scorekeeping simultaneously.' },
{ color: 'text-purple-400', title: 'Vimeo Restream', desc: 'Add your Vimeo RTMP URL and stream key when creating a stream to automatically restream to Vimeo for wider audience reach.' },
{ color: 'text-yellow-400', title: 'Fundraising Campaigns', desc: 'Set a fan access price when creating a stream. Share the watch link to family/friends. Revenue tracked via Stripe payment processing.' },
];
export default function AdminHowTo() {
const [expandedStep, setExpandedStep] = useState(null);
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-black text-white">How to Use <span className="text-orange-400">Teamstream SBV</span></h1>
<p className="text-slate-400 mt-2">Your complete all-in-one sports platform. From athlete recruiting profiles and multi-camera live streaming to stat tagging and fundraising — everything you need in one place.</p>
</div>
<div className="space-y-3 mb-10">
{STEPS.map(s => {
const Icon = s.icon;
const isOpen = expandedStep === s.step;
return (
<div key={s.step} className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<button
onClick={() => setExpandedStep(isOpen ? null : s.step)}
className="w-full flex items-center gap-4 p-5 text-left hover:bg-slate-800/50 transition-all"
>
<div className={`w-10 h-10 ${s.color} rounded-xl flex items-center justify-center flex-shrink-0`}>
<Icon className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<p className="text-xs text-slate-500 font-semibold uppercase tracking-wider">Step {s.step}</p>
<p className="text-white font-bold">{s.label}</p>
</div>
{isOpen ? <ChevronDown className="w-5 h-5 text-slate-400" /> : <ChevronRight className="w-5 h-5 text-slate-400" />}
</button>
{isOpen && (
<div className="px-5 pb-5 ml-14">
<p className="text-slate-400 text-sm leading-relaxed">{s.desc}</p>
</div>
)}
</div>
);
})}
</div>
<div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div className="flex items-center gap-2 mb-4">
<span className="text-lg">💡</span>
<h2 className="text-white font-bold text-lg">Pro Tips</h2>
</div>
<div className="space-y-4">
{TIPS.map(tip => (
<div key={tip.title}>
<span className={`font-bold text-sm ${tip.color}`}>{tip.title}:</span>
<span className="text-slate-400 text-sm ml-2">{tip.desc}</span>
</div>
))}
</div>
</div>
</div>
);
}src/components/admin/AdminIdentityImport.jsx import React, { useState, useRef } from 'react';
import { base44 } from '@/api/base44Client';
import { Upload, FileText, Play, AlertCircle, CheckCircle2, Download, Loader2, XCircle, Link2, Users, IdCard } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export default function AdminIdentityImport() {
const [file, setFile] = useState(null);
const [fileUrl, setFileUrl] = useState('');
const [csvText, setCsvText] = useState('');
const [dryRun, setDryRun] = useState(true);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [activeView, setActiveView] = useState('matched');
const fileInputRef = useRef(null);
const handleFileSelect = (e) => {
const f = e.target.files[0];
if (!f) return;
setFile(f);
setFileUrl('');
const reader = new FileReader();
reader.onload = (ev) => setCsvText(ev.target.result);
reader.readAsText(f);
};
const handleUrlMode = () => {
setFile(null);
setCsvText('');
};
const handleRun = async () => {
setLoading(true);
setError(null);
setResult(null);
try {
const payload = {
dry_run: dryRun,
csv_filename: file?.name || (fileUrl ? fileUrl.split('/').pop() : 'unknown.csv')
};
if (fileUrl) {
payload.file_url = fileUrl;
} else if (csvText) {
payload.csv_text = csvText;
} else {
setError('Please upload a file or paste a CSV URL.');
setLoading(false);
return;
}
const res = await base44.functions.invoke('updatePlayerIdentitiesCSV', payload);
setResult(res.data);
setActiveView('matched');
} catch (err) {
setError(err?.response?.data?.error || err?.message || 'Import failed');
}
setLoading(false);
};
const downloadReport = () => {
if (!result) return;
const headers = ['Type', 'Row', 'Player Name', 'Slug', 'Fields / Error'];
const rows = [];
(result.preview?.matched || []).forEach(m => {
rows.push(['Matched', '', m.player_name || '', m.slug || '', (m.fields || []).join(', ')]);
});
(result.preview?.unmatched || []).forEach(u => {
rows.push(['Unmatched', u.row || '', u.player_name || '', u.slug || '', u.error || '']);
});
(result.preview?.errors || []).forEach(e => {
rows.push(['Error', e.row || '', e.player_name || '', '', e.error || '']);
});
const csv = [headers.join(','), ...rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `identity_update_report_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-black text-white mb-1 flex items-center gap-2">
<IdCard className="w-6 h-6 text-cyan-400" /> Update Core Identity from CSV
</h2>
<p className="text-gray-400 text-sm">
Upload a CSV of player identity data (name, contact, academic, physical, social). Players are matched by their{' '}
<strong className="text-cyan-400">Portfolio URL slug</strong>.{' '}
<span className="text-red-400 font-semibold">No new portfolios are created</span> — unmatched rows are skipped.
Only fields with values in the CSV are updated; existing data is preserved for blank fields.
</p>
</div>
{/* Upload / URL section */}
<div className="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div className="flex gap-2 mb-3">
<button onClick={() => fileInputRef.current?.click()}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 border-dashed transition-all text-sm font-medium
${file ? 'border-green-500/40 bg-green-500/5 text-green-400' : 'border-slate-700 text-gray-400 hover:border-cyan-500/40 hover:text-cyan-400'}`}>
{file ? <><CheckCircle2 className="w-4 h-4" /> {file.name}</> : <><Upload className="w-4 h-4" /> Upload CSV File</>}
</button>
<button onClick={handleUrlMode}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 border-dashed transition-all text-sm font-medium
${fileUrl ? 'border-green-500/40 bg-green-500/5 text-green-400' : 'border-slate-700 text-gray-400 hover:border-cyan-500/40 hover:text-cyan-400'}`}>
<Link2 className="w-4 h-4" /> Paste CSV URL
</button>
</div>
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={handleFileSelect} className="hidden" />
{fileUrl !== '' || (!file && csvText === '') ? (
<Input
placeholder="https://...csv"
value={fileUrl}
onChange={e => setFileUrl(e.target.value)}
className="bg-slate-800 border-slate-700 text-white text-sm"
/>
) : null}
{/* Options */}
<div className="flex flex-wrap gap-4 pt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={dryRun} onChange={e => setDryRun(e.target.checked)}
className="w-4 h-4 accent-cyan-500" />
<span className="text-sm text-gray-300">Dry Run <span className="text-gray-500">(preview only — recommended first)</span></span>
</label>
</div>
{/* Run button */}
<Button onClick={handleRun} disabled={loading || (!file && !fileUrl && !csvText)}
className="w-full bg-cyan-600 hover:bg-cyan-700 text-white font-bold"
size="lg">
{loading ? (
<><Loader2 className="w-4 h-4 animate-spin mr-2" /> Processing{dryRun ? ' Dry Run' : ''}... (may take 30-60s for large files)</>
) : (
<><Play className="w-4 h-4 mr-2" /> {dryRun ? 'Run Dry Run (Preview)' : 'Apply Identity Updates'}</>
)}
</Button>
{error && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
</div>
{/* Results */}
{result && (
<div className="space-y-4">
{/* Summary banner */}
<div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div className="flex items-center gap-2 mb-4">
{result.dry_run ? (
<span className="px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">DRY RUN</span>
) : (
<span className="px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest bg-green-500/20 text-green-400 border border-green-500/30">APPLIED</span>
)}
<span className="text-gray-500 text-xs">{result.import_log_id ? `Log ID: ${result.import_log_id}` : ''}</span>
</div>
<p className="text-gray-300 text-sm mb-4">{result.summary}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{[
{ label: 'Total Rows', value: result.total_rows, icon: FileText, color: 'text-white' },
{ label: 'Matched', value: result.matched, icon: CheckCircle2, color: 'text-green-400' },
{ label: result.dry_run ? 'Would Update' : 'Updated', value: result.dry_run ? result.matched : result.updated, icon: Play, color: 'text-cyan-400' },
{ label: 'Unmatched', value: result.unmatched, icon: XCircle, color: 'text-gray-400' },
{ label: 'Parse Errors', value: result.parse_errors, icon: AlertCircle, color: 'text-red-400' },
{ label: 'New Created', value: 0, icon: Users, color: 'text-gray-500' },
].map(s => (
<div key={s.label} className="bg-slate-800/50 rounded-lg p-3 text-center">
<s.icon className={`w-4 h-4 mx-auto mb-1 ${s.color}`} />
<div className={`text-xl font-black ${s.color}`}>{s.value}</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide mt-0.5">{s.label}</div>
</div>
))}
</div>
</div>
{/* Tab selector */}
<div className="flex gap-2 flex-wrap">
{[
{ id: 'matched', label: `Matched (${result.matched})`, icon: CheckCircle2, color: 'text-green-400' },
{ id: 'unmatched', label: `Unmatched (${result.unmatched})`, icon: XCircle, color: 'text-gray-400' },
{ id: 'errors', label: `Errors (${result.parse_errors})`, icon: AlertCircle, color: 'text-red-400' },
].map(t => (
<button key={t.id} onClick={() => setActiveView(t.id)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all
${activeView === t.id ? 'bg-slate-800 text-white' : 'text-gray-500 hover:text-gray-300'}`}>
<t.icon className={`w-3.5 h-3.5 ${t.color}`} />
{t.label}
</button>
))}
<button onClick={downloadReport}
className="ml-auto flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-slate-800 text-cyan-400 hover:bg-slate-700 transition-all">
<Download className="w-3.5 h-3.5" /> Download Report
</button>
</div>
{/* Tab content */}
<div className="bg-slate-900 rounded-xl border border-slate-800 p-4 max-h-[500px] overflow-y-auto">
{activeView === 'matched' && (
<div className="space-y-2">
{result.preview?.matched?.length > 0 ? result.preview.matched.map((m, i) => (
<div key={i} className="flex items-center gap-3 py-2 px-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-all">
<CheckCircle2 className="w-4 h-4 text-green-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-white text-sm font-medium">{m.player_name}</span>
<span className="text-gray-500 text-xs ml-2">CSV: "{m.csv_name}" · /{m.slug}</span>
</div>
<span className="text-cyan-400 text-xs font-bold shrink-0 max-w-[300px] truncate">{m.fields?.length} fields: {m.fields?.join(', ')}</span>
</div>
)) : <p className="text-gray-500 text-sm text-center py-8">No matched players.</p>}
{result.matched > 30 && <p className="text-gray-600 text-xs text-center pt-2">Showing first 30 of {result.matched} matches. Full list saved in ImportLog.</p>}
</div>
)}
{activeView === 'unmatched' && (
<div className="space-y-2">
{result.preview?.unmatched?.length > 0 ? result.preview.unmatched.map((u, i) => (
<div key={i} className="flex items-center gap-3 py-2 px-3 rounded-lg bg-slate-800/50">
<XCircle className="w-4 h-4 text-gray-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-white text-sm font-medium">{u.player_name}</span>
<span className="text-gray-500 text-xs ml-2">/{u.slug}</span>
<p className="text-gray-500 text-xs mt-0.5">{u.error}</p>
</div>
</div>
)) : <p className="text-gray-500 text-sm text-center py-8">No unmatched rows — every CSV row found a matching portfolio!</p>}
{result.unmatched > 30 && <p className="text-gray-600 text-xs text-center pt-2">Showing first 30 of {result.unmatched} unmatched. Full list saved in ImportLog.</p>}
</div>
)}
{activeView === 'errors' && (
<div className="space-y-2">
{result.preview?.errors?.length > 0 ? result.preview.errors.map((e, i) => (
<div key={i} className="py-2 px-3 rounded-lg bg-slate-800/50">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="text-white text-sm font-medium">{e.player_name || 'Unknown'}</span>
{e.row && <span className="text-gray-600 text-xs ml-2">Row {e.row}</span>}
<p className="text-gray-400 text-xs mt-0.5">{e.error}</p>
</div>
</div>
</div>
)) : <p className="text-gray-500 text-sm text-center py-8">No parse errors!</p>}
</div>
)}
</div>
{/* Apply button (if dry run had matches) */}
{result.dry_run && result.matched > 0 && (
<div className="bg-green-500/10 border border-green-500/20 rounded-xl p-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-400 shrink-0" />
<p className="text-green-400 text-sm">
<strong>{result.matched}</strong> existing portfolios matched for identity update from <strong>{result.total_rows}</strong> rows.
No new portfolios will be created. Review above, then apply when ready.
</p>
</div>
<Button onClick={() => { setDryRun(false); setTimeout(handleRun, 100); }}
className="bg-green-600 hover:bg-green-700 text-white font-bold shrink-0"
disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4 mr-1" />}
Apply Updates
</Button>
</div>
)}
</div>
)}
</div>
);
}src/components/admin/AdminLiveStream.jsx import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Radio, Plus, Copy, ExternalLink, Code2, X, Zap, DollarSign, Video } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
function NewStreamModal({ teams, onSave, onClose }) {
const [form, setForm] = useState({
title: '', team_id: teams[0]?.id || '', is_fundraiser: false,
fan_access_price: 5, embed_enabled: true, team_page_hosted: false,
vimeo_restream_url: '', vimeo_restream_key: '',
});
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="bg-slate-900 rounded-2xl border border-slate-700 w-full max-w-lg p-6 my-4">
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-bold text-white">New Live Stream</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-4">
<div>
<label className="text-xs text-slate-400 mb-1 block">Stream Title *</label>
<Input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })}
className="bg-slate-800 border-slate-700 text-white" placeholder="e.g. Mambas vs Tigers" />
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Team *</label>
<select value={form.team_id} onChange={e => setForm({ ...form, team_id: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 text-white rounded-md px-3 py-2 text-sm">
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
{/* Fundraiser toggle */}
<div className="bg-slate-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-yellow-400" />
<span className="text-white text-sm font-semibold">Fundraising Stream</span>
</div>
<button
onClick={() => setForm({ ...form, is_fundraiser: !form.is_fundraiser })}
className={`relative w-10 h-5 rounded-full transition-colors ${form.is_fundraiser ? 'bg-yellow-500' : 'bg-slate-600'}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${form.is_fundraiser ? 'translate-x-5' : ''}`} />
</button>
</div>
{form.is_fundraiser && (
<div>
<label className="text-xs text-slate-400 mb-1 block">Fan Access Price ($)</label>
<Input type="number" value={form.fan_access_price}
onChange={e => setForm({ ...form, fan_access_price: parseFloat(e.target.value) || 0 })}
className="bg-slate-700 border-slate-600 text-white" placeholder="5" />
</div>
)}
</div>
{/* Hosting options */}
<div className="bg-slate-800 rounded-xl p-4 space-y-3">
<p className="text-white text-sm font-semibold">Hosting Options</p>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={form.embed_enabled}
onChange={e => setForm({ ...form, embed_enabled: e.target.checked })}
className="w-4 h-4 accent-blue-500" />
<div>
<p className="text-white text-sm">Allow Embed Code</p>
<p className="text-slate-400 text-xs">Teams can embed the stream on their own website</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={form.team_page_hosted}
onChange={e => setForm({ ...form, team_page_hosted: e.target.checked })}
className="w-4 h-4 accent-blue-500" />
<div>
<p className="text-white text-sm">Host on GOAIO Team Page</p>
<p className="text-slate-400 text-xs">Stream will appear on the team's GOAIO-hosted page</p>
</div>
</label>
</div>
{/* Vimeo restream */}
<div className="bg-slate-800 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2">
<Video className="w-4 h-4 text-blue-400" />
<p className="text-white text-sm font-semibold">Restream to Vimeo (optional)</p>
</div>
<Input value={form.vimeo_restream_url}
onChange={e => setForm({ ...form, vimeo_restream_url: e.target.value })}
className="bg-slate-700 border-slate-600 text-white" placeholder="Vimeo RTMP URL" />
<Input value={form.vimeo_restream_key}
onChange={e => setForm({ ...form, vimeo_restream_key: e.target.value })}
className="bg-slate-700 border-slate-600 text-white" placeholder="Vimeo Stream Key" />
</div>
</div>
<div className="flex gap-3 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1 text-slate-400">Cancel</Button>
<Button onClick={() => onSave(form)} className="flex-1 bg-red-500 hover:bg-red-600"
disabled={!form.title || !form.team_id}>
<Radio className="w-4 h-4 mr-2" /> Create Stream
</Button>
</div>
</div>
</div>
);
}
function EmbedModal({ stream, onClose }) {
const embedCode = `<iframe src="${window.location.origin}/stream/${stream.stream_code}" width="100%" height="480" frameborder="0" allowfullscreen allow="autoplay"></iframe>`;
const [copied, setCopied] = useState(false);
const copy = (text) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-slate-900 rounded-2xl border border-slate-700 w-full max-w-lg p-6">
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-bold text-white">Embed & Share</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-4">
<div>
<label className="text-xs text-slate-400 mb-2 block">Stream Code (share with streamers)</label>
<div className="flex items-center gap-2 bg-slate-800 rounded-lg p-3">
<span className="text-orange-400 font-black text-xl tracking-widest flex-1">{stream.stream_code}</span>
<button onClick={() => copy(stream.stream_code)} className="text-slate-400 hover:text-white">
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div>
<label className="text-xs text-slate-400 mb-2 block">Fan Watch Link</label>
<div className="flex items-center gap-2 bg-slate-800 rounded-lg p-3">
<span className="text-blue-400 text-sm flex-1 truncate">{window.location.origin}/stream/{stream.stream_code}</span>
<button onClick={() => copy(`${window.location.origin}/stream/${stream.stream_code}`)} className="text-slate-400 hover:text-white">
<Copy className="w-4 h-4" />
</button>
</div>
</div>
{stream.embed_enabled && (
<div>
<label className="text-xs text-slate-400 mb-2 block">Embed Code</label>
<div className="bg-slate-800 rounded-lg p-3">
<pre className="text-green-400 text-xs whitespace-pre-wrap break-all">{embedCode}</pre>
<button onClick={() => copy(embedCode)}
className="mt-2 flex items-center gap-1 text-xs text-slate-400 hover:text-white">
<Copy className="w-3.5 h-3.5" /> {copied ? 'Copied!' : 'Copy embed code'}
</button>
</div>
</div>
)}
{stream.aws_ingest_endpoint && (
<div>
<label className="text-xs text-slate-400 mb-2 block">AWS Ingest Endpoint (for broadcaster app)</label>
<div className="bg-slate-800 rounded-lg p-3">
<p className="text-slate-300 text-xs break-all">{stream.aws_ingest_endpoint}</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default function AdminLiveStream() {
const queryClient = useQueryClient();
const [showNew, setShowNew] = useState(false);
const [embedStream, setEmbedStream] = useState(null);
const { data: teams = [] } = useQuery({ queryKey: ['hoop-teams'], queryFn: () => base44.entities.HoopTeam.list() });
const { data: streams = [] } = useQuery({ queryKey: ['live-streams'], queryFn: () => base44.entities.LiveStream.list('-created_date', 50) });
const createMutation = useMutation({
mutationFn: (data) => base44.functions.invoke('manageStream', { action: 'create', streamData: data }),
onSuccess: () => { queryClient.invalidateQueries(['live-streams']); setShowNew(false); },
});
const statusMutation = useMutation({
mutationFn: ({ streamId, action }) => base44.functions.invoke('manageStream', { action, streamId }),
onSuccess: () => queryClient.invalidateQueries(['live-streams']),
});
const deleteMutation = useMutation({
mutationFn: (id) => base44.entities.LiveStream.delete(id),
onSuccess: () => queryClient.invalidateQueries(['live-streams']),
});
const getTeam = (teamId) => teams.find(t => t.id === teamId);
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-black text-white">Live Streaming</h1>
<p className="text-slate-400 text-sm">Create streams, generate codes, save to AWS cloud</p>
</div>
<Button onClick={() => setShowNew(true)} className="bg-red-500 hover:bg-red-600 gap-2">
<Plus className="w-4 h-4" /> New Stream
</Button>
</div>
{/* Info banner */}
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-xl p-4 mb-6 flex items-start gap-3">
<Zap className="w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-yellow-400 text-sm font-semibold">Fundraising Feature</p>
<p className="text-slate-400 text-xs mt-0.5">
Generate revenue by streaming your games. Streams are saved to AWS S3 cloud storage.
Restream to Vimeo for wider audience reach. Share unique stream codes with your team.
</p>
</div>
</div>
<div className="space-y-4">
{streams.map(stream => {
const team = getTeam(stream.team_id);
return (
<div key={stream.id} className="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-white font-bold">{stream.title}</h3>
<Badge className={
stream.status === 'live' ? 'bg-red-500 animate-pulse' :
stream.status === 'ended' ? 'bg-slate-600' : 'bg-slate-700'
}>
{stream.status === 'live' ? '● LIVE' : stream.status}
</Badge>
{stream.is_fundraiser && <Badge className="bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">Fundraiser</Badge>}
</div>
<p className="text-slate-400 text-sm">{team?.name || 'No team'}</p>
</div>
<button onClick={() => deleteMutation.mutate(stream.id)}
className="p-1.5 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10">
<X className="w-4 h-4" />
</button>
</div>
{/* Stream code */}
<div className="grid sm:grid-cols-3 gap-3 mb-4">
<div className="bg-slate-800 rounded-lg p-3">
<p className="text-xs text-slate-400 mb-1">Stream Code</p>
<p className="text-orange-400 font-black tracking-widest">{stream.stream_code}</p>
</div>
{stream.is_fundraiser && (
<div className="bg-slate-800 rounded-lg p-3">
<p className="text-xs text-slate-400 mb-1">Fan Access Price</p>
<p className="text-green-400 font-bold">${stream.fan_access_price}</p>
</div>
)}
<div className="bg-slate-800 rounded-lg p-3">
<p className="text-xs text-slate-400 mb-1">Score</p>
<p className="text-white font-bold">{stream.score_us} – {stream.score_opponent}</p>
</div>
</div>
{/* Feature flags */}
<div className="flex gap-2 mb-4 flex-wrap">
{stream.embed_enabled && <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded-full border border-blue-500/30">Embed Enabled</span>}
{stream.team_page_hosted && <span className="text-xs bg-purple-500/20 text-purple-400 px-2 py-1 rounded-full border border-purple-500/30">GOAIO Team Page</span>}
{stream.vimeo_restream_url && <span className="text-xs bg-cyan-500/20 text-cyan-400 px-2 py-1 rounded-full border border-cyan-500/30">Vimeo Restream</span>}
{stream.aws_recording_url && <span className="text-xs bg-orange-500/20 text-orange-400 px-2 py-1 rounded-full border border-orange-500/30">AWS Cloud Save</span>}
</div>
{/* Actions */}
<div className="flex gap-2 flex-wrap">
{stream.status === 'offline' && (
<Button size="sm" onClick={() => statusMutation.mutate({ streamId: stream.id, action: 'go_live' })}
className="bg-red-500 hover:bg-red-600 gap-1">
<Radio className="w-3.5 h-3.5" /> Go Live
</Button>
)}
{stream.status === 'live' && (
<Button size="sm" onClick={() => statusMutation.mutate({ streamId: stream.id, action: 'end_stream' })}
className="bg-slate-600 hover:bg-slate-500 gap-1">
End Stream
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setEmbedStream(stream)}
className="border-slate-700 text-slate-300 hover:text-white gap-1">
<Code2 className="w-3.5 h-3.5" /> Embed & Share
</Button>
<Button size="sm" variant="outline"
onClick={() => window.open(`/stream/${stream.stream_code}`, '_blank')}
className="border-slate-700 text-slate-300 hover:text-white gap-1">
<ExternalLink className="w-3.5 h-3.5" /> Watch Page
</Button>
</div>
</div>
);
})}
{streams.length === 0 && (
<div className="bg-slate-900/50 rounded-xl border border-dashed border-slate-700 p-12 text-center">
<Radio className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<p className="text-slate-400 mb-4">No streams yet</p>
<Button onClick={() => setShowNew(true)} className="bg-red-500 hover:bg-red-600">Create First Stream</Button>
</div>
)}
</div>
{showNew && <NewStreamModal teams={teams} onSave={d => createMutation.mutate(d)} onClose={() => setShowNew(false)} />}
{embedStream && <EmbedModal stream={embedStream} onClose={() => setEmbedStream(null)} />}
</div>
);
}src/components/admin/AdminPlayerExport.jsx import { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { Download, Copy, Check } from 'lucide-react';
export default function AdminPlayerExport() {
const [loading, setLoading] = useState(false);
const [urls, setUrls] = useState([]);
const [copied, setCopied] = useState(false);
const fetchAll = async () => {
setLoading(true);
const all = await base44.entities.Player.filter({}, 'full_name', 5000);
const seen = new Set();
const unique = all
.filter(p => {
if (seen.has(p.id)) return false;
seen.add(p.id);
return p.portfolio_url_slug;
})
.sort((a, b) => a.full_name.localeCompare(b.full_name))
.map(p => ({ name: p.full_name, url: `https://goaio.live/player/${p.portfolio_url_slug}` }));
setUrls(unique);
setLoading(false);
};
const text = urls.map(u => `${u.name}\t${u.url}`).join('\n');
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const download = () => {
const blob = new Blob([text], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'player-portfolio-urls.txt';
a.click();
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-white">Player Portfolio URLs</h2>
<p className="text-slate-400 text-sm">{urls.length > 0 ? `${urls.length} players loaded` : 'Click Load to fetch all players'}</p>
</div>
<div className="flex gap-2">
<button onClick={fetchAll} disabled={loading}
className="px-4 py-2 rounded-lg bg-orange-500 hover:bg-orange-600 text-white text-sm font-bold disabled:opacity-50 transition-all">
{loading ? 'Loading...' : 'Load All'}
</button>
{urls.length > 0 && (
<>
<button onClick={copy}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-sm font-bold transition-all">
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
{copied ? 'Copied!' : 'Copy'}
</button>
<button onClick={download}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-sm font-bold transition-all">
<Download className="w-4 h-4" /> Download
</button>
</>
)}
</div>
</div>
{urls.length > 0 && (
<textarea
readOnly
value={text}
className="w-full h-[60vh] bg-slate-900 border border-slate-700 rounded-lg p-4 text-xs text-slate-300 font-mono resize-none focus:outline-none"
/>
)}
</div>
);
}src/components/admin/AdminPortfolios.jsx import { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
Plus, Search, Edit, ExternalLink, Sparkles, FileText,
LayoutTemplate, Upload, ToggleLeft, ToggleRight, Crown, History, Trash2, Star, ClipboardList
} from 'lucide-react';
import PlayerEditModal from '../portfolio/PlayerEditModal';
import BulkUpload from '../portfolio/BulkUpload';
import GameHighlightLinks from '../portfolio/GameHighlightLinks';
import PortfolioTemplates from '../portfolio/PortfolioTemplates';
import RosterImportPanel from '../portfolio/RosterImportPanel';
import SlugFixButton from '../portfolio/SlugFixButton';
import AuditHistoryModal from './AuditHistoryModal';
import { computeAverages, toPlayerId } from '@/lib/portfolioHelpers';
const TABS = [
{ key: 'players', label: 'Player Portfolios' },
{ key: 'concierge_tab', label: '⏳ Concierge Queue' },
{ key: 'bulk', label: 'Batch Builder' },
{ key: 'links', label: 'Game & Highlight Links' },
{ key: 'templates', label: 'Templates' },
];
export default function AdminPortfolios({ currentUser }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [tab, setTab] = useState('players');
const [search, setSearch] = useState('');
const [editPlayer, setEditPlayer] = useState(null);
const [showAddNew, setShowAddNew] = useState(false);
const [auditPlayer, setAuditPlayer] = useState(null);
const [filter, setFilter] = useState('all'); // 'all' | 'published' | 'draft' | 'pending' | 'needs_correction'
const { data: players = [], isLoading } = useQuery({
queryKey: ['all-players-admin'],
queryFn: () => base44.entities.Player.filter({}, '-updated_date', 5000),
});
const deleteMutation = useMutation({
mutationFn: (id) => base44.entities.Player.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['all-players-admin'] }),
});
const filtered = players.filter(p => {
const q = search.toLowerCase();
const matchesSearch = !q
|| (p.full_name || '').toLowerCase().includes(q)
|| (p.current_team_name || '').toLowerCase().includes(q)
|| (p.position || '').toLowerCase().includes(q)
|| (p.high_school || '').toLowerCase().includes(q)
|| (p.email || '').toLowerCase().includes(q)
|| (p.phone || '').includes(q);
const matchesFilter =
filter === 'all' ? true :
filter === 'published' ? p.is_published :
filter === 'draft' ? !p.is_published :
filter === 'pending' ? (p.audit_status === 'pending' || !p.audit_status) :
filter === 'needs_correction' ? p.audit_status === 'needs_correction' :
filter === 'concierge' ? (p.audit_status !== 'verified') : true;
return matchesSearch && matchesFilter;
}).sort((a, b) => {
const aPriority = a.portfolio_tier === 'premium' || a.portfolio_tier === 'under_review' || a.is_published;
const bPriority = b.portfolio_tier === 'premium' || b.portfolio_tier === 'under_review' || b.is_published;
if (aPriority && !bPriority) return -1;
if (bPriority && !aPriority) return 1;
return new Date(b.updated_date || 0) - new Date(a.updated_date || 0);
});
// Save via audit-wrapped backend function
const handleSave = async (data) => {
const slug = data.portfolio_url_slug || toPlayerId(data.full_name);
const avgs = computeAverages(data.boxscores || []);
const payload = { ...data, portfolio_url_slug: slug, ...avgs };
if (data.id) {
// Use audit-wrapped update
await base44.functions.invoke('updatePlayerWithAudit', {
playerId: data.id,
updates: payload,
});
} else {
// New player — create directly then log creation
const created = await base44.entities.Player.create(payload);
await base44.entities.AuditLog.create({
target_entity: 'Player',
target_id: created.id,
target_name: created.full_name,
changed_by_user_id: currentUser?.id || 'unknown',
action: 'create',
changes: {},
}).catch(() => {}); // Non-blocking
}
queryClient.invalidateQueries({ queryKey: ['all-players-admin'] });
setEditPlayer(null);
setShowAddNew(false);
};
const publishedCount = players.filter(p => p.is_published).length;
const draftCount = players.filter(p => !p.is_published).length;
const premiumCount = players.filter(p => p.portfolio_tier === 'premium').length;
const pendingAuditCount = players.filter(p => p.audit_status === 'pending' || !p.audit_status).length;
const needsCorrectionCount = players.filter(p => p.audit_status === 'needs_correction').length;
const priorityCount = players.filter(p => p.is_priority_recruit).length;
return (
<div className="text-white">
{/* Sub-tabs */}
<div className="border-b border-white/10 mb-6 -mx-4 px-4 overflow-x-auto">
<div className="flex gap-0">
{TABS.map(t => (
<button key={t.key} onClick={() => setTab(t.key)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-all whitespace-nowrap ${
tab === t.key ? 'border-orange-500 text-white' : 'border-transparent text-gray-500 hover:text-gray-300'
}`}>
{t.label}
</button>
))}
</div>
</div>
{/* ── PLAYERS TAB ── */}
{tab === 'players' && (
<>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-start gap-4 mb-5">
<div>
<h2 className="text-2xl font-black text-white">Player Portfolios</h2>
<div className="flex items-center gap-3 mt-1 flex-wrap">
<span className="text-gray-500 text-xs">{players.length} total</span>
<span className="text-green-400 text-xs">✓ {publishedCount} live</span>
<span className="text-yellow-500 text-xs">◎ {draftCount} draft</span>
<span className="text-purple-400 text-xs">★ {premiumCount} premium</span>
{pendingAuditCount > 0 && <span className="text-orange-400 text-xs">⏳ {pendingAuditCount} pending audit</span>}
{needsCorrectionCount > 0 && <span className="text-red-400 text-xs">⚠️ {needsCorrectionCount} needs correction</span>}
{priorityCount > 0 && <span className="text-yellow-300 text-xs">🌟 {priorityCount} priority</span>}
</div>
</div>
<div className="sm:ml-auto flex flex-wrap gap-2">
<button onClick={() => setTab('bulk')}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold bg-gradient-to-r from-violet-600 to-purple-600 text-white hover:opacity-90 transition-all">
<Sparkles className="w-4 h-4" /> Bulk Import
</button>
<button onClick={() => setTab('links')}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm border border-white/15 text-gray-300 hover:bg-white/5 transition-all">
<FileText className="w-4 h-4" /> Links
</button>
<button onClick={() => { setEditPlayer({}); setShowAddNew(true); }}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold bg-[#FF6B00] text-white hover:bg-orange-500 transition-all">
<Plus className="w-4 h-4" /> Add Player
</button>
</div>
</div>
{/* Search + Filter */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Search name, team, position, email, phone…"
className="w-full bg-slate-800 border border-slate-700 rounded-xl pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500/50"
/>
</div>
<div className="flex gap-1 flex-wrap">
{[
['all','All'],
['published','Live'],
['draft','Draft'],
['concierge','Queue'],
['pending','Pending'],
['needs_correction','Fix Needed'],
].map(([val, label]) => (
<button key={val} onClick={() => setFilter(val)}
className={`px-3 py-2 rounded-lg text-xs font-semibold transition-all ${
filter === val ? 'bg-slate-700 text-white' :
val === 'needs_correction' ? 'text-red-500 hover:text-red-300' :
val === 'concierge' ? 'text-orange-500 hover:text-orange-300' :
'text-gray-500 hover:text-gray-300'
}`}>
{label}
</button>
))}
</div>
</div>
{/* Player List */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<div className="space-y-2">
{filtered.map(p => (
<PlayerAdminRow
key={p.id}
player={p}
onEdit={() => navigate(`/admin-player/${p.id}`)}
onHistory={() => setAuditPlayer(p)}
onDelete={() => { if (confirm(`Delete ${p.full_name}? This cannot be undone.`)) deleteMutation.mutate(p.id); }}
queryClient={queryClient}
/>
))}
{filtered.length === 0 && (
<div className="text-center py-16 text-gray-600">
<p className="text-lg">No players found</p>
<p className="text-sm mt-1">Try adjusting your search or filter</p>
</div>
)}
</div>
)}
</>
)}
{/* ── CONCIERGE QUEUE TAB ── */}
{tab === 'concierge_tab' && (
<>
<h2 className="text-2xl font-black text-white mb-1">Concierge Queue</h2>
<p className="text-gray-500 text-sm mb-6">Players not yet verified — your active 72-hour production list.</p>
<div className="space-y-2">
{players
.filter(p => p.audit_status !== 'verified')
.sort((a, b) => {
const order = { needs_correction: 0, in_progress: 1, pending: 2 };
return (order[a.audit_status] ?? 2) - (order[b.audit_status] ?? 2);
})
.map(p => (
<PlayerAdminRow
key={p.id}
player={p}
onEdit={() => navigate(`/admin-player/${p.id}`)}
onHistory={() => {}}
onDelete={() => {}}
queryClient={queryClient}
/>
))}
{players.filter(p => p.audit_status !== 'verified').length === 0 && (
<div className="text-center py-16 text-gray-600">
<p className="text-lg">🎉 All players verified</p>
<p className="text-sm mt-1">Nothing left in the queue</p>
</div>
)}
</div>
</>
)}
{/* ── BULK TAB ── */}
{tab === 'bulk' && (
<>
<h2 className="text-2xl font-black text-white mb-1">Bulk Content Generator</h2>
<p className="text-gray-500 text-sm mb-6">Upload multiple files at once to import player rosters.</p>
<div className="space-y-6">
<RosterImportPanel onImported={() => queryClient.invalidateQueries({ queryKey: ['all-players-admin'] })} />
<BulkUpload onPlayersImported={() => { queryClient.invalidateQueries({ queryKey: ['all-players-admin'] }); setTab('players'); }} />
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<p className="text-white font-semibold text-sm mb-1">Fix Player URL Slugs</p>
<p className="text-gray-500 text-xs mb-3">Re-format all slugs to firstname-lastname format</p>
<SlugFixButton />
</div>
</div>
</>
)}
{/* ── LINKS TAB ── */}
{tab === 'links' && (
<>
<h2 className="text-2xl font-black text-white mb-1">Game & Highlight Links</h2>
<p className="text-gray-500 text-sm mb-6">Links are matched to player boxscores within ±2 days.</p>
<GameHighlightLinks players={players} onSave={() => queryClient.invalidateQueries({ queryKey: ['all-players-admin'] })} />
</>
)}
{/* ── TEMPLATES TAB ── */}
{tab === 'templates' && (
<>
<h2 className="text-2xl font-black text-white mb-1">Portfolio Templates</h2>
<p className="text-gray-500 text-sm mb-6">Choose a default layout for player portfolio pages.</p>
<PortfolioTemplates />
</>
)}
{/* Edit Modal */}
{(editPlayer !== null || showAddNew) && (
<PlayerEditModal
player={editPlayer || {}}
onSave={handleSave}
onClose={() => { setEditPlayer(null); setShowAddNew(false); }}
/>
)}
{/* Audit History Modal */}
{auditPlayer && (
<AuditHistoryModal player={auditPlayer} onClose={() => setAuditPlayer(null)} />
)}
</div>
);
}
function PlayerAdminRow({ player, onEdit, onHistory, onDelete, queryClient }) {
const slug = player.portfolio_url_slug;
const displayTeam = player.current_team_name || player.high_school || '—';
const gamesPlayed = player.career_games_played || player.boxscores?.filter(b => !b.excluded_from_averages).length || 0;
const toggleCustomization = async (e) => {
e.stopPropagation();
await base44.entities.Player.update(player.id, {
allow_player_customization: !player.allow_player_customization,
});
queryClient.invalidateQueries({ queryKey: ['all-players-admin'] });
};
const togglePriority = async (e) => {
e.stopPropagation();
await base44.entities.Player.update(player.id, {
is_priority_recruit: !player.is_priority_recruit,
});
queryClient.invalidateQueries({ queryKey: ['all-players-admin'] });
};
const AUDIT_BADGE = {
pending: { label: 'Pending', cls: 'bg-slate-800 border-slate-600 text-gray-400' },
in_progress: { label: 'Auditing…', cls: 'bg-orange-900/30 border-orange-600/30 text-orange-400' },
needs_correction: { label: 'Fix Needed', cls: 'bg-red-900/30 border-red-600/30 text-red-400' },
verified: { label: 'Verified', cls: 'bg-green-900/30 border-green-600/30 text-green-400' },
};
const auditBadge = AUDIT_BADGE[player.audit_status] || AUDIT_BADGE.pending;
return (
<div className="bg-slate-800/60 rounded-xl px-4 py-3 border border-slate-700/50 hover:border-slate-600 transition-all">
{/* Top row */}
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full overflow-hidden bg-slate-700 border border-white/10 shrink-0">
{player.profile_photo_url
? <img src={player.profile_photo_url} alt={player.full_name} className="w-full h-full object-cover" />
: <div className="w-full h-full flex items-center justify-center font-black text-gray-500 text-sm">
{player.full_name?.[0]}
</div>}
</div>
{/* Name + meta */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-white text-sm">{player.full_name}</span>
{player.jersey_number && <span className="text-orange-400 text-xs font-bold">#{player.jersey_number}</span>}
{player.position && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-900/30 border border-blue-600/25 text-blue-400">
{player.position}
</span>
)}
{!player.is_published && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-yellow-900/40 border border-yellow-600/25 text-yellow-500">Draft</span>
)}
{player.portfolio_tier === 'premium' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-green-900/40 border border-green-600/25 text-green-400 flex items-center gap-0.5">
<Crown className="w-2.5 h-2.5" /> Premium
</span>
)}
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${auditBadge.cls}`}>
{auditBadge.label}
</span>
{player.is_priority_recruit && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-yellow-900/30 border border-yellow-500/30 text-yellow-400 flex items-center gap-0.5">
<Star className="w-2.5 h-2.5 fill-yellow-400" /> Priority
</span>
)}
</div>
<div className="text-gray-500 text-xs mt-0.5 flex items-center gap-2 flex-wrap">
<span>{displayTeam}</span>
{gamesPlayed > 0 && <span>· {gamesPlayed} games</span>}
{player.career_ppg != null && <span>· {player.career_ppg.toFixed(1)} PPG</span>}
</div>
</div>
{/* Stats (desktop) */}
<div className="hidden md:flex gap-2 shrink-0">
{[
{ label: 'PPG', value: player.career_ppg?.toFixed(1), orange: true },
{ label: 'RPG', value: player.career_rpg?.toFixed(1) },
{ label: 'APG', value: player.career_apg?.toFixed(1) },
].map(s => (
<div key={s.label} className="w-12 bg-slate-900 rounded-lg py-1.5 text-center border border-white/[0.05]">
<div className={`font-black text-sm leading-none ${s.orange ? 'text-orange-400' : 'text-white'}`}>{s.value || '—'}</div>
<div className="text-[9px] text-gray-600 uppercase tracking-widest mt-0.5">{s.label}</div>
</div>
))}
</div>
{/* Actions */}
<div className="flex gap-1.5 shrink-0 flex-wrap items-center">
<button onClick={togglePriority}
title={player.is_priority_recruit ? 'Priority Recruit — click to remove' : 'Mark as Priority Recruit'}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border transition-all ${
player.is_priority_recruit
? 'bg-yellow-900/30 border-yellow-500/30 text-yellow-400'
: 'border-slate-600 text-gray-600 hover:text-gray-400'
}`}>
<Star className={`w-3.5 h-3.5 ${player.is_priority_recruit ? 'fill-yellow-400' : ''}`} />
</button>
<button onClick={toggleCustomization}
title={player.allow_player_customization ? 'Player can edit — click to lock' : 'Player editing locked'}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border transition-all ${
player.allow_player_customization
? 'bg-green-900/30 border-green-600/30 text-green-400'
: 'border-slate-600 text-gray-600 hover:text-gray-400'
}`}>
{player.allow_player_customization ? <ToggleRight className="w-3.5 h-3.5" /> : <ToggleLeft className="w-3.5 h-3.5" />}
</button>
<button onClick={onHistory}
title="Audit History"
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border border-slate-600 text-gray-400 hover:text-white hover:border-slate-500 transition-all">
<History className="w-3.5 h-3.5" />
</button>
<button onClick={onEdit}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-semibold bg-blue-900/20 border border-blue-600/30 text-blue-400 hover:bg-blue-900/40 transition-all">
<Edit className="w-3 h-3" /> Edit
</button>
{slug && (
<a href={`/player/${slug}`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border border-slate-600 text-gray-400 hover:text-white transition-all">
<ExternalLink className="w-3 h-3" />
</a>
)}
<button onClick={onDelete}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border border-red-900/30 text-red-600 hover:bg-red-900/20 transition-all">
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{/* PII Row — visible only in admin */}
<div className="mt-2 pt-2 border-t border-white/[0.04] flex flex-wrap gap-x-4 gap-y-1">
{player.email && (
<span className="text-[11px] text-gray-500">📧 {player.email}</span>
)}
{player.phone && (
<span className="text-[11px] text-gray-500">📞 {player.phone}</span>
)}
{player.date_of_birth && (
<span className="text-[11px] text-gray-500">🎂 {player.date_of_birth}</span>
)}
{player.home_address && (
<span className="text-[11px] text-gray-500">📍 {player.home_address}{player.address_city ? `, ${player.address_city}` : ''}{player.address_state ? `, ${player.address_state}` : ''}</span>
)}
{player.gpa && (
<span className="text-[11px] text-gray-500">GPA: {player.gpa}</span>
)}
{player.class_year && (
<span className="text-[11px] text-gray-500">Class: {player.class_year}</span>
)}
</div>
</div>
);
}src/components/admin/AdminStatGame.jsx import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, BarChart3, Play, CheckCircle, Clock, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
function NewGameModal({ teams, onSave, onClose }) {
const [form, setForm] = useState({
team_id: teams[0]?.id || '',
opponent: '',
game_date: new Date().toISOString().slice(0, 16),
location: '',
home_away: 'home',
});
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-slate-900 rounded-2xl border border-slate-700 w-full max-w-md p-6">
<h2 className="text-lg font-bold text-white mb-5">New Game</h2>
<div className="space-y-4">
<div>
<label className="text-xs text-slate-400 mb-1 block">Team *</label>
<select value={form.team_id} onChange={e => setForm({ ...form, team_id: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 text-white rounded-md px-3 py-2 text-sm">
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Opponent *</label>
<Input value={form.opponent} onChange={e => setForm({ ...form, opponent: e.target.value })}
className="bg-slate-800 border-slate-700 text-white" placeholder="Opponent name" />
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Date & Time</label>
<Input type="datetime-local" value={form.game_date}
onChange={e => setForm({ ...form, game_date: e.target.value })}
className="bg-slate-800 border-slate-700 text-white" />
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Location</label>
<Input value={form.location} onChange={e => setForm({ ...form, location: e.target.value })}
className="bg-slate-800 border-slate-700 text-white" placeholder="Gym / Arena" />
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Home / Away</label>
<select value={form.home_away} onChange={e => setForm({ ...form, home_away: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 text-white rounded-md px-3 py-2 text-sm">
<option value="home">Home</option>
<option value="away">Away</option>
<option value="neutral">Neutral</option>
</select>
</div>
</div>
<div className="flex gap-3 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1 text-slate-400">Cancel</Button>
<Button onClick={() => onSave(form)} className="flex-1 bg-green-500 hover:bg-green-600"
disabled={!form.team_id || !form.opponent}>Create Game</Button>
</div>
</div>
</div>
);
}
const STATUS_CONFIG = {
pending: { label: 'Pending', color: 'bg-slate-500', icon: Clock },
tagging: { label: 'Tagging', color: 'bg-orange-500', icon: Play },
complete: { label: 'Complete', color: 'bg-green-500', icon: CheckCircle },
};
export default function AdminStatGame() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [showNewGame, setShowNewGame] = useState(false);
const { data: teams = [] } = useQuery({ queryKey: ['hoop-teams'], queryFn: () => base44.entities.HoopTeam.list() });
const { data: games = [] } = useQuery({ queryKey: ['hoop-games'], queryFn: () => base44.entities.HoopGame.list('-game_date', 50) });
const createMutation = useMutation({
mutationFn: (data) => base44.entities.HoopGame.create({ ...data, status: 'pending' }),
onSuccess: () => { queryClient.invalidateQueries(['hoop-games']); setShowNewGame(false); },
});
const deleteMutation = useMutation({
mutationFn: (id) => base44.entities.HoopGame.delete(id),
onSuccess: () => queryClient.invalidateQueries(['hoop-games']),
});
const getTeam = (teamId) => teams.find(t => t.id === teamId);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-black text-white">Games</h1>
<Button onClick={() => setShowNewGame(true)} className="bg-green-500 hover:bg-green-600 gap-2">
<Plus className="w-4 h-4" /> New Game
</Button>
</div>
<div className="space-y-3">
{games.map(game => {
const team = getTeam(game.team_id);
const cfg = STATUS_CONFIG[game.status] || STATUS_CONFIG.pending;
const Icon = cfg.icon;
return (
<div key={game.id} className="bg-slate-900 rounded-xl border border-slate-800 p-4 flex items-center gap-4 hover:border-slate-600 transition-all">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Badge className={`${cfg.color} text-white text-[10px] px-2`}>{cfg.label}</Badge>
<span className="text-xs text-slate-500">
{game.game_date ? format(new Date(game.game_date), 'MMM d, yyyy · h:mm a') : '—'}
</span>
</div>
<p className="text-white font-bold">{team?.name || 'Team'} vs {game.opponent}</p>
<p className="text-slate-500 text-xs mt-0.5">{game.home_away?.toUpperCase()} · {game.location || 'No location'}</p>
{game.status === 'complete' && (
<p className="text-green-400 text-xs mt-1 font-semibold">Final: {game.final_score_us} – {game.final_score_opponent}</p>
)}
</div>
<div className="flex gap-2">
<Button size="sm"
onClick={() => navigate(`/hoop-tagging?id=${game.id}`)}
className={`text-xs ${game.status === 'pending' ? 'bg-green-500 hover:bg-green-600' : 'bg-slate-700 hover:bg-slate-600'}`}>
<Icon className="w-3.5 h-3.5 mr-1" />
{game.status === 'pending' ? 'Start Tagging' : game.status === 'tagging' ? 'Continue' : 'View'}
</Button>
<button onClick={() => deleteMutation.mutate(game.id)}
className="p-2 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-all">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
{games.length === 0 && (
<div className="bg-slate-900/50 rounded-xl border border-dashed border-slate-700 p-12 text-center">
<BarChart3 className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<p className="text-slate-400 mb-4">No games yet</p>
<Button onClick={() => setShowNewGame(true)} className="bg-green-500 hover:bg-green-600">Create First Game</Button>
</div>
)}
</div>
{showNewGame && <NewGameModal teams={teams} onSave={d => createMutation.mutate(d)} onClose={() => setShowNewGame(false)} />}
</div>
);
}src/components/admin/AdminStats.jsx import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery } from '@tanstack/react-query';
import { BarChart3, TrendingUp, Users, Trophy } from 'lucide-react';
export default function AdminStats() {
const { data: teams = [] } = useQuery({ queryKey: ['hoop-teams'], queryFn: () => base44.entities.HoopTeam.list() });
const { data: games = [] } = useQuery({ queryKey: ['hoop-games'], queryFn: () => base44.entities.HoopGame.list() });
const { data: players = [] } = useQuery({ queryKey: ['all-players'], queryFn: () => base44.entities.Player.list() });
const { data: events = [] } = useQuery({ queryKey: ['all-events'], queryFn: () => base44.entities.HoopEvent.list() });
const [selectedTeam, setSelectedTeam] = useState('all');
const filteredGames = selectedTeam === 'all' ? games : games.filter(g => g.team_id === selectedTeam);
const completeGames = filteredGames.filter(g => g.status === 'complete');
const wins = completeGames.filter(g => g.final_score_us > g.final_score_opponent).length;
const totalPts = completeGames.reduce((s, g) => s + (g.final_score_us || 0), 0);
const avgPpg = completeGames.length ? (totalPts / completeGames.length).toFixed(1) : '—';
const teamPlayers = selectedTeam === 'all' ? players : players.filter(p => p.team_id === selectedTeam);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-black text-white">Stats & Analytics</h1>
<select value={selectedTeam} onChange={e => setSelectedTeam(e.target.value)}
className="bg-slate-800 border border-slate-700 text-white rounded-lg px-3 py-2 text-sm">
<option value="all">All Teams</option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{[
{ icon: Trophy, label: 'Win/Loss', value: `${wins}-${completeGames.length - wins}`, color: 'text-yellow-400', bg: 'from-yellow-500/20 to-yellow-600/10' },
{ icon: BarChart3, label: 'Avg PPG', value: avgPpg, color: 'text-green-400', bg: 'from-green-500/20 to-green-600/10' },
{ icon: Users, label: 'Players', value: teamPlayers.length, color: 'text-blue-400', bg: 'from-blue-500/20 to-blue-600/10' },
{ icon: TrendingUp, label: 'Events Tagged', value: events.length, color: 'text-orange-400', bg: 'from-orange-500/20 to-orange-600/10' },
].map(card => {
const Icon = card.icon;
return (
<div key={card.label} className={`bg-gradient-to-br ${card.bg} rounded-xl border border-slate-800 p-5`}>
<Icon className={`w-5 h-5 ${card.color} mb-3`} />
<p className={`text-2xl font-black ${card.color}`}>{card.value}</p>
<p className="text-slate-400 text-xs mt-1">{card.label}</p>
</div>
);
})}
</div>
{/* Player stats table */}
<div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div className="px-5 py-4 border-b border-slate-800">
<h2 className="text-white font-bold">Player Season Averages</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-800/50">
{['Player', 'Team', 'Pos', 'PPG', 'RPG', 'APG', 'SPG', 'BPG', 'Games'].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs text-slate-400 font-semibold uppercase tracking-wider whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{teamPlayers.map((p, i) => {
const team = teams.find(t => t.id === p.team_id);
return (
<tr key={p.id} className={`${i % 2 === 0 ? '' : 'bg-slate-800/20'} hover:bg-slate-800/40 transition-colors`}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{p.profile_photo_url ? (
<img src={p.profile_photo_url} className="w-7 h-7 rounded-full object-cover" alt="" />
) : (
<div className="w-7 h-7 rounded-full bg-slate-700 flex items-center justify-center text-xs text-slate-300 font-bold">
{p.full_name?.[0] || '?'}
</div>
)}
<span className="text-white font-semibold whitespace-nowrap">{p.full_name}</span>
</div>
</td>
<td className="px-4 py-3 text-slate-400 whitespace-nowrap">{team?.name || '—'}</td>
<td className="px-4 py-3 text-slate-400">{p.position || '—'}</td>
<td className="px-4 py-3 text-green-400 font-bold">{p.career_ppg ?? '—'}</td>
<td className="px-4 py-3 text-white">{p.career_rpg ?? '—'}</td>
<td className="px-4 py-3 text-white">{p.career_apg ?? '—'}</td>
<td className="px-4 py-3 text-white">{p.career_spg ?? '—'}</td>
<td className="px-4 py-3 text-white">{p.career_bpg ?? '—'}</td>
<td className="px-4 py-3 text-slate-400">{p.career_games_played ?? '—'}</td>
</tr>
);
})}
{teamPlayers.length === 0 && (
<tr><td colSpan={9} className="px-4 py-12 text-center text-slate-500">No players found</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}src/components/admin/AdminTeamSiteDashboard.jsx import { useState } from 'react';
import { Search, Loader2 } from 'lucide-react';
import { base44 } from '@/api/base44Client';
import useGeorgiaTeamData from '@/hooks/useGeorgiaTeamData';
import TeamCard from './dashboard/TeamCard';
import GameModal from './dashboard/GameModal';
import AssignCoachModal from './dashboard/AssignCoachModal';
import { ALL_GEORGIA_TEAMS, teamToSlug } from './dashboard/constants';
export default function AdminTeamSiteDashboard() {
const { games, coachProfiles, claimedTeams, lockedTeams, isLoading, queryClient } = useGeorgiaTeamData();
const [search, setSearch] = useState('');
const [modalGame, setModalGame] = useState(null);
const [expandedTeam, setExpandedTeam] = useState(null);
const [togglingLock, setTogglingLock] = useState(null);
const [assignCoachTeam, setAssignCoachTeam] = useState(null);
const [assigningCoach, setAssigningCoach] = useState(false);
// Build unique sorted team list with priority order
const allTeamsSet = new Set([
...ALL_GEORGIA_TEAMS,
...games.flatMap(g => [g.team1, g.team2].filter(Boolean))
]);
const priority = ['milton', 'greater-atlanta-christian', 'dacula'];
const allTeams = Array.from(allTeamsSet).sort((a, b) => {
const aSlug = teamToSlug(a);
const bSlug = teamToSlug(b);
const aIdx = priority.indexOf(aSlug);
const bIdx = priority.indexOf(bSlug);
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
if (aIdx !== -1) return -1;
if (bIdx !== -1) return 1;
return a.localeCompare(b);
});
const filteredTeams = allTeams.filter(t => t.toLowerCase().includes(search.toLowerCase()));
const handleSaveGame = async (data) => {
if (modalGame?.id) {
await base44.entities.GeorgiaGame.update(modalGame.id, data);
} else {
await base44.entities.GeorgiaGame.create(data);
}
queryClient.invalidateQueries({ queryKey: ['georgia-games'] });
setModalGame(null);
};
const toggleLock = async (slug) => {
setTogglingLock(slug);
try {
const isLocked = lockedTeams.includes(slug);
await base44.functions.invoke('toggleTeamLock', { slug, lock: !isLocked });
queryClient.invalidateQueries({ queryKey: ['team-claims'] });
} catch (e) {
alert('Error: ' + e.message);
}
setTogglingLock(null);
};
const assignCoachToTeam = async (coach) => {
if (!assignCoachTeam) return;
setAssigningCoach(true);
try {
await base44.entities.CoachProfile.update(coach.id, { high_school: assignCoachTeam });
queryClient.invalidateQueries({ queryKey: ['coach-profiles'] });
setAssignCoachTeam(null);
} catch (e) {
alert('Error assigning coach: ' + e.message);
}
setAssigningCoach(false);
};
const createAndAssignCoach = async (newCoachData) => {
if (!assignCoachTeam || !newCoachData.coach_name || !newCoachData.email) {
alert('Coach name and email are required');
return;
}
setAssigningCoach(true);
try {
await base44.entities.CoachProfile.create({
...newCoachData,
high_school: assignCoachTeam,
is_verified: false,
has_paid: false
});
queryClient.invalidateQueries({ queryKey: ['coach-profiles'] });
setAssignCoachTeam(null);
} catch (e) {
alert('Error creating coach: ' + e.message);
}
setAssigningCoach(false);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-orange-500" />
</div>
);
}
// Filter teams: show only claimed ones, with priority order
const claimedTeamNames = claimedTeams
.map(slug => {
const found = allTeams.find(t => teamToSlug(t) === slug);
return found || slug;
})
.sort((a, b) => {
const aSlug = teamToSlug(a);
const bSlug = teamToSlug(b);
const aIdx = priority.indexOf(aSlug);
const bIdx = priority.indexOf(bSlug);
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
if (aIdx !== -1) return -1;
if (bIdx !== -1) return 1;
return a.localeCompare(b);
});
const filteredClaimedTeams = claimedTeamNames.filter(t =>
t.toLowerCase().includes(search.toLowerCase())
);
const claimedCount = claimedTeams.length;
return (
<div className="flex flex-col h-full" style={{ minHeight: 600 }}>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-black text-white">Team Sites</h2>
<div className="flex items-center gap-3 mt-1 flex-wrap">
<span className="text-gray-500 text-xs">{claimedCount} claimed</span>
<span className="text-green-400 text-xs">✓ {claimedTeams.filter(s => !lockedTeams.includes(s)).length} unlocked</span>
<span className="text-yellow-500 text-xs">🔒 {lockedTeams.length} locked</span>
<span className="text-blue-400 text-xs">{allTeams.length} total teams</span>
</div>
</div>
</div>
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-600" />
<input type="text" placeholder="Search all teams..." value={search} onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-3 rounded-xl text-sm text-white bg-white/5 border border-white/10 focus:outline-none focus:border-orange-500/50 placeholder-slate-600" />
</div>
<div className="space-y-3 overflow-y-auto pr-2">
{filteredTeams.length === 0 ? (
<div className="text-center py-12 text-gray-600">
<p className="text-lg">No teams found</p>
<p className="text-sm mt-1">{search ? 'Try adjusting your search' : 'Teams will appear here'}</p>
</div>
) : (
filteredTeams.map(team => {
const slug = teamToSlug(team);
const teamGames = games
.filter(g => teamToSlug(g.team1) === slug || teamToSlug(g.team2) === slug)
.sort((a, b) => new Date(a.date) - new Date(b.date));
const teamCoaches = coachProfiles.filter(c => c.high_school?.toLowerCase() === team.toLowerCase());
const isClaimed = claimedTeams.includes(slug);
const isLocked = isClaimed ? lockedTeams.includes(slug) : true;
return (
<TeamCard
key={slug}
team={team}
slug={slug}
isLocked={isLocked}
isClaimed={isClaimed}
teamGames={teamGames}
teamCoaches={teamCoaches}
isExpanded={expandedTeam === slug}
onToggleExpand={() => setExpandedTeam(expandedTeam === slug ? null : slug)}
togglingLock={togglingLock === slug}
onToggleLock={() => toggleLock(slug)}
onEditGame={setModalGame}
onAddGame={() => setModalGame({ team1: team, team2: '' })}
onAssignCoach={() => setAssignCoachTeam(team)}
/>
);
})
)}
</div>
{modalGame !== null && (
<GameModal game={modalGame} onSave={handleSaveGame} onClose={() => setModalGame(null)} />
)}
{assignCoachTeam && (
<AssignCoachModal
teamName={assignCoachTeam}
coachProfiles={coachProfiles}
onAssign={assignCoachToTeam}
onCreate={createAndAssignCoach}
onClose={() => setAssignCoachTeam(null)}
assigning={assigningCoach}
/>
)}
</div>
);
}src/components/admin/AdminTeamSitePortfolios.jsx import { useState, useEffect } from 'react';
import { Search, Loader2, Plus, Edit2, Trash2, Eye, X } from 'lucide-react';
import { base44 } from '@/api/base44Client';
import { useQuery } from '@tanstack/react-query';
export default function AdminTeamSitePortfolios() {
const [selectedTeamId, setSelectedTeamId] = useState(null);
const [search, setSearch] = useState('');
const [editingPlayer, setEditingPlayer] = useState(null);
const [showAddForm, setShowAddForm] = useState(false);
const [editForm, setEditForm] = useState({});
// Portfolio search modal state
const [showPortfolioModal, setShowPortfolioModal] = useState(false);
const [portfolioSearch, setPortfolioSearch] = useState('');
const [addingPortfolioId, setAddingPortfolioId] = useState(null);
const [addingAll, setAddingAll] = useState(false);
const handleAddAll = async () => {
if (!selectedTeamId || filteredPortfolios.length === 0) return;
setAddingAll(true);
try {
for (const portfolio of filteredPortfolios) {
let height = '';
if (portfolio.height_inches) {
const ft = Math.floor(portfolio.height_inches / 12);
const inches = portfolio.height_inches % 12;
height = `${ft}'${inches}"`;
}
await base44.entities.HoopPlayer.create({
team_id: selectedTeamId,
full_name: portfolio.full_name,
position: portfolio.position || '',
height: height,
class_year: portfolio.class_year || '',
photo_url: portfolio.profile_photo_url || '',
portfolio_url_slug: portfolio.portfolio_url_slug || '',
high_school: portfolio.high_school || '',
});
}
refetchPlayers();
setShowPortfolioModal(false);
setPortfolioSearch('');
} catch (e) {
alert('Error adding all players: ' + e.message);
} finally {
setAddingAll(false);
}
};
// Fetch only published Player portfolios (from /players)
const { data: allPortfolios = [] } = useQuery({
queryKey: ['all-portfolios-for-roster'],
queryFn: () => base44.entities.Player.filter({ is_published: true }),
});
const filteredPortfolios = allPortfolios.filter(p => {
const q = portfolioSearch.toLowerCase();
return (
p.full_name?.toLowerCase().includes(q) ||
p.current_team_name?.toLowerCase().includes(q) ||
p.aau_team?.toLowerCase().includes(q) ||
p.high_school?.toLowerCase().includes(q)
);
});
const handleAddFromPortfolio = async (portfolio) => {
if (!selectedTeamId) return;
setAddingPortfolioId(portfolio.id);
try {
// Build height string from inches if available
let height = '';
if (portfolio.height_inches) {
const ft = Math.floor(portfolio.height_inches / 12);
const inches = portfolio.height_inches % 12;
height = `${ft}'${inches}"`;
}
await base44.entities.HoopPlayer.create({
team_id: selectedTeamId,
full_name: portfolio.full_name,
position: portfolio.position || '',
height: height,
class_year: portfolio.class_year || '',
photo_url: portfolio.profile_photo_url || '',
portfolio_url_slug: portfolio.portfolio_url_slug || '',
high_school: portfolio.high_school || '',
});
refetchPlayers();
} catch (e) {
alert('Error adding player: ' + e.message);
} finally {
setAddingPortfolioId(null);
}
};
// Fetch all HoopTeams (team sites)
const { data: teams = [], isLoading: teamsLoading } = useQuery({
queryKey: ['hoop-teams'],
queryFn: async () => {
const result = await base44.entities.HoopTeam.list();
return result;
}
});
// Fetch players for selected team
const { data: players = [], isLoading: playersLoading, refetch: refetchPlayers } = useQuery({
queryKey: ['hoop-players', selectedTeamId],
queryFn: async () => {
if (!selectedTeamId) return [];
const result = await base44.entities.HoopPlayer.filter({ team_id: selectedTeamId });
return result;
},
enabled: !!selectedTeamId
});
const filteredPlayers = players.filter(p =>
p.full_name.toLowerCase().includes(search.toLowerCase())
);
const handleEditPlayer = (player) => {
setEditingPlayer(player.id);
setEditForm({
full_name: player.full_name,
jersey_number: player.jersey_number || '',
position: player.position || '',
height: player.height || '',
class_year: player.class_year || '',
photo_url: player.photo_url || ''
});
};
const handleSavePlayer = async () => {
try {
await base44.entities.HoopPlayer.update(editingPlayer, editForm);
refetchPlayers();
setEditingPlayer(null);
setEditForm({});
} catch (e) {
alert('Error saving player: ' + e.message);
}
};
const handleDeletePlayer = async (playerId) => {
if (!confirm('Delete this player?')) return;
try {
await base44.entities.HoopPlayer.delete(playerId);
refetchPlayers();
} catch (e) {
alert('Error deleting player: ' + e.message);
}
};
const handleAddPlayer = async (e) => {
e.preventDefault();
if (!selectedTeamId) {
alert('Please select a team first');
return;
}
try {
await base44.entities.HoopPlayer.create({
team_id: selectedTeamId,
full_name: editForm.full_name,
jersey_number: editForm.jersey_number || null,
position: editForm.position || '',
height: editForm.height || '',
class_year: editForm.class_year || '',
photo_url: editForm.photo_url || ''
});
refetchPlayers();
setShowAddForm(false);
setEditForm({});
} catch (e) {
alert('Error adding player: ' + e.message);
}
};
if (teamsLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-orange-500" />
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="mb-6">
<h2 className="text-2xl font-black text-white mb-4">Team Site Portfolios</h2>
<div className="grid lg:grid-cols-2 gap-6">
{/* Teams List */}
<div>
<h3 className="text-lg font-semibold text-white mb-3">Select Team</h3>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-600" />
<input
type="text"
placeholder="Search teams..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-lg text-sm text-white bg-white/5 border border-white/10 focus:outline-none focus:border-orange-500/50"
/>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{teams.map(team => (
<button
key={team.id}
onClick={() => setSelectedTeamId(team.id)}
className={`w-full text-left px-4 py-3 rounded-lg font-semibold transition-all ${
selectedTeamId === team.id
? 'bg-orange-500 text-white'
: 'bg-white/5 text-slate-300 hover:bg-white/10'
}`}
>
<div>{team.name}</div>
<div className="text-xs opacity-70">{team.season || 'No season'}</div>
</button>
))}
</div>
</div>
{/* Players List */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-white">
{selectedTeamId ? `Players (${filteredPlayers.length})` : 'Select a team'}
</h3>
{selectedTeamId && (
<button
onClick={() => { setShowPortfolioModal(true); setPortfolioSearch(''); }}
className="flex items-center gap-2 px-3 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg text-sm font-semibold transition-all"
>
<Plus className="w-4 h-4" />
Add Player
</button>
)}
</div>
{showAddForm && selectedTeamId && (
<form onSubmit={handleAddPlayer} className="bg-white/5 border border-white/10 rounded-lg p-4 mb-3">
<input
type="text"
placeholder="Full Name *"
required
value={editForm.full_name || ''}
onChange={(e) => setEditForm({ ...editForm, full_name: e.target.value })}
className="w-full mb-2 px-3 py-2 rounded-lg text-sm bg-white/10 border border-white/10 text-white placeholder-slate-500 focus:outline-none"
/>
<input
type="number"
placeholder="Jersey #"
value={editForm.jersey_number || ''}
onChange={(e) => setEditForm({ ...editForm, jersey_number: e.target.value })}
className="w-full mb-2 px-3 py-2 rounded-lg text-sm bg-white/10 border border-white/10 text-white placeholder-slate-500 focus:outline-none"
/>
<select
value={editForm.position || ''}
onChange={(e) => setEditForm({ ...editForm, position: e.target.value })}
className="w-full mb-2 px-3 py-2 rounded-lg text-sm bg-white/10 border border-white/10 text-white focus:outline-none"
>
<option value="">Position</option>
{['PG', 'SG', 'SF', 'PF', 'C', 'G', 'F'].map(pos => (
<option key={pos} value={pos}>{pos}</option>
))}
</select>
<input
type="text"
placeholder="Height"
value={editForm.height || ''}
onChange={(e) => setEditForm({ ...editForm, height: e.target.value })}
className="w-full mb-2 px-3 py-2 rounded-lg text-sm bg-white/10 border border-white/10 text-white placeholder-slate-500 focus:outline-none"
/>
<input
type="text"
placeholder="Photo URL"
value={editForm.photo_url || ''}
onChange={(e) => setEditForm({ ...editForm, photo_url: e.target.value })}
className="w-full mb-3 px-3 py-2 rounded-lg text-sm bg-white/10 border border-white/10 text-white placeholder-slate-500 focus:outline-none"
/>
<div className="flex gap-2">
<button
type="submit"
className="flex-1 px-3 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-semibold transition-all"
>
Add
</button>
<button
type="button"
onClick={() => {
setShowAddForm(false);
setEditForm({});
}}
className="flex-1 px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm font-semibold transition-all"
>
Cancel
</button>
</div>
</form>
)}
<div className="space-y-2 max-h-96 overflow-y-auto">
{filteredPlayers.length === 0 ? (
<div className="text-center py-6 text-slate-500">
{selectedTeamId ? 'No players yet' : 'Select a team to view players'}
</div>
) : (
filteredPlayers.map(player => (
<div key={player.id} className="bg-white/5 border border-white/10 rounded-lg p-3">
{editingPlayer === player.id ? (
<form onSubmit={(e) => { e.preventDefault(); handleSavePlayer(); }} className="space-y-2">
<input
type="text"
value={editForm.full_name || ''}
onChange={(e) => setEditForm({ ...editForm, full_name: e.target.value })}
className="w-full px-2 py-1 rounded text-sm bg-white/10 border border-white/10 text-white focus:outline-none"
/>
<input
type="number"
value={editForm.jersey_number || ''}
onChange={(e) => setEditForm({ ...editForm, jersey_number: e.target.value })}
placeholder="Jersey #"
className="w-full px-2 py-1 rounded text-sm bg-white/10 border border-white/10 text-white placeholder-slate-500 focus:outline-none"
/>
<select
value={editForm.position || ''}
onChange={(e) => setEditForm({ ...editForm, position: e.target.value })}
className="w-full px-2 py-1 rounded text-sm bg-white/10 border border-white/10 text-white focus:outline-none"
>
<option value="">Position</option>
{['PG', 'SG', 'SF', 'PF', 'C', 'G', 'F'].map(pos => (
<option key={pos} value={pos}>{pos}</option>
))}
</select>
<div className="flex gap-2">
<button
type="submit"
className="flex-1 px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-xs font-semibold transition-all"
>
Save
</button>
<button
type="button"
onClick={() => setEditingPlayer(null)}
className="flex-1 px-2 py-1 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs font-semibold transition-all"
>
Cancel
</button>
</div>
</form>
) : (
<div>
<div className="flex items-start justify-between">
<div>
<div className="font-semibold text-white">
{player.jersey_number ? `#${player.jersey_number}` : ''} {player.full_name}
</div>
<div className="text-xs text-slate-400">
{player.position && <span>{player.position}</span>}
{player.height && <span> • {player.height}</span>}
{player.high_school && <span> • {player.high_school}</span>}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => handleEditPlayer(player)}
className="p-1.5 hover:bg-blue-500/20 text-blue-400 rounded transition-all"
title="Edit"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeletePlayer(player.id)}
className="p-1.5 hover:bg-red-500/20 text-red-400 rounded transition-all"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
</div>
))
)}
</div>
</div>
</div>
</div>
{/* Portfolio Search Modal */}
{showPortfolioModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-slate-900 rounded-2xl border border-slate-700 w-full max-w-lg p-6 flex flex-col max-h-[80vh]">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-white">Search Player Portfolios</h2>
<div className="flex items-center gap-2">
{filteredPortfolios.length > 0 && (
<button
onClick={handleAddAll}
disabled={addingAll}
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded-lg text-xs font-semibold transition-all disabled:opacity-50"
>
{addingAll ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
Add All ({filteredPortfolios.length})
</button>
)}
<button onClick={() => setShowPortfolioModal(false)} className="text-slate-400 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="text"
autoFocus
placeholder="Search by name or team..."
value={portfolioSearch}
onChange={e => setPortfolioSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-lg text-sm text-white bg-white/5 border border-white/10 focus:outline-none focus:border-orange-500/50"
/>
</div>
<div className="overflow-y-auto flex-1 space-y-2">
{filteredPortfolios.length === 0 ? (
<p className="text-slate-500 text-sm text-center py-6">No portfolios found</p>
) : (
filteredPortfolios.map(p => (
<div key={p.id} className="flex items-center justify-between bg-white/5 border border-white/10 rounded-lg px-4 py-3">
<div className="flex items-center gap-3">
{p.profile_photo_url && (
<img src={p.profile_photo_url} alt={p.full_name} className="w-8 h-8 rounded-full object-cover" />
)}
<div>
<div className="text-white font-semibold text-sm">{p.full_name}</div>
<div className="text-slate-400 text-xs">
{p.current_team_name && <span>{p.current_team_name}</span>}
{p.high_school && !p.current_team_name && <span>{p.high_school}</span>}
{p.position && <span> · {p.position}</span>}
{p.class_year && <span> · {p.class_year}</span>}
</div>
</div>
</div>
<button
onClick={() => handleAddFromPortfolio(p)}
disabled={addingPortfolioId === p.id}
className="flex items-center gap-1 px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white rounded-lg text-xs font-semibold transition-all disabled:opacity-50"
>
{addingPortfolioId === p.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
Add
</button>
</div>
))
)}
</div>
</div>
</div>
)}
</div>
);
}src/components/admin/AdminTeams.jsx import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Users, Trash2, Edit2, X, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
function TeamForm({ team, onSave, onClose }) {
const [form, setForm] = useState(team || {
name: '', season: new Date().getFullYear() + '-' + (new Date().getFullYear() + 1),
level: 'high_school', organization: '', logo_url: ''
});
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-slate-900 rounded-2xl border border-slate-700 w-full max-w-md p-6">
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-bold text-white">{team ? 'Edit Team' : 'New Team'}</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-4">
<div>
<label className="text-xs text-slate-400 mb-1 block">Team Name *</label>
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="bg-slate-800 border-slate-700 text-white" placeholder="e.g. Colorado Mambas" />
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Season *</label>
<Input value={form.season} onChange={e => setForm({ ...form, season: e.target.value })}
className="bg-slate-800 border-slate-700 text-white" placeholder="e.g. 2024-25" />
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Level</label>
<select value={form.level} onChange={e => setForm({ ...form, level: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 text-white rounded-md px-3 py-2 text-sm">
<option value="high_school">High School</option>
<option value="aau">AAU</option>
<option value="club">Club</option>
<option value="small_college">Small College</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Organization / School</label>
<Input value={form.organization} onChange={e => setForm({ ...form, organization: e.target.value })}
className="bg-slate-800 border-slate-700 text-white" placeholder="e.g. Jefferson High School" />
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Logo URL (optional)</label>
<Input value={form.logo_url} onChange={e => setForm({ ...form, logo_url: e.target.value })}
className="bg-slate-800 border-slate-700 text-white" placeholder="https://..." />
</div>
</div>
<div className="flex gap-3 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1 text-slate-400">Cancel</Button>
<Button onClick={() => onSave(form)} className="flex-1 bg-orange-500 hover:bg-orange-600"
disabled={!form.name || !form.season}>
{team ? 'Save Changes' : 'Create Team'}
</Button>
</div>
</div>
</div>
);
}
export default function AdminTeams() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingTeam, setEditingTeam] = useState(null);
const { data: teams = [] } = useQuery({
queryKey: ['hoop-teams'],
queryFn: () => base44.entities.HoopTeam.list(),
});
const { data: players = [] } = useQuery({
queryKey: ['all-players'],
queryFn: () => base44.entities.Player.list(),
});
const saveMutation = useMutation({
mutationFn: async (data) => {
if (editingTeam) return base44.entities.HoopTeam.update(editingTeam.id, data);
return base44.entities.HoopTeam.create(data);
},
onSuccess: () => { queryClient.invalidateQueries(['hoop-teams']); setShowForm(false); setEditingTeam(null); }
});
const deleteMutation = useMutation({
mutationFn: (id) => base44.entities.HoopTeam.delete(id),
onSuccess: () => queryClient.invalidateQueries(['hoop-teams']),
});
const handleSave = (data) => saveMutation.mutate(data);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-black text-white">Teams</h1>
<Button onClick={() => { setEditingTeam(null); setShowForm(true); }}
className="bg-orange-500 hover:bg-orange-600 gap-2">
<Plus className="w-4 h-4" /> New Team
</Button>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{teams.map(team => {
const teamPlayers = players.filter(p => p.team_id === team.id);
return (
<div key={team.id} className="bg-slate-900 rounded-xl border border-slate-800 p-5 hover:border-slate-600 transition-all">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
{team.logo_url ? (
<img src={team.logo_url} alt={team.name} className="w-10 h-10 rounded-lg object-cover" />
) : (
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center">
<Users className="w-5 h-5 text-white" />
</div>
)}
<div>
<p className="text-white font-bold">{team.name}</p>
<p className="text-slate-400 text-xs">{team.season} · {team.level?.replace('_', ' ')}</p>
</div>
</div>
<div className="flex gap-1">
<button onClick={() => { setEditingTeam(team); setShowForm(true); }}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-all">
<Edit2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => deleteMutation.mutate(team.id)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition-all">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{team.organization && <p className="text-slate-500 text-xs mb-2">{team.organization}</p>}
<div className="flex items-center gap-2 text-xs text-slate-400">
<Users className="w-3.5 h-3.5" />
<span>{teamPlayers.length} players</span>
</div>
</div>
);
})}
{teams.length === 0 && (
<div className="col-span-3 bg-slate-900/50 rounded-xl border border-dashed border-slate-700 p-12 text-center">
<Users className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<p className="text-slate-400 mb-4">No teams yet</p>
<Button onClick={() => setShowForm(true)} className="bg-orange-500 hover:bg-orange-600">
Create First Team
</Button>
</div>
)}
</div>
{showForm && (
<TeamForm
team={editingTeam}
onSave={handleSave}
onClose={() => { setShowForm(false); setEditingTeam(null); }}
/>
)}
</div>
);
}src/components/admin/AdminTopPlayersExport.jsx import { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { Download, Trophy, Loader2, Table } from 'lucide-react';
function csvCell(v) {
const s = String(v ?? '');
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
export default function AdminTopPlayersExport() {
const [loading, setLoading] = useState(false);
const [players, setPlayers] = useState([]);
const [error, setError] = useState('');
const fetchTop = async () => {
setLoading(true);
setError('');
try {
const all = await base44.entities.Player.filter({ career_ppg: { $gt: 0 } }, '-career_ppg', 300);
setPlayers(all);
} catch (e) {
setError(e?.message || 'Failed to load players');
} finally {
setLoading(false);
}
};
const rows = players.map((p, i) => [
i + 1,
p.full_name || '',
p.career_ppg ?? '',
p.phone || '',
p.portfolio_url_slug ? `https://goaio.live/player/${p.portfolio_url_slug}` : '',
]);
const csv = ['Rank,Full Name,PPG,Phone,Portfolio URL', ...rows.map(r => r.map(csvCell).join(','))].join('\n');
const download = () => {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'top-300-players-by-ppg.csv';
a.click();
};
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-400" /> Top 300 Players by PPG
</h2>
<p className="text-slate-400 text-sm">
{players.length > 0
? `${players.length} players loaded · top ${players[0]?.career_ppg} → ${players[players.length - 1]?.career_ppg} PPG`
: 'Loads the top 300 by career points average with phone + portfolio URL.'}
</p>
</div>
<div className="flex gap-2">
<button onClick={fetchTop} disabled={loading}
className="px-4 py-2 rounded-lg bg-orange-500 hover:bg-orange-600 text-white text-sm font-bold disabled:opacity-50 transition-all flex items-center gap-1.5">
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <Table className="w-4 h-4" />} Load Top 300
</button>
{players.length > 0 && (
<button onClick={download}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-sm font-bold transition-all">
<Download className="w-4 h-4" /> Download CSV
</button>
)}
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{players.length > 0 && (
<>
<p className="text-xs text-slate-500">
Tip: open <span className="text-slate-300">sheets.new</span> → File → Import → Upload → choose the CSV to create a Google Sheet.
</p>
<div className="overflow-auto rounded-lg border border-slate-800 max-h-[60vh]">
<table className="w-full text-xs">
<thead className="bg-slate-900 sticky top-0">
<tr className="text-slate-400 text-left">
<th className="px-3 py-2 w-12">#</th>
<th className="px-3 py-2">Player</th>
<th className="px-3 py-2">PPG</th>
<th className="px-3 py-2">Phone</th>
<th className="px-3 py-2">Portfolio URL</th>
</tr>
</thead>
<tbody>
{players.map((p, i) => (
<tr key={p.id || i} className="border-t border-slate-800 hover:bg-slate-800/50">
<td className="px-3 py-2 text-slate-500">{i + 1}</td>
<td className="px-3 py-2 text-white font-medium">{p.full_name}</td>
<td className="px-3 py-2 text-orange-400 font-bold">{p.career_ppg}</td>
<td className="px-3 py-2 text-slate-300">{p.phone || '—'}</td>
<td className="px-3 py-2 text-slate-400 truncate max-w-xs">
{p.portfolio_url_slug ? `goaio.live/player/${p.portfolio_url_slug}` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
}src/components/admin/AdminTournamentInquiries.jsx import { useQuery } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client';
import { Building2, CalendarDays, Mail, MapPin, Phone, Video } from 'lucide-react';
export default function AdminTournamentInquiries() {
const { data: inquiries = [], isLoading } = useQuery({
queryKey: ['tournament-inquiries'],
queryFn: () => base44.entities.TournamentInquiry.list('-created_date', 100),
});
if (isLoading) return <p className="text-sm text-slate-400">Loading tournament inquiries...</p>;
return (
<div>
<div className="mb-5">
<h2 className="text-xl font-black text-white">Tournament Inquiries</h2>
<p className="text-sm text-slate-400">{inquiries.length} tournament partnership submission{inquiries.length === 1 ? '' : 's'}</p>
</div>
{inquiries.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-700 py-14 text-center text-sm text-slate-400">No tournament inquiries yet.</div>
) : (
<div className="space-y-3">
{inquiries.map((inquiry) => (
<div key={inquiry.id} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-bold text-white">{inquiry.organization}</p>
<p className="mt-0.5 text-sm text-slate-400">{inquiry.contact_name}</p>
</div>
<span className="rounded-full bg-orange-500/10 px-2.5 py-1 text-xs font-semibold text-orange-400">
{inquiry.video_coverage === 'need_game_recording' ? 'Needs game recording' : 'Own video vendor'}
</span>
</div>
<div className="mt-4 grid gap-2 text-sm text-slate-400 sm:grid-cols-2 lg:grid-cols-4">
<span className="flex items-center gap-2"><Mail className="w-3.5 h-3.5" />{inquiry.email}</span>
<span className="flex items-center gap-2"><Phone className="w-3.5 h-3.5" />{inquiry.phone}</span>
<span className="flex items-center gap-2"><CalendarDays className="w-3.5 h-3.5" />{inquiry.tournament_date}</span>
<span className="flex items-center gap-2"><MapPin className="w-3.5 h-3.5" />{inquiry.city}, {inquiry.state}</span>
</div>
</div>
))}
</div>
)}
</div>
);
}src/components/admin/AdminUpdateRequests.jsx import { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { Inbox, Loader2 } from 'lucide-react';
import UpdateRequestCard from './UpdateRequestCard';
export default function AdminUpdateRequests() {
const [requests, setRequests] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
const load = async () => {
setLoading(true);
try {
const reqs = await base44.entities.UpdateRequest.list('-created_date', 200);
setRequests(reqs);
} catch (e) {
console.error('Failed to load update requests:', e);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const filtered = filter === 'all' ? requests : requests.filter(r => r.status === filter);
const counts = { submitted: 0, in_review: 0, applied: 0, rejected: 0 };
requests.forEach(r => { if (counts[r.status] !== undefined) counts[r.status]++; });
return (
<div>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<h2 className="text-xl font-black text-white flex items-center gap-2">
<Inbox className="w-5 h-5 text-orange-400" /> Update Requests
</h2>
<div className="flex gap-1 flex-wrap">
{['all', 'submitted', 'in_review', 'applied', 'rejected'].map(f => (
<button key={f} onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold capitalize ${
filter === f ? 'bg-orange-500 text-black' : 'bg-slate-800 text-slate-400 hover:text-white'
}`}>
{f === 'all' ? 'All' : f.replace('_', ' ')}{f !== 'all' && counts[f] ? ` (${counts[f]})` : ''}
</button>
))}
</div>
</div>
{loading ? (
<div className="flex justify-center py-12"><Loader2 className="w-6 h-6 text-orange-400 animate-spin" /></div>
) : filtered.length === 0 ? (
<p className="text-slate-400 text-sm py-8 text-center">No update requests found.</p>
) : (
<div className="space-y-3">{filtered.map(r => <UpdateRequestCard key={r.id} request={r} onChanged={load} />)}</div>
)}
</div>
);
}src/components/admin/AuditHistoryModal.jsx import { useState, useEffect } from 'react';
import { X, Clock, User, ChevronDown, ChevronRight } from 'lucide-react';
import { base44 } from '@/api/base44Client';
function ChangeRow({ fieldName, change }) {
const sensitiveFields = ['date_of_birth', 'home_address', 'address_city', 'address_state', 'address_zip', 'email', 'phone', 'act_score', 'sat_score'];
const isSensitive = sensitiveFields.includes(fieldName);
const label = fieldName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const format = (val) => {
if (val === null || val === undefined) return <span className="text-gray-600 italic">empty</span>;
if (typeof val === 'boolean') return val ? 'true' : 'false';
return String(val);
};
return (
<div className="flex items-start gap-3 py-2 border-b border-white/5 last:border-0">
<div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-gray-300">{label}</span>
{isSensitive && <span className="ml-1.5 text-[9px] px-1.5 py-0.5 rounded bg-yellow-900/30 border border-yellow-600/20 text-yellow-600">PII</span>}
</div>
<div className="flex items-center gap-2 text-xs shrink-0">
<span className="text-red-400 line-through max-w-[100px] truncate">{format(change.old)}</span>
<ChevronRight className="w-3 h-3 text-gray-600" />
<span className="text-green-400 max-w-[100px] truncate">{format(change.new)}</span>
</div>
</div>
);
}
function AuditEntry({ entry }) {
const [expanded, setExpanded] = useState(false);
const changeCount = Object.keys(entry.changes || {}).length;
const date = new Date(entry.created_date);
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
return (
<div className="bg-[#1a1a1a] rounded-xl border border-white/[0.06] overflow-hidden">
<button
onClick={() => setExpanded(e => !e)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-white/[0.02] transition-colors"
>
<div className="w-7 h-7 rounded-full bg-orange-900/30 border border-orange-600/25 flex items-center justify-center shrink-0">
<User className="w-3.5 h-3.5 text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white font-medium capitalize">{entry.action || 'update'}</p>
<p className="text-xs text-gray-500 truncate">by user <span className="text-gray-400 font-mono">{entry.changed_by_user_id}</span></p>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="text-right">
<p className="text-xs text-gray-400">{dateStr}</p>
<p className="text-[10px] text-gray-600">{timeStr}</p>
</div>
{changeCount > 0 && (
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-blue-900/30 border border-blue-600/20 text-blue-400">
{changeCount} field{changeCount !== 1 ? 's' : ''}
</span>
)}
{expanded ? <ChevronDown className="w-3.5 h-3.5 text-gray-500" /> : <ChevronRight className="w-3.5 h-3.5 text-gray-500" />}
</div>
</button>
{expanded && changeCount > 0 && (
<div className="px-4 pb-3 border-t border-white/[0.06]">
<div className="mt-3">
{Object.entries(entry.changes).map(([field, change]) => (
<ChangeRow key={field} fieldName={field} change={change} />
))}
</div>
</div>
)}
</div>
);
}
export default function AuditHistoryModal({ player, onClose }) {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
base44.entities.AuditLog.filter({ target_id: player.id }, '-created_date', 50)
.then(setLogs)
.finally(() => setLoading(false));
}, [player.id]);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-[#111] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[80vh] flex flex-col shadow-2xl">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<div>
<h2 className="text-white font-bold text-lg">Audit History</h2>
<p className="text-gray-500 text-xs mt-0.5">{player.full_name} — all recorded changes</p>
</div>
<button onClick={onClose} className="text-gray-500 hover:text-white p-1">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
{loading ? (
<div className="flex justify-center py-12">
<div className="w-5 h-5 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
</div>
) : logs.length === 0 ? (
<div className="text-center py-12">
<Clock className="w-8 h-8 text-gray-700 mx-auto mb-3" />
<p className="text-gray-500 text-sm">No changes recorded yet.</p>
<p className="text-gray-600 text-xs mt-1">Future edits made through the Admin Portal will appear here.</p>
</div>
) : (
logs.map(entry => <AuditEntry key={entry.id} entry={entry} />)
)}
</div>
</div>
</div>
);
}src/components/admin/CleanSlateReset.jsx import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { Shield, Trash2, AlertTriangle, Loader2, CheckCircle2, XCircle, Database, Lock, Crown, FileText } from 'lucide-react';
const ORANGE = '#FF6A00';
export default function CleanSlateReset({ onCompleted }) {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [confirmText, setConfirmText] = useState('');
const [activeView, setActiveView] = useState('deletable');
const [hasRunDryRun, setHasRunDryRun] = useState(false);
const runReset = async (isDryRun) => {
setLoading(true);
setError(null);
setResult(null);
try {
const res = await base44.functions.invoke('cleanImportAndReset', { dry_run: isDryRun });
setResult(res.data);
if (isDryRun) {
setHasRunDryRun(true);
setActiveView('deletable');
} else {
setConfirmText('');
setHasRunDryRun(false);
if (onCompleted) onCompleted();
}
} catch (err) {
setError(err?.response?.data?.error || err?.message || 'Reset failed');
}
setLoading(false);
};
const canExecute = hasRunDryRun && result && confirmText === 'DELETE' && result.deletable_count > 0 && !loading;
return (
<div className="bg-[#1a1a1a] rounded-xl border border-white/10 p-6 space-y-5">
{/* Header */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0" style={{ background: 'rgba(255,106,0,0.12)', border: '1px solid rgba(255,106,0,0.25)' }}>
<Trash2 className="w-5 h-5" style={{ color: ORANGE }} />
</div>
<div>
<h3 className="text-white font-bold text-lg leading-tight">Clean Slate Reset</h3>
<p className="text-gray-500 text-sm mt-0.5">
Deletes all stale player records while preserving published and premium portfolios.
Team sites (<span className="text-gray-400">HoopTeam</span>, <span className="text-gray-400">GeorgiaTeamSite</span>) are separate entities and are never touched.
</p>
</div>
</div>
{/* Protection criteria */}
<div className="flex items-start gap-2 p-3 rounded-lg" style={{ background: 'rgba(0,255,133,0.06)', border: '1px solid rgba(0,255,133,0.15)' }}>
<Shield className="w-4 h-4 text-green-400 shrink-0 mt-0.5" />
<div className="text-xs text-gray-400 leading-relaxed">
<span className="text-green-400 font-bold">Protected (never deleted):</span>
<ul className="mt-1 space-y-0.5 ml-3">
<li>• <span className="text-gray-300">is_published = true</span> — live public portfolios</li>
<li>• <span className="text-gray-300">portfolio_tier = premium</span> — paid/claimed portfolios</li>
<li>• <span className="text-gray-300">portfolio_tier = under_review</span> — pending activation</li>
</ul>
<p className="mt-1.5">URL slugs for protected players remain unchanged — shared links stay valid.</p>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3">
<button
onClick={() => runReset(true)}
disabled={loading}
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-lg text-sm font-bold transition-all disabled:opacity-50"
style={{ background: 'rgba(255,106,0,0.12)', border: `1px solid ${ORANGE}40`, color: ORANGE }}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Database className="w-4 h-4" />}
Run Dry Run (Preview)
</button>
</div>
{error && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
{/* Results */}
{result && (
<div className="space-y-4">
{/* Summary banner */}
<div className="bg-[#111] rounded-lg border border-white/10 p-4">
<div className="flex items-center gap-2 mb-3">
{result.dry_run ? (
<span className="px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">DRY RUN</span>
) : (
<span className="px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest bg-green-500/20 text-green-400 border border-green-500/30">EXECUTED</span>
)}
{result.import_log_id && (
<span className="text-gray-600 text-xs flex items-center gap-1">
<FileText className="w-3 h-3" /> Log: {result.import_log_id.slice(-8)}
</span>
)}
</div>
<p className="text-gray-300 text-sm mb-4">{result.summary}</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Total Players" value={result.total_players} icon={Database} color="text-white" />
<StatCard label="Protected" value={result.protected_count} icon={Shield} color="text-green-400" />
<StatCard label={result.dry_run ? 'Would Delete' : 'Deleted'} value={result.dry_run ? result.deletable_count : result.deleted_count} icon={Trash2} color="text-red-400" />
<StatCard label="Errors" value={result.errors_count} icon={XCircle} color={result.errors_count > 0 ? 'text-red-400' : 'text-gray-600'} />
</div>
</div>
{/* Tab selector */}
<div className="flex gap-2">
<TabButton active={activeView === 'deletable'} onClick={() => setActiveView('deletable')}
icon={Trash2} color="text-red-400"
label={`${result.dry_run ? 'Would Delete' : 'Deleted'} (${result.deletable_count})`} />
<TabButton active={activeView === 'protected'} onClick={() => setActiveView('protected')}
icon={Shield} color="text-green-400"
label={`Protected (${result.protected_count})`} />
</div>
{/* Tab content */}
<div className="bg-[#111] rounded-lg border border-white/10 p-4 max-h-[400px] overflow-y-auto">
{activeView === 'deletable' && (
<div className="space-y-1.5">
{result.preview?.deletable_players?.length > 0 ? result.preview.deletable_players.map((p, i) => (
<div key={i} className="flex items-center gap-3 py-1.5 px-3 rounded-lg bg-red-500/5">
<XCircle className="w-3.5 h-3.5 text-red-400 shrink-0" />
<span className="text-gray-300 text-sm flex-1 min-w-0 truncate">{p.full_name || 'Unknown'}</span>
<span className="text-gray-600 text-xs shrink-0">{p.current_team_name || p.high_school || '—'}</span>
</div>
)) : <p className="text-gray-600 text-sm text-center py-6">No deletable records.</p>}
{result.deletable_count > 50 && (
<p className="text-gray-700 text-xs text-center pt-2">Showing first 50 of {result.deletable_count}. Full list in ImportLog.</p>
)}
</div>
)}
{activeView === 'protected' && (
<div className="space-y-1.5">
{result.preview?.protected_players?.length > 0 ? result.preview.protected_players.map((p, i) => (
<div key={i} className="flex items-center gap-3 py-1.5 px-3 rounded-lg bg-green-500/5">
{p.portfolio_tier === 'premium' ? <Crown className="w-3.5 h-3.5 text-green-400 shrink-0" /> : <Lock className="w-3.5 h-3.5 text-green-400 shrink-0" />}
<span className="text-gray-300 text-sm flex-1 min-w-0 truncate">{p.full_name || 'Unknown'}</span>
<span className="text-gray-600 text-xs shrink-0">{p.reason}</span>
{p.portfolio_url_slug && <span className="text-gray-700 text-xs shrink-0 hidden sm:inline">/{p.portfolio_url_slug}</span>}
</div>
)) : <p className="text-gray-600 text-sm text-center py-6">No protected records.</p>}
{result.protected_count > 50 && (
<p className="text-gray-700 text-xs text-center pt-2">Showing first 50 of {result.protected_count}. Full list in ImportLog.</p>
)}
</div>
)}
</div>
{/* Execute confirmation (only after dry run) */}
{result.dry_run && result.deletable_count > 0 && (
<div className="bg-red-500/5 border border-red-500/20 rounded-lg p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<p className="text-red-400 text-sm">
<strong>Warning:</strong> This will permanently delete <strong>{result.deletable_count}</strong> player records.
This cannot be undone. {result.protected_count} portfolios will be preserved.
</p>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1.5">
Type <span className="text-red-400 font-bold">DELETE</span> to confirm:
</label>
<input
value={confirmText}
onChange={e => setConfirmText(e.target.value)}
placeholder="DELETE"
className="w-full bg-[#111] border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-700 focus:outline-none focus:border-red-500/40"
/>
</div>
<button
onClick={() => runReset(false)}
disabled={!canExecute}
className="w-full flex items-center justify-center gap-2 py-3 rounded-lg text-sm font-bold transition-all disabled:opacity-30 disabled:cursor-not-allowed"
style={{ background: canExecute ? '#dc2626' : '#333', color: canExecute ? '#fff' : '#666' }}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Execute Clean Slate Reset — Delete {result.deletable_count} Records
</button>
{!canExecute && confirmText !== '' && confirmText !== 'DELETE' && (
<p className="text-red-400 text-xs text-center">You must type "DELETE" exactly to proceed.</p>
)}
</div>
)}
{result.dry_run && result.deletable_count === 0 && (
<div className="bg-green-500/5 border border-green-500/20 rounded-lg p-4 flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-400 shrink-0" />
<p className="text-green-400 text-sm">All records are protected — nothing to delete. Database is already clean.</p>
</div>
)}
{!result.dry_run && result.success && (
<div className="bg-green-500/5 border border-green-500/20 rounded-lg p-4 flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-400 shrink-0" />
<p className="text-green-400 text-sm">
Clean slate complete. {result.deleted_count} records deleted, {result.protected_count} portfolios preserved.
{result.errors_count > 0 && ` ${result.errors_count} errors — check ImportLog for details.`}
</p>
</div>
)}
</div>
)}
</div>
);
}
function StatCard({ label, value, icon: Icon, color }) {
return (
<div className="bg-[#1a1a1a] rounded-lg p-3 text-center border border-white/5">
<Icon className={`w-4 h-4 mx-auto mb-1 ${color}`} />
<div className={`text-xl font-black ${color}`}>{value}</div>
<div className="text-[10px] text-gray-600 uppercase tracking-wide mt-0.5">{label}</div>
</div>
);
}
function TabButton({ active, onClick, icon: Icon, color, label }) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all
${active ? 'bg-[#333] text-white' : 'text-gray-500 hover:text-gray-300'}`}
>
<Icon className={`w-3.5 h-3.5 ${color}`} />
{label}
</button>
);
}src/components/admin/PdfStatsExtractor.jsx import { useState, useRef } from 'react';
import { base44 } from '@/api/base44Client';
import { Upload, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
const ORANGE = '#FF6A00';
const STAT_KEYS = [
'pts','ast','to','oreb','dreb','spg','bpg','two_pt_made','two_pt_att',
'three_pt_made','three_pt_att','ft_made','ft_att','shots','opps',
'efg_pct','ft_pct','two_fg_pct','three_fg_pct','ato_ratio',
'oreb_pct','ts_pct','ftf','off_ppp'
];
const STRING_STATS = ['efg_pct','ft_pct','two_fg_pct','three_fg_pct','ato_ratio','oreb_pct','ts_pct','off_ppp'];
export default function PdfStatsExtractor({ team1, team2, onStatsExtracted }) {
const [status, setStatus] = useState('idle');
const [error, setError] = useState(null);
const [dragging, setDragging] = useState(false);
const fileRef = useRef(null);
const processFile = async (file) => {
if (!file) return;
setStatus('uploading');
setError(null);
let fileUrl;
try {
const res = await base44.integrations.Core.UploadFile({ file });
fileUrl = res.file_url;
} catch (e) {
setError('Upload failed: ' + e.message);
setStatus('error');
return;
}
setStatus('extracting');
try {
const statProps = {};
STAT_KEYS.forEach(k => {
statProps[k] = STRING_STATS.includes(k) ? { type: 'string' } : { type: 'number' };
});
const result = await base44.integrations.Core.InvokeLLM({
prompt: `Analyze this basketball game statistics PDF (Hoopsalytics game-by-game format).
Find the game between "${team1}" and "${team2}". Team names may appear differently (abbreviated, with state codes like "- GA", etc.) — use fuzzy matching.
The PDF likely contains game-by-game stats for one team across multiple opponents. Identify which team the PDF covers, find the row for the game against the other team, and extract their offensive stats.
If the PDF also contains the opponent's stats for the same game, extract those too under opponent_stats.
Set "found" to false ONLY if no matching game exists in the PDF.`,
file_urls: [fileUrl],
response_json_schema: {
type: 'object',
properties: {
found: { type: 'boolean' },
pdf_team_name: { type: 'string', description: 'The team whose stats this PDF primarily covers' },
opponent_in_pdf: { type: 'string', description: 'How the opponent appears in the PDF' },
pdf_team_stats: { type: 'object', properties: statProps },
opponent_stats: { type: 'object', properties: statProps, description: 'Only if available in PDF' },
}
}
});
if (!result.found) {
setError(`No game between "${team1}" and "${team2}" found in this PDF`);
setStatus('error');
return;
}
const norm = (n) => (n || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const nPdf = norm(result.pdf_team_name);
const n1 = norm(team1);
const n2 = norm(team2);
const isPdfTeam1 = nPdf.includes(n1) || n1.includes(nPdf);
let t1Stats = {}, t2Stats = {};
if (isPdfTeam1) {
t1Stats = result.pdf_team_stats || {};
t2Stats = result.opponent_stats || {};
} else {
t2Stats = result.pdf_team_stats || {};
t1Stats = result.opponent_stats || {};
}
onStatsExtracted(t1Stats, t2Stats);
setStatus('done');
} catch (e) {
setError('Extraction failed: ' + e.message);
setStatus('error');
}
};
return (
<div
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={e => { e.preventDefault(); setDragging(false); processFile(e.dataTransfer.files?.[0]); }}
onClick={() => !['uploading','extracting'].includes(status) && fileRef.current?.click()}
className="border-2 border-dashed rounded-xl p-5 text-center cursor-pointer transition-all"
style={{
borderColor: status === 'done' ? 'rgba(0,255,133,0.3)' : status === 'error' ? 'rgba(255,59,48,0.3)' : dragging ? ORANGE : 'rgba(255,255,255,0.12)',
background: status === 'done' ? 'rgba(0,255,133,0.03)' : status === 'error' ? 'rgba(255,59,48,0.03)' : dragging ? 'rgba(255,106,0,0.05)' : 'rgba(255,255,255,0.02)'
}}
>
{status === 'idle' && <>
<Upload className="w-5 h-5 mx-auto mb-1.5 text-slate-500" />
<p className="text-white font-semibold text-xs">Drop a stat PDF to auto-fill</p>
<p className="text-slate-600 text-[10px] mt-0.5">Hoopsalytics game-by-game format</p>
</>}
{(status === 'uploading' || status === 'extracting') && <>
<Loader2 className="w-5 h-5 mx-auto mb-1.5 animate-spin text-orange-400" />
<p className="text-orange-400 font-semibold text-xs">{status === 'uploading' ? 'Uploading PDF…' : 'Extracting stats…'}</p>
</>}
{status === 'done' && <>
<CheckCircle2 className="w-5 h-5 mx-auto mb-1.5 text-green-400" />
<p className="text-green-400 font-semibold text-xs">Stats extracted — review below</p>
<p className="text-slate-600 text-[10px] mt-0.5">Drop another PDF to re-extract</p>
</>}
{status === 'error' && <>
<AlertCircle className="w-5 h-5 mx-auto mb-1.5 text-red-400" />
<p className="text-red-400 font-semibold text-xs">{error}</p>
<p className="text-slate-600 text-[10px] mt-0.5">Try again or fill manually</p>
</>}
<input ref={fileRef} type="file" accept=".pdf" className="hidden" onChange={e => processFile(e.target.files?.[0])} />
</div>
);
}src/components/admin/PdfStatsImporter.jsx import { useState, useRef } from 'react';
import { base44 } from '@/api/base44Client';
import { CheckCircle2, AlertCircle, Loader2, Play, Upload, X, FileText, Plus } from 'lucide-react';
const PRELOADED_PDFS = [
{ team: 'Alpharetta', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/f2317d93a_Alpharetta-GAAlpharetta.pdf' },
{ team: 'Apalachee', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/b7a649c43_Apalachee-GAApalachee-GA.pdf' },
{ team: 'Baldwin', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/6c2bbf8b0_Baldwin-GABaldwin-GA.pdf' },
{ team: 'Beach', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/5612924c7_Beach-GABeach-GA.pdf' },
{ team: 'Berkmar', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/80d5a4f1d_Berkmar-GA.pdf' },
{ team: 'BEST Academy', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/fd2362526_BESTAcademy-GA.pdf' },
{ team: 'Bowdon', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/dbc511061_Bowdon-GA.pdf' },
{ team: 'Bradwell Institute', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/a46e277da_BradwellInstitute-GA.pdf' },
{ team: 'Brookwood', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/292f4a13c_Brookwood-GA.pdf' },
{ team: 'Brunswick', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/b2b65f488_Brunswick-GABrunswick-GA-Incomplete.pdf' },
{ team: 'Buford', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/ee4f0414c_Buford-GA.pdf' },
{ team: 'Burke County', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/5fb6087d1_BurkeCounty-GA.pdf' },
{ team: 'Butler', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/705464a32_Butler-GA.pdf' },
{ team: 'Campbell', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/52766de6e_Campbell-GA.pdf' },
{ team: 'Carrollton', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/22fa42e58_Carrollton-GA.pdf' },
{ team: 'Cartersville', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/2882396d8_Cartersville-GA.pdf' },
{ team: 'Cedar Grove', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/861e39aad_CedarGrove-GA.pdf' },
{ team: 'Cedar Shoals', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/4acfd6815_CedarShoals-GA.pdf' },
{ team: 'Centennial', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/98008562e_Centennial-GA.pdf' },
{ team: 'Chamblee', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/978b25704_Chamblee-GA.pdf' },
{ team: 'Chapel Hill', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/8000e8104_ChapelHill-GA.pdf' },
{ team: 'Cherokee', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/545888faf_Cherokee-GA.pdf' },
{ team: 'Christian Heritage', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/6ff10f03d_ChristianHeritage-GA.pdf' },
{ team: 'Columbia', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/f431182bd_Columbia-GAColumbia.pdf' },
{ team: 'Cross Creek', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/dfcbcb148_CrossCreek-GA.pdf' },
{ team: 'Dacula', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/9363ae547_Dacula-GADacula.pdf' },
{ team: 'Darlington School', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/3aab12695_DarlingtonSchool-GA.pdf' },
{ team: 'Douglas County', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/34b7a20c2_DouglasCounty-GA.pdf' },
{ team: 'East Coweta', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/afed171ce_EastCoweta-GA.pdf' },
{ team: 'East Forsyth', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/143e58965_EastForsyth-GA.pdf' },
{ team: 'East Paulding', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/5923d634a_EastPaulding-GA.pdf' },
{ team: 'Eastside', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/13811eacb_Eastside-GA.pdf' },
{ team: 'ELCA', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/4b8b426bd_ELCA-GA.pdf' },
{ team: 'Franklin County', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/f75d6ad22_FranklinCounty-GA.pdf' },
{ team: 'Gainesville', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/c939f747b_Gainesville-GA.pdf' },
{ team: 'Alexander', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/cd5b80f6a_GamebyGameStats_AlexanderGA.pdf' },
{ team: 'Grayson', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/eaf1f2bc3_Grayson-GA.pdf' },
{ team: 'Greater Atlanta Christian', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/b0e3c395c_GreaterAtlantaChristian-GA.pdf' },
{ team: 'Habersham Central', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/36c311238_HabershamCentral-GA.pdf' },
{ team: 'Harlem', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/d52c96db9_Harlem-GA.pdf' },
{ team: 'Hebron Christian', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/c572549c5_HebronChristian-GA.pdf' },
{ team: 'Hillgrove', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/cdbfdec23_Hillgrove-GA.pdf' },
{ team: 'Holy Innocents', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/42559a2f7_HolyInnocents-GA.pdf' },
{ team: 'Jonesboro', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/8601c91af_Jonesboro-GA.pdf' },
{ team: 'Kell', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/1525bba10_Kell-GAKell.pdf' },
{ team: 'Lanier', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/78a925d04_Lanier-GA.pdf' },
{ team: 'Lassiter', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/d56d59925_Lassiter-GA.pdf' },
{ team: 'Lee County', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/7d1447caf_LeeCounty-GA.pdf' },
{ team: 'Lovett School', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/2d5babf54_LovettSchool-GA.pdf' },
{ team: 'Madison County', url: 'https://media.base44.com/files/public/69bb16a38ac0086814d94e6f/3cc0c2563_MadisonCounty-GA.pdf' },
];
function guessTeamName(filename) {
const base = filename.replace(/\.pdf$/i, '').replace(/^.*_/, '');
return base.replace(/-GA.*$/i, '').replace(/GamebyGameStats_/i, '').replace(/[-_]/g, ' ').trim();
}
export default function PdfStatsImporter() {
const [tab, setTab] = useState('preloaded');
const [statuses, setStatuses] = useState({});
const [running, setRunning] = useState(false);
const [currentTeam, setCurrentTeam] = useState('');
const [syncRunning, setSyncRunning] = useState(false);
const [syncResult, setSyncResult] = useState(null);
const [uploadQueue, setUploadQueue] = useState([]);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef();
const importTeam = async (pdf) => {
setStatuses(prev => ({ ...prev, [pdf.team]: { status: 'loading' } }));
const res = await base44.functions.invoke('importGeorgiaStats', { pdf_url: pdf.url, team_name: pdf.team });
const data = res.data;
const updated = data?.results?.filter(r => r.status === 'updated').length || 0;
const notFound = data?.results?.filter(r => r.status === 'not_found').length || 0;
setStatuses(prev => ({
...prev,
[pdf.team]: { status: data?.error ? 'error' : 'done', updated, notFound, error: data?.error }
}));
};
const importAll = async () => {
setRunning(true);
for (const pdf of PRELOADED_PDFS) {
if (statuses[pdf.team]?.status === 'done') continue;
setCurrentTeam(pdf.team);
await importTeam(pdf);
await new Promise(r => setTimeout(r, 800));
}
setCurrentTeam('');
setRunning(false);
};
const doneCount = Object.values(statuses).filter(s => s.status === 'done').length;
const errorCount = Object.values(statuses).filter(s => s.status === 'error').length;
const handleFiles = (files) => {
const pdfs = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.endsWith('.pdf'));
const newItems = pdfs.map(file => ({
id: Math.random().toString(36).slice(2),
file,
teamName: guessTeamName(file.name),
url: null,
uploadStatus: 'pending',
result: null,
error: null,
}));
setUploadQueue(prev => [...prev, ...newItems]);
};
const updateItem = (id, patch) => setUploadQueue(prev => prev.map(i => i.id === id ? { ...i, ...patch } : i));
const uploadAndImportAll = async () => {
setRunning(true);
for (const item of uploadQueue) {
if (item.uploadStatus === 'done') continue;
updateItem(item.id, { uploadStatus: 'uploading' });
let fileUrl;
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file: item.file });
fileUrl = file_url;
updateItem(item.id, { url: fileUrl, uploadStatus: 'importing' });
} catch (e) {
updateItem(item.id, { uploadStatus: 'error', error: 'Upload failed: ' + e.message });
continue;
}
try {
const res = await base44.functions.invoke('importGeorgiaStats', { pdf_url: fileUrl, team_name: item.teamName });
const data = res.data;
const updated = data?.results?.filter(r => r.status === 'updated').length || 0;
const notFound = data?.results?.filter(r => r.status === 'not_found').length || 0;
updateItem(item.id, {
uploadStatus: data?.error ? 'error' : 'done',
result: { updated, notFound },
error: data?.error || null,
});
} catch (e) {
updateItem(item.id, { uploadStatus: 'error', error: 'Import failed: ' + e.message });
}
await new Promise(r => setTimeout(r, 500));
}
setRunning(false);
};
const pendingUploadCount = uploadQueue.filter(i => i.uploadStatus !== 'done').length;
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-black text-white">Stats Import</h2>
<p className="text-slate-400 text-sm mt-1">Import game stats from Hoopsalytics PDFs</p>
</div>
<div className="flex gap-2">
<button onClick={() => {
setSyncRunning(true);
base44.functions.invoke('addPlayersToTeamRosters', {}).then(res => {
setSyncResult(res.data);
setSyncRunning(false);
}).catch(e => {
setSyncResult({ error: e.message });
setSyncRunning(false);
});
}} disabled={syncRunning}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold disabled:opacity-50"
style={{ background: '#00FF85', color: '#000' }}>
{syncRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
{syncRunning ? 'Syncing...' : 'Sync Players'}
</button>
<button onClick={() => {
setSyncRunning(true);
base44.functions.invoke('populateTeamStats', {}).then(res => {
setSyncResult(res.data);
setSyncRunning(false);
}).catch(e => {
setSyncResult({ error: e.message });
setSyncRunning(false);
});
}} disabled={syncRunning}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold disabled:opacity-50"
style={{ background: '#FF6A00', color: '#000' }}>
{syncRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
{syncRunning ? 'Syncing...' : 'Sync Stats'}
</button>
</div>
</div>
{syncResult && (
<div className={`mb-6 p-4 rounded-lg text-sm font-semibold ${syncResult.error ? 'text-red-400 bg-red-950' : 'text-green-400 bg-green-950'}`}>
{syncResult.error ? `Error: ${syncResult.error}` : `✓ ${syncResult.added} players added ${syncResult.errors?.length > 0 ? `(${syncResult.errors.length} errors)` : ''}`}
</div>
)}
<div className="flex gap-1 mb-6 border-b border-white/10">
{[{ id: 'preloaded', label: 'Pre-loaded' }, { id: 'upload', label: 'Upload New' }].map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-2.5 text-sm font-bold border-b-2 -mb-px transition-all ${tab === t.id ? 'text-white border-orange-500' : 'text-slate-500 border-transparent hover:text-slate-300'}`}>
{t.label}
</button>
))}
</div>
{tab === 'preloaded' && (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-slate-400 text-sm">{PRELOADED_PDFS.length} teams · {doneCount} imported{errorCount > 0 ? ` · ${errorCount} errors` : ''}</p>
<button onClick={importAll} disabled={running}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold disabled:opacity-50"
style={{ background: '#FF6A00', color: '#000' }}>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? `Importing ${currentTeam}...` : 'Import All'}
</button>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{PRELOADED_PDFS.map(pdf => {
const s = statuses[pdf.team];
return (
<div key={pdf.team} className="flex items-center justify-between px-4 py-3 rounded-xl border"
style={{ background: '#0a0a0a', borderColor: s?.status === 'done' ? 'rgba(0,255,133,0.2)' : s?.status === 'error' ? 'rgba(255,59,48,0.2)' : 'rgba(255,255,255,0.06)' }}>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold text-sm">{pdf.team}</p>
{s?.status === 'done' && <p className="text-xs text-green-400">{s.updated} updated{s.notFound > 0 ? `, ${s.notFound} unmatched` : ''}</p>}
{s?.status === 'error' && <p className="text-xs text-red-400">{s.error}</p>}
</div>
<div className="ml-3 shrink-0">
{s?.status === 'loading' && <Loader2 className="w-4 h-4 animate-spin text-orange-400" />}
{s?.status === 'done' && <CheckCircle2 className="w-4 h-4 text-green-400" />}
{s?.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
{!s && (
<button onClick={() => importTeam(pdf)}
className="text-xs px-2 py-1 rounded font-bold text-slate-500 hover:text-white"
style={{ background: 'rgba(255,255,255,0.05)' }}>
Import
</button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{tab === 'upload' && (
<div>
<div onDragOver={e => { e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)}
onDrop={e => { e.preventDefault(); setDragging(false); handleFiles(e.dataTransfer.files); }}
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all mb-6"
style={{ borderColor: dragging ? '#FF6A00' : 'rgba(255,255,255,0.12)', background: dragging ? 'rgba(255,106,0,0.05)' : 'rgba(255,255,255,0.02)' }}>
<Upload className="w-8 h-8 mx-auto mb-3 text-slate-500" />
<p className="text-white font-semibold text-sm">Drop PDF files or click to browse</p>
<p className="text-slate-500 text-xs mt-1">Team names auto-detected from filename</p>
<input ref={fileInputRef} type="file" accept=".pdf" multiple className="hidden" onChange={e => handleFiles(e.target.files)} />
</div>
{uploadQueue.length > 0 && (
<>
<div className="flex items-center justify-between mb-3">
<p className="text-slate-400 text-sm">{uploadQueue.length} files queued</p>
<div className="flex gap-2">
<button onClick={() => setUploadQueue([])} disabled={running}
className="text-xs px-3 py-1.5 rounded-lg font-bold text-slate-500 hover:text-white disabled:opacity-40"
style={{ background: 'rgba(255,255,255,0.05)' }}>
Clear All
</button>
<button onClick={uploadAndImportAll} disabled={running || pendingUploadCount === 0}
className="flex items-center gap-2 px-4 py-1.5 rounded-lg text-sm font-bold disabled:opacity-50"
style={{ background: '#FF6A00', color: '#000' }}>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? 'Importing...' : `Import All (${pendingUploadCount})`}
</button>
</div>
</div>
<div className="space-y-2">
{uploadQueue.map(item => (
<div key={item.id} className="flex items-center gap-3 px-4 py-3 rounded-xl border"
style={{ background: '#0a0a0a', borderColor: item.uploadStatus === 'done' ? 'rgba(0,255,133,0.2)' : item.uploadStatus === 'error' ? 'rgba(255,59,48,0.2)' : 'rgba(255,255,255,0.06)' }}>
<FileText className="w-4 h-4 text-slate-500 shrink-0" />
<div className="flex-1 min-w-0">
<input value={item.teamName} onChange={e => updateItem(item.id, { teamName: e.target.value })}
disabled={item.uploadStatus !== 'pending'}
className="bg-transparent text-white font-semibold text-sm w-full focus:outline-none border-b border-transparent focus:border-orange-500 transition-colors disabled:opacity-60"
placeholder="Team name..." />
<p className="text-xs text-slate-600 truncate">{item.file.name}</p>
{item.uploadStatus === 'done' && <p className="text-xs text-green-400">{item.result?.updated} games updated{item.result?.notFound > 0 ? `, ${item.result.notFound} unmatched` : ''}</p>}
{item.uploadStatus === 'error' && <p className="text-xs text-red-400">{item.error}</p>}
</div>
<div className="shrink-0 flex items-center gap-2">
{item.uploadStatus === 'uploading' && <span className="text-xs text-slate-400">Uploading...</span>}
{item.uploadStatus === 'importing' && <span className="text-xs text-orange-400">Importing...</span>}
{(item.uploadStatus === 'uploading' || item.uploadStatus === 'importing') && <Loader2 className="w-4 h-4 animate-spin text-orange-400" />}
{item.uploadStatus === 'done' && <CheckCircle2 className="w-4 h-4 text-green-400" />}
{item.uploadStatus === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
{item.uploadStatus === 'pending' && (
<button onClick={() => setUploadQueue(prev => prev.filter(i => i.id !== item.id))} className="text-slate-600 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
}src/components/admin/UpdateRequestCard.jsx import { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { Link } from 'react-router-dom';
import { ExternalLink, CheckCircle2, XCircle, Clock, Loader2, MessageSquare } from 'lucide-react';
const STATUS_BADGE = {
submitted: 'bg-blue-500/10 text-blue-400',
in_review: 'bg-yellow-500/10 text-yellow-400',
applied: 'bg-green-500/10 text-green-400',
rejected: 'bg-red-500/10 text-red-400',
};
const CAT_LABELS = {
contact_info: 'Contact Info', identity: 'Identity', academic: 'Academic',
athletic: 'Athletic', media: 'Media', bio: 'Bio', stats: 'Stats',
social: 'Social', offers: 'Offers', visibility: 'Visibility', other: 'Other',
};
export default function UpdateRequestCard({ request, onChanged }) {
const [notes, setNotes] = useState(request.admin_notes || '');
const [busy, setBusy] = useState(false);
const updateStatus = async (status) => {
setBusy(true);
try {
await base44.entities.UpdateRequest.update(request.id, {
status,
admin_notes: notes,
reviewed_date: new Date().toISOString(),
});
onChanged();
} catch {
alert('Failed to update request status.');
} finally {
setBusy(false);
}
};
const saveNotes = async () => {
setBusy(true);
try {
await base44.entities.UpdateRequest.update(request.id, { admin_notes: notes });
onChanged();
} catch {
alert('Failed to save notes.');
} finally {
setBusy(false);
}
};
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<div className="flex items-start justify-between mb-2">
<div>
<p className="text-sm font-bold text-white">{request.player_name || 'Unknown'}</p>
<p className="text-xs text-slate-500">
From: {request.requester_name || 'Unknown'} · {CAT_LABELS[request.category] || request.category}
</p>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-semibold capitalize ${STATUS_BADGE[request.status] || ''}`}>
{request.status.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-slate-300 bg-slate-800/50 rounded p-2 mb-3">{request.requested_changes}</p>
<div className="flex gap-2 mb-3">
<input value={notes} onChange={e => setNotes(e.target.value)} placeholder="Admin notes..."
className="flex-1 px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white text-xs focus:outline-none focus:border-orange-500" />
<button onClick={saveNotes} disabled={busy} className="px-2 py-1.5 rounded bg-slate-800 text-slate-400 hover:text-white text-xs">
<MessageSquare className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex flex-wrap gap-2 items-center">
{request.status === 'submitted' && (
<button onClick={() => updateStatus('in_review')} disabled={busy}
className="px-3 py-1.5 rounded-lg bg-yellow-500/20 text-yellow-400 text-xs font-semibold hover:bg-yellow-500/30 flex items-center gap-1">
<Clock className="w-3.5 h-3.5" /> Start Review
</button>
)}
{request.status === 'in_review' && (
<>
<Link to={`/admin-player/${request.player_id}`}
className="px-3 py-1.5 rounded-lg bg-orange-500 text-black text-xs font-semibold hover:bg-orange-600 flex items-center gap-1">
<ExternalLink className="w-3.5 h-3.5" /> Edit Player
</Link>
<button onClick={() => updateStatus('applied')} disabled={busy}
className="px-3 py-1.5 rounded-lg bg-green-500/20 text-green-400 text-xs font-semibold hover:bg-green-500/30 flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5" /> Mark Applied
</button>
<button onClick={() => updateStatus('rejected')} disabled={busy}
className="px-3 py-1.5 rounded-lg bg-red-500/20 text-red-400 text-xs font-semibold hover:bg-red-500/30 flex items-center gap-1">
<XCircle className="w-3.5 h-3.5" /> Reject
</button>
</>
)}
{request.status === 'applied' && (
<Link to={`/admin-player/${request.player_id}`}
className="px-3 py-1.5 rounded-lg bg-slate-800 text-slate-400 text-xs font-semibold hover:text-white flex items-center gap-1">
<ExternalLink className="w-3.5 h-3.5" /> View Player
</Link>
)}
{busy && <Loader2 className="w-4 h-4 animate-spin text-slate-500" />}
</div>
</div>
);
}src/components/admin/dashboard/AssignCoachModal.jsx import { useState } from 'react';
import { X, Plus, Loader2 } from 'lucide-react';
import { ORANGE } from './constants';
export default function AssignCoachModal({ teamName, coachProfiles, onAssign, onCreate, onClose, assigning }) {
const [search, setSearch] = useState('');
const [newCoach, setNewCoach] = useState({ coach_name: '', email: '', phone: '' });
const available = coachProfiles
.filter(c => c.high_school !== teamName)
.filter(c =>
c.coach_name?.toLowerCase().includes(search.toLowerCase()) ||
c.email?.toLowerCase().includes(search.toLowerCase())
)
.slice(0, 10);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-800 rounded-xl w-full max-w-sm">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
<h2 className="text-white font-black text-base">Assign Coach to {teamName}</h2>
<button onClick={onClose} className="text-slate-500 hover:text-white"><X className="w-4 h-4" /></button>
</div>
<div className="px-5 py-4 space-y-4">
{/* Search existing */}
<div>
<label className="text-xs text-slate-400 font-semibold block mb-1.5">Assign Existing Coach</label>
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
placeholder="Search coaches…"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500 mb-2" />
<div className="max-h-48 overflow-y-auto space-y-1">
{available.map(coach => (
<button key={coach.id} onClick={() => onAssign(coach)} disabled={assigning}
className="w-full text-left px-3 py-2 rounded text-xs bg-slate-800 hover:bg-slate-700 text-white transition-all disabled:opacity-50">
<p className="font-semibold">{coach.coach_name}</p>
<p className="text-slate-500 text-[10px]">{coach.email}</p>
</button>
))}
{available.length === 0 && <p className="text-xs text-slate-600 py-2">No available coaches</p>}
</div>
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-slate-700" /></div>
<div className="relative flex justify-center text-xs"><span className="px-2 bg-slate-900 text-slate-500">or</span></div>
</div>
{/* Create new */}
<div className="space-y-2">
<p className="text-xs text-slate-400 font-semibold">Create New Coach</p>
<input type="text" value={newCoach.coach_name} onChange={e => setNewCoach(p => ({ ...p, coach_name: e.target.value }))}
placeholder="Coach Name"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500" />
<input type="email" value={newCoach.email} onChange={e => setNewCoach(p => ({ ...p, email: e.target.value }))}
placeholder="Email"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500" />
<input type="tel" value={newCoach.phone} onChange={e => setNewCoach(p => ({ ...p, phone: e.target.value }))}
placeholder="Phone (optional)"
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500" />
<button onClick={() => onCreate(newCoach)} disabled={assigning}
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-bold text-white disabled:opacity-50 transition-all"
style={{ background: ORANGE }}>
{assigning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Create & Assign
</button>
</div>
</div>
<div className="flex gap-2 px-5 py-4 border-t border-slate-800">
<button onClick={onClose} className="flex-1 px-4 py-2.5 rounded-lg text-sm font-bold text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700">
Cancel
</button>
</div>
</div>
</div>
);
}src/components/admin/dashboard/GameModal.jsx import { useState } from 'react';
import { Film, BarChart3, Save, X, Loader2 } from 'lucide-react';
import PdfStatsExtractor from '@/components/admin/PdfStatsExtractor';
import { ORANGE, STAT_FIELDS, teamToSlug } from './constants';
function StatRow({ label, sKey, stats, setStats, isString }) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-12 shrink-0">{label}</span>
<input
type={isString ? 'text' : 'number'}
value={stats[sKey] ?? ''}
onChange={e => setStats(prev => ({ ...prev, [sKey]: e.target.value }))}
className="flex-1 text-xs px-2 py-1 rounded bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-orange-500"
placeholder="—"
/>
</div>
);
}
export default function GameModal({ game, onSave, onClose }) {
const [meta, setMeta] = useState({
team1: game?.team1 || '',
team2: game?.team2 || '',
date: game?.date || '',
time: game?.time || '',
court: game?.court || '',
game_number: game?.game_number || '',
embed_link: game?.embed_link || '',
duration: game?.duration || '',
});
const buildEmptyStats = (teamName) => {
const slug = teamToSlug(teamName);
const existing = game?.team_stats?.[slug] || {};
const stats = {};
STAT_FIELDS.forEach(f => {
stats[f.key] = existing[f.key] !== undefined ? existing[f.key] : (f.isString ? '' : '');
});
return stats;
};
const [stats1, setStats1] = useState(() => buildEmptyStats(game?.team1 || ''));
const [stats2, setStats2] = useState(() => buildEmptyStats(game?.team2 || ''));
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState('info');
const handleSave = async () => {
if (!meta.team1 || !meta.team2) { alert('Both team names are required'); return; }
setSaving(true);
const team_stats = {};
const slug1 = teamToSlug(meta.team1);
const slug2 = teamToSlug(meta.team2);
const parseStats = (s) => {
const out = {};
STAT_FIELDS.forEach(f => {
const v = s[f.key];
if (v === '' || v === null || v === undefined) return;
out[f.key] = f.isString ? String(v) : Number(v);
});
return out;
};
team_stats[slug1] = parseStats(stats1);
team_stats[slug2] = parseStats(stats2);
await onSave({ ...meta, team_stats });
setSaving(false);
};
const Field = ({ label, field, placeholder, colSpan }) => (
<div className={colSpan ? 'col-span-2' : ''}>
<label className="text-xs text-slate-400 font-semibold block mb-1">{label}</label>
<input type="text" value={meta[field]} onChange={e => setMeta(p => ({ ...p, [field]: e.target.value }))}
placeholder={placeholder}
className="w-full text-xs px-2 py-1.5 rounded bg-slate-800 border border-slate-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500" />
</div>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-800 rounded-xl w-full max-w-2xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
<h2 className="text-white font-black text-base">{game?.id ? 'Edit Game' : 'New Game'}</h2>
<button onClick={onClose} className="text-slate-500 hover:text-white"><X className="w-4 h-4" /></button>
</div>
<div className="flex border-b border-slate-800">
{[{ id: 'info', label: 'Game Info', icon: Film }, { id: 'stats', label: 'Stats', icon: BarChart3 }].map(t => {
const Icon = t.icon;
return (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-xs font-bold border-b-2 -mb-px transition-all ${activeTab === t.id ? 'text-white border-orange-500' : 'text-slate-500 border-transparent hover:text-slate-300'}`}>
<Icon className="w-3.5 h-3.5" />{t.label}
</button>
);
})}
</div>
<div className="overflow-y-auto flex-1 px-5 py-4">
{activeTab === 'info' && (
<div className="grid grid-cols-2 gap-3">
<Field label="Team 1 *" field="team1" placeholder="e.g., Milton - GA" />
<Field label="Team 2 *" field="team2" placeholder="e.g., Dacula - GA" />
<Field label="Date" field="date" placeholder="e.g., June 13, 2026" />
<Field label="Time" field="time" placeholder="e.g., 3:00 PM EST" />
<Field label="Court" field="court" placeholder="e.g., COURT 03" />
<Field label="Game #" field="game_number" placeholder="e.g., GM01" />
<Field label="Duration" field="duration" placeholder="e.g., 54:17" />
<div />
<Field label="Embed Link (Vimeo URL)" field="embed_link" placeholder="https://player.vimeo.com/video/..." colSpan />
</div>
)}
{activeTab === 'stats' && (
<div className="space-y-6">
<PdfStatsExtractor
team1={meta.team1}
team2={meta.team2}
onStatsExtracted={(t1, t2) => {
const merge = (prev, extracted) => {
const updated = { ...prev };
Object.entries(extracted).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') updated[k] = String(v);
});
return updated;
};
if (Object.keys(t1).length) setStats1(prev => merge(prev, t1));
if (Object.keys(t2).length) setStats2(prev => merge(prev, t2));
}}
/>
{[{ label: meta.team1 || 'Team 1', stats: stats1, setStats: setStats1, accent: true },
{ label: meta.team2 || 'Team 2', stats: stats2, setStats: setStats2, accent: false }].map(({ label, stats, setStats, accent }) => (
<div key={label}>
<h3 className="text-sm font-black text-white mb-3 flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs ${accent ? '' : 'bg-slate-700 text-slate-300'}`}
style={accent ? { background: 'rgba(255,106,0,0.15)', color: ORANGE } : {}}>{label}</span>
Stats
</h3>
<div className="grid grid-cols-2 gap-2">
{STAT_FIELDS.map(f => <StatRow key={f.key} label={f.label} sKey={f.key} stats={stats} setStats={setStats} isString={f.isString} />)}
</div>
</div>
))}
</div>
)}
</div>
<div className="flex gap-2 px-5 py-4 border-t border-slate-800">
<button onClick={handleSave} disabled={saving}
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-bold text-white disabled:opacity-50 transition-all"
style={{ background: ORANGE }}>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Game
</button>
<button onClick={onClose} className="px-4 py-2.5 rounded-lg text-sm font-bold text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700">
Cancel
</button>
</div>
</div>
</div>
);
}src/components/admin/dashboard/TeamCard.jsx import { ChevronDown, Plus, ExternalLink } from 'lucide-react';
import { ORANGE, teamToSlug } from './constants';
export default function TeamCard({
team, slug, isLocked, isClaimed, teamGames, teamCoaches,
isExpanded, onToggleExpand, togglingLock, onToggleLock,
onEditGame, onAddGame, onAssignCoach
}) {
return (
<div className="rounded-xl border overflow-hidden" style={{ background: '#0a0a0a', borderColor: 'rgba(255,255,255,0.07)' }}>
{/* Header */}
<div className="flex items-center gap-4 px-4 py-4">
<div className="w-10 h-10 rounded-full flex items-center justify-center font-barlow font-black text-lg shrink-0"
style={{ background: 'rgba(255,106,0,0.1)', color: ORANGE }}>
{team[0]}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-white text-sm truncate">{team}</p>
<p className="text-xs text-slate-600">
{isLocked ? '🔒 Locked' : '🔓 Unlocked'} {isClaimed && '✓ Claimed'}
{teamGames.length > 0 && ` · ${teamGames.length} games`}
{teamCoaches.length > 0 && ` · ${teamCoaches.length} coach${teamCoaches.length !== 1 ? 'es' : ''}`}
</p>
</div>
<a href={`/georgia-team/${slug}`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-1 px-3 py-2 rounded-lg transition-all font-bold text-xs shrink-0 text-white hover:opacity-90"
style={{ background: ORANGE }}>
View <ExternalLink className="w-3 h-3" />
</a>
<button onClick={onToggleLock} disabled={togglingLock}
className="flex items-center justify-center px-3 py-2 rounded-lg transition-all font-bold text-xs shrink-0 disabled:opacity-50"
style={{ background: isLocked ? 'rgba(255,59,48,0.15)' : 'rgba(0,255,133,0.15)', color: isLocked ? '#FF3B30' : '#00FF85' }}>
{isLocked ? 'Unlock' : 'Lock'}
</button>
<button onClick={onToggleExpand}
className="flex items-center justify-center w-9 h-9 rounded-lg transition-all shrink-0 text-slate-500 hover:text-white"
style={{ background: 'rgba(255,255,255,0.05)' }}>
<ChevronDown className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
</button>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="px-4 py-4 border-t space-y-4" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
{/* Games */}
{teamGames.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-bold text-slate-400 uppercase tracking-wide">Games ({teamGames.length})</p>
<button onClick={onAddGame}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-bold text-white transition-all hover:opacity-90"
style={{ background: ORANGE }}>
<Plus className="w-3 h-3" /> Add
</button>
</div>
<div className="space-y-2">
{teamGames.map(game => {
const isTeam1 = teamToSlug(game.team1) === slug;
const opp = isTeam1 ? game.team2 : game.team1;
const oppSlug = teamToSlug(opp);
const myStats = game.team_stats?.[slug];
const oppStats = game.team_stats?.[oppSlug];
const hasStats = myStats?.pts !== undefined || oppStats?.pts !== undefined;
return (
<div key={game.id} className="p-2 rounded bg-white/5 flex items-center justify-between gap-2 text-xs">
<div className="flex-1 min-w-0">
<p className="text-white font-bold truncate">{game.date} - {opp}</p>
<p className="text-slate-600 text-[10px]">{game.time || '—'} {game.court ? `· ${game.court}` : ''}</p>
{hasStats && <p className="text-orange-400 text-[10px] mt-0.5">✓ Stats included</p>}
</div>
<button onClick={() => onEditGame(game)}
className="px-2 py-1 rounded font-bold text-white transition-all hover:opacity-90"
style={{ background: ORANGE }}>
Edit
</button>
</div>
);
})}
</div>
</div>
)}
{/* Coaches */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-bold text-slate-400 uppercase tracking-wide">Coaches</p>
<button onClick={onAssignCoach}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-bold text-white transition-all hover:opacity-90"
style={{ background: ORANGE }}>
<Plus className="w-3 h-3" /> Assign
</button>
</div>
{teamCoaches.length === 0 ? (
<p className="text-xs text-slate-600 py-2">No coaches assigned</p>
) : (
<div className="space-y-1">
{teamCoaches.map(coach => (
<div key={coach.id} className="p-2 rounded text-xs bg-white/5">
<p className="text-white font-semibold">{coach.coach_name}</p>
<p className="text-slate-500 text-[10px]">{coach.email}</p>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
} Team Site Buildersrc/pages/GeorgiaTeamSite.jsx import { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { Link, useParams } from 'react-router-dom';
import { Film, Users, ExternalLink, Home, CheckCircle2, Lock, Loader2 } from 'lucide-react';
import MiltonGameStats from '@/components/georgia/MiltonGameStats';
import DaculaGameStats from '@/components/georgia/DaculaGameStats';
import MariettaGameStats from '@/components/georgia/MariettaGameStats';
import GameStatsPanel from '@/components/georgia/GameStatsPanel';
import RosterPlayerCard from '@/components/georgia/RosterPlayerCard';
import TeamCheckout from '@/components/georgia/TeamCheckout';
import MiltonSponsors from '@/components/georgia/MiltonSponsors';
import { useAuth } from '@/lib/AuthContext';
const ORANGE = '#FF6A00';
// Normalize team name to a URL-safe slug
export function teamToSlug(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
export default function GeorgiaTeamSite() {
const { teamSlug } = useParams();
const { user } = useAuth();
const [games, setGames] = useState([]);
const [hoopPlayers, setHoopPlayers] = useState([]);
const [portfolioPlayers, setPortfolioPlayers] = useState([]);
const [loading, setLoading] = useState(true);
const [isLocked, setIsLocked] = useState(false);
const [activeTab, setActiveTab] = useState('film');
const [teamName, setTeamName] = useState('');
const [passwordRequired, setPasswordRequired] = useState(false);
const [passwordInput, setPasswordInput] = useState('');
const [passwordError, setPasswordError] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [claimedTeams, setClaimedTeams] = useState([]);
useEffect(() => {
const load = async () => {
// Check if team is claimed/locked via TeamClaim entity
try {
const claimsRes = await base44.functions.invoke('getGeorgiaTeamsData', {});
setClaimedTeams(claimsRes.data?.activeSlugs || []);
// Check if this team is locked (skip for Mill Creek)
if (teamSlug !== 'mill-creek') {
try {
const allClaims = await base44.asServiceRole.entities.TeamClaim.filter({ team_slug: teamSlug });
const teamClaim = allClaims?.[0];
if (teamClaim?.is_locked) {
setIsLocked(true);
}
} catch (e) {
// ignore
}
}
} catch (e) {
console.error('Error loading claimed teams:', e);
}
// Clean up claim_success URL param after loading
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('claim_success') === 'true') {
window.history.replaceState({}, '', window.location.pathname);
}
// Load all georgia games and filter by team slug
const allGames = await base44.entities.GeorgiaGame.list();
// Find games where this team is team1 or team2
const teamGames = allGames.filter(g =>
teamToSlug(g.team1) === teamSlug || teamToSlug(g.team2) === teamSlug
);
// Determine actual team name from the data
const firstGame = teamGames[0];
if (firstGame) {
const name = teamToSlug(firstGame.team1) === teamSlug ? firstGame.team1 : firstGame.team2;
setTeamName(name);
// Update meta tags for sharing
document.title = `${name} · Georgia HS Basketball Showcase`;
updateMetaTag('og:title', name);
updateMetaTag('og:description', `${name} · Georgia HS Boys Basketball Showcase 2026`);
updateMetaTag('twitter:title', name);
updateMetaTag('twitter:description', `${name} · Georgia HS Boys Basketball Showcase 2026`);
}
// Build game list — always show this team first in title
const formattedGames = teamGames.map(g => {
const isTeam1 = teamToSlug(g.team1) === teamSlug;
const myTeam = isTeam1 ? g.team1 : g.team2;
const oppTeam = isTeam1 ? g.team2 : g.team1;
return {
...g,
displayTitle: `${g.date} · ${g.time} · ${g.court} · ${g.game_number} · ${myTeam} vs ${oppTeam}`,
opponent: oppTeam,
};
}).sort((a, b) => new Date(a.date) - new Date(b.date));
setGames(formattedGames.filter(g => g.embed_link && g.embed_link.trim() !== ''));
// Fetch roster via backend function (service-role to bypass RLS)
try {
const rosterRes = await base44.functions.invoke('getGeorgiaTeamRoster', { team_slug: teamSlug });
if (rosterRes.data?.hoop_players) {
setHoopPlayers(rosterRes.data.hoop_players);
}
if (rosterRes.data?.portfolio_players) {
setPortfolioPlayers(rosterRes.data.portfolio_players);
}
// Check if password is required by looking at the first team (they should all have same password)
if (rosterRes.data?.team_id) {
try {
const team = await base44.asServiceRole.entities.HoopTeam.get(rosterRes.data.team_id);
if (team?.password) {
setPasswordRequired(true);
}
} catch (e) {
// ignore team fetch errors
}
}
} catch (e) {
// no team data yet
}
setLoading(false);
};
load();
// Subscribe to real-time updates on games
const unsub = base44.entities.GeorgiaGame.subscribe(event => {
load();
});
return unsub;
}, [teamSlug]);
// Helper to update meta tags dynamically
const updateMetaTag = (property, content) => {
let tag = document.querySelector(`meta[property="${property}"]`);
if (!tag) {
tag = document.createElement('meta');
tag.setAttribute('property', property);
document.head.appendChild(tag);
}
tag.setAttribute('content', content);
};
if (loading) return (
<div className="min-h-screen bg-black flex items-center justify-center">
<div className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: ORANGE }} />
</div>
);
// Check if unclaimed and user is not admin
const isClaimed = claimedTeams.includes(teamSlug);
if (!isClaimed && (!user || user.role !== 'admin')) {
return (
<div className="min-h-screen bg-black text-white flex flex-col items-center justify-center text-center px-6">
<Lock className="w-16 h-16 text-orange-500 mb-4" />
<h1 className="font-barlow font-black text-3xl mb-2">Team Site Locked</h1>
<p className="text-gray-500 mb-6">Claim this team site to unlock full access to game film and stats.</p>
<Link to="/coach-partnership"
className="inline-block px-6 py-3 rounded-lg font-black uppercase tracking-widest text-sm transition-all"
style={{ background: ORANGE, color: '#000' }}>
Claim Now
</Link>
<Link to="/georgia-teams" className="text-sm hover:underline mt-4" style={{ color: ORANGE }}>← Back to Teams</Link>
</div>
);
}
// Check if locked and user is not admin
if (isLocked && (!user || user.role !== 'admin')) {
return (
<div className="min-h-screen bg-black text-white flex flex-col items-center justify-center text-center px-6">
<Lock className="w-16 h-16 text-orange-500 mb-4" />
<h1 className="font-barlow font-black text-3xl mb-2">Team Page Locked</h1>
<p className="text-gray-500 mb-6">This team page is currently locked. Claim this site to unlock.</p>
<Link to="/coach-partnership"
className="inline-block px-6 py-3 rounded-lg font-black uppercase tracking-widest text-sm transition-all"
style={{ background: ORANGE, color: '#000' }}>
Claim Team Site
</Link>
<Link to="/georgia-teams" className="text-sm hover:underline mt-4" style={{ color: ORANGE }}>← Back to Teams</Link>
</div>
);
}
if (!teamName && games.length === 0) return (
<div className="min-h-screen bg-black text-white flex flex-col items-center justify-center text-center px-6">
<p className="text-5xl mb-4">🏀</p>
<h1 className="font-barlow font-black text-3xl mb-2">Team Not Found</h1>
<p className="text-gray-500 mb-6">We couldn't find a team with this name.</p>
<Link to="/georgia-teams" className="text-sm hover:underline" style={{ color: ORANGE }}>← View All Teams</Link>
</div>
);
// Merge hoop players with portfolio data
const rosterPlayers = hoopPlayers.length > 0
? hoopPlayers.map(hp => {
const pp = portfolioPlayers.find(p =>
p.id === hp.portfolio_player_id ||
(p.full_name?.toLowerCase() === hp.full_name?.toLowerCase())
);
return { ...hp, portfolio: pp };
})
: portfolioPlayers.map(p => ({ ...p, full_name: p.full_name, portfolio: p }));
const hasPremium = (player) => {
const pp = player.portfolio;
return pp && (pp.portfolio_tier === 'premium' || pp.portfolio_tier === 'under_review');
};
const sortedRosterPlayers = teamSlug === 'milton'
? [...rosterPlayers].sort((a, b) => Number(hasPremium(b)) - Number(hasPremium(a)))
: rosterPlayers;
const handlePasswordSubmit = async () => {
// Get the team to check password
const allTeams = await base44.entities.HoopTeam.list();
const team = allTeams.find(t => teamToSlug(t.name) === teamSlug);
if (team && team.password === passwordInput) {
setIsAuthenticated(true);
setPasswordError(false);
setPasswordInput('');
} else {
setPasswordError(true);
setPasswordInput('');
}
};
if (passwordRequired && !isAuthenticated) {
return (
<div className="min-h-screen bg-black text-white flex flex-col items-center justify-center px-4">
<div className="w-full max-w-sm rounded-2xl border p-8" style={{ background: '#0a0a0a', borderColor: 'rgba(255,255,255,0.07)' }}>
<h1 className="font-barlow font-black text-2xl mb-2">{teamName || 'Team Page'}</h1>
<p className="text-gray-500 text-sm mb-6">This team page is password protected.</p>
<input
type="password"
value={passwordInput}
onChange={e => { setPasswordInput(e.target.value); setPasswordError(false); }}
placeholder="Enter password"
autoFocus
onKeyDown={e => e.key === 'Enter' && handlePasswordSubmit()}
className="w-full px-4 py-3 rounded-xl bg-[#111] text-white text-sm placeholder-white/30 focus:outline-none mb-3"
style={{ border: `1px solid ${passwordError ? '#FF3B30' : 'rgba(255,255,255,0.09)'}` }}
/>
{passwordError && <p className="text-red-400 text-xs mb-3">Incorrect password</p>}
<button
onClick={handlePasswordSubmit}
className="w-full py-3 rounded-xl font-black uppercase tracking-widest transition-all"
style={{ background: ORANGE, color: '#000' }}>
Unlock
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-black text-white">
{/* Nav */}
<div className="sticky top-0 z-30 border-b" style={{ background: 'rgba(0,0,0,0.95)', backdropFilter: 'blur(16px)', borderColor: 'rgba(255,255,255,0.07)' }}>
<div className="max-w-5xl mx-auto px-5 h-14 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/Home" className="text-gray-500 hover:text-white transition-colors">
<Home className="w-4 h-4" />
</Link>
<span className="text-gray-600">/</span>
<Link to="/georgia-teams" className="text-gray-500 hover:text-white text-sm transition-colors">Georgia Showcase</Link>
<span className="text-gray-600">/</span>
<span className="font-barlow font-black text-white text-sm">{teamName}</span>
</div>
{isClaimed && (
<span className="text-xs px-3 py-1.5 rounded-lg font-bold flex items-center gap-1.5"
style={{ background: 'rgba(0,255,133,0.08)', color: '#00FF85', border: '1px solid rgba(0,255,133,0.25)' }}>
<CheckCircle2 className="w-3 h-3" /> Claimed
</span>
)}
</div>
</div>
{/* Hero */}
<div className="border-b" style={{ borderColor: 'rgba(255,255,255,0.06)', background: 'linear-gradient(to bottom, rgba(255,106,0,0.05), transparent)' }}>
<div className="max-w-5xl mx-auto px-5 py-10">
<div className="flex items-center gap-3 mb-2">
<span className="text-xs px-2 py-1 rounded-full font-bold tracking-widest uppercase" style={{ background: 'rgba(255,106,0,0.1)', color: ORANGE, border: '1px solid rgba(255,106,0,0.25)' }}>
Georgia HS Boys Basketball Showcase 2026
</span>
</div>
<h1 className="font-barlow font-black text-5xl uppercase tracking-wide mb-1">{teamName}</h1>
<p className="text-gray-500 text-sm">{teamSlug === 'marietta' ? 8 : teamSlug === 'milton' ? 8 : games.length} games recorded · Georgia</p>
</div>
</div>
<div className="max-w-5xl mx-auto px-5 py-8">
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
{[
{ id: 'film', label: 'Game Film', icon: Film, count: teamSlug === 'marietta' ? 8 : teamSlug === 'milton' ? 8 : teamSlug === 'dacula' ? 8 : games.length },
{ id: 'roster', label: 'Roster', icon: Users, count: rosterPlayers.length },
].map(tab => {
const Icon = tab.icon;
return (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-bold transition-all border-b-2 -mb-px ${activeTab === tab.id ? 'text-white' : 'text-gray-500 hover:text-gray-300 border-transparent'}`}
style={activeTab === tab.id ? { borderColor: ORANGE } : { borderColor: 'transparent' }}>
<Icon className="w-4 h-4" />
{tab.label}
<span className="text-xs px-1.5 py-0.5 rounded-full bg-white/5">{tab.count}</span>
</button>
);
})}
</div>
{/* Film Tab */}
{activeTab === 'film' && (
<div>
{teamSlug === 'marietta' ? (
<MariettaGameStats />
) : teamSlug === 'milton' ? (
<MiltonGameStats />
) : teamSlug === 'dacula' ? (
<DaculaGameStats />
) : games.length === 0 ? (
<div className="text-center py-16 rounded-2xl border border-dashed border-white/10">
<Film className="w-12 h-12 text-gray-700 mx-auto mb-3" />
<p className="text-gray-500">No game film available yet.</p>
</div>
) : (
<div className="space-y-6">
{games.map(game => (
<div key={game.id} className="rounded-2xl border overflow-hidden" style={{ borderColor: 'rgba(255,255,255,0.07)', background: '#0a0a0a' }}>
<div className="px-5 py-4 flex items-center justify-between border-b" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
<div>
<p className="font-bold text-white text-sm">{game.displayTitle}</p>
<p className="text-xs text-gray-600 mt-0.5">Duration: {game.duration}</p>
</div>
<a href={game.embed_link} target="_blank" rel="noopener noreferrer"
className="p-2 rounded-lg transition-colors text-gray-500 hover:text-white" style={{ background: 'rgba(255,255,255,0.05)' }}>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
<div className="aspect-video">
<iframe
src={game.embed_link}
className="w-full h-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
title={game.displayTitle}
/>
</div>
<GameStatsPanel
game={game}
myTeamName={teamName}
oppTeamName={game.opponent}
isPurchased={claimedTeams.includes(teamSlug) || !!(user && (user.role === 'admin' || user.role === 'coach'))}
/>
</div>
))}
</div>
)}
</div>
)}
{/* Roster Tab */}
{activeTab === 'roster' && (
<div>
{rosterPlayers.length === 0 ? (
<div className="text-center py-16 rounded-2xl border border-dashed border-white/10">
<Users className="w-12 h-12 text-gray-700 mx-auto mb-3" />
<p className="text-gray-500 mb-2">Roster not yet available.</p>
<p className="text-gray-600 text-sm">Coaches can claim this site to add players.</p>
<div className="flex gap-3 justify-center mt-4">
<Link to={`/coach?team=${encodeURIComponent(teamName)}`}
className="inline-block px-5 py-2 rounded-lg text-sm font-bold"
style={{ background: ORANGE, color: '#000' }}>
Claim Team Site
</Link>
{user && (user.role === 'admin' || user.role === 'coach') && (
<Link to={`/players?team=${encodeURIComponent(teamName)}`}
className="inline-block px-5 py-2 rounded-lg text-sm font-bold text-white"
style={{ background: 'rgba(255,255,255,0.1)' }}>
Add Players to Roster
</Link>
)}
</div>
</div>
) : (
<div className="space-y-2">
{sortedRosterPlayers.map((player, i) => (
<RosterPlayerCard key={player.id || i} player={player} user={user} />
))}
</div>
)}
</div>
)}
{/* Bottom CTA */}
<div className="mt-12 pt-8 border-t" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
<Link to="/coach-partnership"
className="w-full py-3 rounded-xl font-black uppercase tracking-widest text-sm transition-all text-center block"
style={{ background: ORANGE, color: '#000' }}>
Partner With Us
</Link>
</div>
{teamSlug === 'milton' && <MiltonSponsors />}
</div>
</div>
);
}src/components/georgia/CoachFilmForm.jsx import { useState } from 'react';
import { X, ArrowRight, Loader2, Check } from 'lucide-react';
import { base44 } from '@/api/base44Client';
import { AnimatePresence, motion } from 'framer-motion';
const GOLD = '#D4AF37';
const GOLD_DIM = 'rgba(212,175,55,0.10)';
const GOLD_BORDER = 'rgba(212,175,55,0.25)';
const inputCls = "w-full px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none bg-[#0D0D0D]";
const inputStyle = (focused) => ({
border: `1px solid ${focused ? 'rgba(212,175,55,0.5)' : 'rgba(255,255,255,0.1)'}`,
fontFamily: 'var(--font-inter)',
transition: 'border-color 0.15s',
});
export default function CoachFilmForm({ label = 'Purchase Package', buttonStyle = {} }) {
const [showModal, setShowModal] = useState(false);
const [focused, setFocused] = useState('');
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
highSchool: '',
interestedInPortfolios: null, // true | false | null
});
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
const valid = form.firstName.trim() && form.lastName.trim() && form.email.trim() && form.highSchool.trim() && form.interestedInPortfolios !== null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!valid) return;
if (window.self !== window.top) {
alert('Checkout is only available on the published app. Please open the live site to complete your purchase.');
return;
}
setLoading(true);
try {
const response = await base44.functions.invoke('createCheckout', {
productKey: 'coach_portfolio',
successUrl: window.location.origin + '/georgia?success=true',
cancelUrl: window.location.origin + '/georgia?canceled=true',
customerEmail: form.email,
metadata: {
coach_first_name: form.firstName,
coach_last_name: form.lastName,
high_school: form.highSchool,
interested_in_portfolios: form.interestedInPortfolios ? 'yes' : 'no',
package: 'coach_film_package',
},
});
if (response.data?.url) {
window.location.href = response.data.url;
}
} catch {
alert('Failed to start checkout. Please try again.');
setLoading(false);
}
};
return (
<>
<button
onClick={() => setShowModal(true)}
className="w-full text-xs py-2.5 font-semibold transition-all"
style={{ background: GOLD_DIM, border: `1px solid ${GOLD_BORDER}`, color: GOLD, ...buttonStyle }}
>
{label}
</button>
<AnimatePresence>
{showModal && (
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(6px)' }}
onClick={() => setShowModal(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
className="relative w-full max-w-md overflow-hidden"
style={{ background: '#0D0D0D', border: `1px solid ${GOLD_BORDER}`, boxShadow: `0 0 60px rgba(212,175,55,0.12)` }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between px-7 pt-7 pb-5 border-b" style={{ borderColor: GOLD_BORDER }}>
<div>
<span className="inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.2em] px-2 py-1 mb-2"
style={{ background: GOLD_DIM, border: `1px solid ${GOLD_BORDER}`, color: GOLD }}>
For Coaches
</span>
<h3 className="font-barlow font-black text-2xl text-white leading-tight">Game Film Package</h3>
<p className="text-xs mt-1" style={{ color: 'rgba(255,255,255,0.4)' }}>
Complete the form below to proceed to secure checkout — <span style={{ color: GOLD }}>$100</span>
</p>
</div>
<button onClick={() => setShowModal(false)} style={{ color: 'rgba(255,255,255,0.3)' }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'}
onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.3)'}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="px-7 py-6 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-black uppercase tracking-widest mb-1.5" style={{ color: 'rgba(255,255,255,0.35)', letterSpacing: '0.16em' }}>First Name *</label>
<input type="text" required value={form.firstName} onChange={set('firstName')} placeholder="First"
className={inputCls} style={inputStyle(focused === 'fn')}
onFocus={() => setFocused('fn')} onBlur={() => setFocused('')}
/>
</div>
<div>
<label className="block text-[10px] font-black uppercase tracking-widest mb-1.5" style={{ color: 'rgba(255,255,255,0.35)', letterSpacing: '0.16em' }}>Last Name *</label>
<input type="text" required value={form.lastName} onChange={set('lastName')} placeholder="Last"
className={inputCls} style={inputStyle(focused === 'ln')}
onFocus={() => setFocused('ln')} onBlur={() => setFocused('')}
/>
</div>
</div>
<div>
<label className="block text-[10px] font-black uppercase tracking-widest mb-1.5" style={{ color: 'rgba(255,255,255,0.35)', letterSpacing: '0.16em' }}>Email Address *</label>
<input type="email" required value={form.email} onChange={set('email')} placeholder="coach@school.edu"
className={inputCls} style={inputStyle(focused === 'em')}
onFocus={() => setFocused('em')} onBlur={() => setFocused('')}
/>
</div>
<div>
<label className="block text-[10px] font-black uppercase tracking-widest mb-1.5" style={{ color: 'rgba(255,255,255,0.35)', letterSpacing: '0.16em' }}>High School / Program *</label>
<input type="text" required value={form.highSchool} onChange={set('highSchool')} placeholder="Lincoln High School"
className={inputCls} style={inputStyle(focused === 'hs')}
onFocus={() => setFocused('hs')} onBlur={() => setFocused('')}
/>
</div>
{/* Portfolio interest question */}
<div>
<label className="block text-[10px] font-black uppercase tracking-widest mb-3" style={{ color: 'rgba(255,255,255,0.35)', letterSpacing: '0.16em' }}>
Interested in Player Portfolio Packages for the Regular Season? *
</label>
<div className="grid grid-cols-2 gap-2">
{[{ val: true, label: 'Yes, tell me more' }, { val: false, label: 'Not right now' }].map(opt => (
<button key={String(opt.val)} type="button"
onClick={() => setForm(f => ({ ...f, interestedInPortfolios: opt.val }))}
className="py-2.5 px-4 text-xs font-black uppercase tracking-widest transition-all"
style={{
border: `1px solid ${form.interestedInPortfolios === opt.val ? GOLD : 'rgba(255,255,255,0.1)'}`,
background: form.interestedInPortfolios === opt.val ? GOLD_DIM : 'transparent',
color: form.interestedInPortfolios === opt.val ? GOLD : 'rgba(255,255,255,0.4)',
letterSpacing: '0.1em',
}}
>
{form.interestedInPortfolios === opt.val && <Check className="w-3 h-3 inline mr-1.5" />}
{opt.label}
</button>
))}
</div>
{form.interestedInPortfolios === true && (
<p className="text-xs mt-2 leading-relaxed" style={{ color: 'rgba(255,255,255,0.4)' }}>
We'll follow up after checkout with full season portfolio pricing options for your players.
</p>
)}
</div>
<button type="submit" disabled={!valid || loading}
className="w-full mt-2 flex items-center justify-center gap-2 py-3.5 font-barlow font-black text-sm uppercase tracking-widest transition-all disabled:opacity-40"
style={{ background: GOLD, color: '#0D0D0D', letterSpacing: '0.14em' }}
onMouseEnter={e => { if (valid && !loading) e.currentTarget.style.background = '#E8C547'; }}
onMouseLeave={e => e.currentTarget.style.background = GOLD}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><ArrowRight className="w-4 h-4" /> Proceed to Checkout</>}
</button>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}src/components/georgia/DaculaGameStats.jsx import { useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronDown, ChevronUp, Users, ExternalLink } from 'lucide-react';
const ORANGE = '#FF6A00';
// Dacula player portfolio slug map
const DACULA_PORTFOLIO_SLUGS = {
'Antonio Case Presley': 'antonio-case-presley',
'Jerry Levine': 'jerry-levine',
'Dorian Douglas': 'dorian-douglas',
'Kamari Trotter': 'kamari-trotter',
'Omari Alleyne': 'omari-alleyne',
'Caleb Golding': 'caleb-golding',
'Tristen Dixon': 'tristen-dixon',
'Samuel Jacques': 'samuel-jacques',
'Leslue Malibe': 'leslue-malibe',
'Douglas Finley': 'douglas-finley',
'Cameron Brown': 'cameron-brown',
};
function vimeoEmbed(url) {
const m = url?.match(/vimeo\.com\/(\d+)/);
return m ? `https://player.vimeo.com/video/${m[1]}` : url;
}
// ── Session 1: June 13-14, 2026 ──────────────────────────────────────────────
const SESSION_1_GAMES = [
{
id: 's1g1',
date: 'Jun 13, 2026',
time: '9:00 PM',
court: 'Court 07',
my_team: 'Dacula - GA',
opp_team: 'Baldwin - GA',
my_score: 63,
opp_score: 43,
result: 'W',
film_url: 'https://vimeo.com/1201669006',
team_stats: {
my: { pts: 63, ppp: 1.09, ast: 10, reb: 33, oreb: 13, dreb: 20, blk: 1, stl: 13, to: 5, deflections: 11, fouls: 15, def_fouls: 13, charges: 0, kills: 7, efg_pct: '46.6%', to_pct: '7.9%', oreb_pct: '37.1%', ftr: 0.24, two_made: 24, two_att: 43, two_pct: '55.8%', three_made: 2, three_att: 15, three_pct: '13.3%', ft_made: 9, ft_att: 14, ft_pct: '64.3%', scoring_opps: 65, shots: 58, ft_trips: 7, two_rate: '74.1%', three_rate: '25.9%', ft: '9/14' },
opp: { pts: 43, ppp: 0.74, ast: 8, reb: 30, oreb: 8, dreb: 22, blk: 3, stl: 3, to: 16, deflections: 6, fouls: 12, def_fouls: 12, charges: 0, kills: 5, efg_pct: '41.7%', to_pct: '27.6%', oreb_pct: '28.6%', ftr: 0.38, two_made: 10, two_att: 25, two_pct: '40.0%', three_made: 5, three_att: 17, three_pct: '29.4%', ft_made: 8, ft_att: 16, ft_pct: '50.0%', scoring_opps: 50, shots: 42, ft_trips: 8, two_rate: '59.5%', three_rate: '40.5%', ft: '8/16' },
},
my_players: [
{ num: '0', name: 'Jerry Levine', time: '14:14', pts: 9, reb: 2, ast: 2, blk: 0, stl: 4, to: 1, two_a: '3/3', three_a: '1/2', ft_a: '0/0', efg: '90.0%', pm: '+18' },
{ num: '1', name: 'Luke Anderson', time: '16:03', pts: 10, reb: 0, ast: 4, blk: 0, stl: 1, to: 0, two_a: '4/5', three_a: '0/2', ft_a: '2/2', efg: '57.1%', pm: '+22' },
{ num: '3', name: 'Dorian Douglas', time: '18:33', pts: 13, reb: 2, ast: 3, blk: 0, stl: 1, to: 1, two_a: '4/7', three_a: '0/0', ft_a: '5/5', efg: '57.1%', pm: '+14', starter: true },
{ num: '4', name: 'Omari Alleyne', time: '17:05', pts: 0, reb: 0, ast: 1, blk: 0, stl: 2, to: 0, two_a: '0/1', three_a: '0/2', ft_a: '0/0', efg: '0%', pm: '0', starter: true },
{ num: '5', name: 'Joshua Brown', time: '18:11', pts: 4, reb: 1, ast: 1, blk: 0, stl: 0, to: 0, two_a: '2/3', three_a: '0/2', ft_a: '0/0', efg: '40.0%', pm: '+3', starter: true },
{ num: '10', name: 'Antonio Case Presley', time: '19:09', pts: 8, reb: 2, ast: 6, blk: 0, stl: 2, to: 2, two_a: '4/8', three_a: '0/3', ft_a: '0/1', efg: '36.4%', pm: '+13', starter: true },
{ num: '12', name: 'Cameron Brown', time: '2:22', pts: 0, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/0', ft_a: '0/2', efg: '0%', pm: '-3' },
{ num: '14', name: 'Tristen Dixon', time: '10:22', pts: 8, reb: 1, ast: 3, blk: 0, stl: 2, to: 0, two_a: '4/7', three_a: '0/2', ft_a: '0/0', efg: '44.4%', pm: '+15' },
{ num: '15', name: 'Samuel Jacques', time: '5:35', pts: 5, reb: 0, ast: 2, blk: 0, stl: 0, to: 1, two_a: '1/2', three_a: '1/2', ft_a: '0/0', efg: '62.5%', pm: '+2' },
{ num: '21', name: 'Leslue Malibe', time: '3:57', pts: 0, reb: 0, ast: 1, blk: 0, stl: 1, to: 0, two_a: '0/2', three_a: '0/0', ft_a: '0/2', efg: '0%', pm: '-2' },
{ num: '23', name: 'Douglas Finley', time: '15:13', pts: 2, reb: 2, ast: 3, blk: 1, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '2/2', efg: '—', pm: '+14' },
{ num: '24', name: 'Caleb Golding', time: '9:10', pts: 4, reb: 0, ast: 3, blk: 0, stl: 0, to: 0, two_a: '2/4', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+4', starter: true },
],
opp_players: [
{ num: '0', name: 'Dillon Wright', time: '24:49', pts: 20, reb: 7, ast: 2, blk: 0, stl: 0, to: 1, two_a: '4/7', three_a: '4/11', ft_a: '0/0', efg: '55.6%', pm: '-15', starter: true },
{ num: '1', name: 'Joetavious Havior', time: '5:36', pts: 0, reb: 2, ast: 0, blk: 0, stl: 0, to: 3, two_a: '0/0', three_a: '0/0', ft_a: '0/2', efg: '—', pm: '-17' },
{ num: '2', name: 'Skyler Williams', time: '10:50', pts: 3, reb: 3, ast: 0, blk: 0, stl: 1, to: 2, two_a: '0/1', three_a: '1/3', ft_a: '0/0', efg: '37.5%', pm: '-15' },
{ num: '4', name: 'Tyson Smith', time: '8:59', pts: 2, reb: 0, ast: 0, blk: 1, stl: 0, to: 1, two_a: '0/0', three_a: '0/1', ft_a: '2/2', efg: '0%', pm: '-17' },
{ num: '11', name: 'Mekhi Worthen', time: '9:45', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-5' },
{ num: '12', name: 'Bryan Goddard', time: '16:40', pts: 2, reb: 4, ast: 2, blk: 0, stl: 0, to: 1, two_a: '1/2', three_a: '0/1', ft_a: '0/0', efg: '33.3%', pm: '-2', starter: true },
{ num: '13', name: 'Amari Fluellen', time: '8:43', pts: 2, reb: 2, ast: 1, blk: 0, stl: 0, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '2/2', efg: '—', pm: '+6' },
{ num: '14', name: 'Kunta Paschal', time: '18:26', pts: 4, reb: 1, ast: 0, blk: 1, stl: 0, to: 1, two_a: '1/3', three_a: '0/1', ft_a: '2/2', efg: '25.0%', pm: '-1', starter: true },
{ num: '15', name: 'Tyler Spikes', time: '15:16', pts: 6, reb: 8, ast: 3, blk: 0, stl: 0, to: 1, two_a: '3/4', three_a: '0/0', ft_a: '0/2', efg: '75.0%', pm: '+5', starter: true },
{ num: '20', name: 'Jase Wesley', time: '4:16', pts: 0, reb: 0, ast: 0, blk: 1, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-8' },
{ num: '22', name: 'Rodarius James', time: '8:25', pts: 0, reb: 1, ast: 0, blk: 0, stl: 2, to: 1, two_a: '0/1', three_a: '0/0', ft_a: '0/4', efg: '0%', pm: '-13' },
{ num: '23', name: 'Ferdinand Farley', time: '18:10', pts: 4, reb: 0, ast: 0, blk: 0, stl: 0, to: 3, two_a: '1/6', three_a: '0/0', ft_a: '2/2', efg: '16.7%', pm: '-18', starter: true },
],
},
{
id: 's1g2',
date: 'Jun 13, 2026',
time: '3:00 PM',
court: 'Court 03',
my_team: 'Dacula - GA',
opp_team: 'Chapel Hill - GA',
my_score: 56,
opp_score: 57,
result: 'L',
film_url: 'https://vimeo.com/1201656871',
team_stats: {
my: { pts: 56, ppp: 0.85, ast: 13, reb: 27, oreb: 7, dreb: 20, blk: 1, stl: 8, to: 21, deflections: 1, fouls: 26, def_fouls: 21, charges: 0, kills: 5, efg_pct: '53.5%', to_pct: '32.8%', oreb_pct: '28.0%', ftr: 0.40, two_made: 14, two_att: 28, two_pct: '50.0%', three_made: 6, three_att: 15, three_pct: '40.0%', ft_made: 10, ft_att: 17, ft_pct: '58.8%', scoring_opps: 53, shots: 43, ft_trips: 10, two_rate: '65.1%', three_rate: '34.9%', ft: '10/17' },
opp: { pts: 57, ppp: 0.86, ast: 15, reb: 35, oreb: 17, dreb: 18, blk: 1, stl: 10, to: 19, deflections: 5, fouls: 14, def_fouls: 11, charges: 0, kills: 5, efg_pct: '40.0%', to_pct: '25.7%', oreb_pct: '45.9%', ftr: 0.42, two_made: 19, two_att: 39, two_pct: '48.7%', three_made: 2, three_att: 16, three_pct: '12.5%', ft_made: 13, ft_att: 23, ft_pct: '56.5%', scoring_opps: 69, shots: 55, ft_trips: 14, two_rate: '70.9%', three_rate: '29.1%', ft: '13/23' },
},
my_players: [
{ num: '0', name: 'Jerry Levine', time: '8:36', pts: 3, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '1/1', ft_a: '0/0', efg: '150.0%', pm: '-6' },
{ num: '1', name: 'Luke Anderson', time: '12:07', pts: 7, reb: 2, ast: 1, blk: 0, stl: 1, to: 0, two_a: '0/1', three_a: '1/3', ft_a: '4/4', efg: '37.5%', pm: '-3' },
{ num: '3', name: 'Dorian Douglas', time: '25:52', pts: 11, reb: 4, ast: 6, blk: 0, stl: 3, to: 5, two_a: '3/7', three_a: '1/1', ft_a: '2/4', efg: '56.2%', pm: '-2', starter: true },
{ num: '4', name: 'Omari Alleyne', time: '19:24', pts: 6, reb: 5, ast: 1, blk: 0, stl: 1, to: 4, two_a: '1/3', three_a: '1/3', ft_a: '1/1', efg: '41.7%', pm: '-1', starter: true },
{ num: '5', name: 'Joshua Brown', time: '25:38', pts: 6, reb: 3, ast: 0, blk: 0, stl: 0, to: 3, two_a: '3/3', three_a: '0/2', ft_a: '0/0', efg: '60.0%', pm: '-1', starter: true },
{ num: '10', name: 'Antonio Case Presley', time: '27:44', pts: 19, reb: 5, ast: 2, blk: 0, stl: 1, to: 5, two_a: '5/9', three_a: '2/5', ft_a: '3/8', efg: '57.1%', pm: '+4', starter: true },
{ num: '14', name: 'Tristen Dixon', time: '6:09', pts: 2, reb: 1, ast: 0, blk: 0, stl: 0, to: 1, two_a: '1/2', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+3' },
{ num: '23', name: 'Douglas Finley', time: '9:25', pts: 2, reb: 1, ast: 0, blk: 1, stl: 0, to: 0, two_a: '1/2', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '0' },
{ num: '24', name: 'Caleb Golding', time: '20:15', pts: 0, reb: 3, ast: 3, blk: 0, stl: 2, to: 3, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+1', starter: true },
],
opp_players: [
{ num: '0', name: 'Amare McKinley', time: '23:35', pts: 8, reb: 4, ast: 3, blk: 0, stl: 2, to: 2, two_a: '4/4', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '0', starter: true },
{ num: '1', name: 'William Pass', time: '25:15', pts: 11, reb: 3, ast: 2, blk: 0, stl: 3, to: 0, two_a: '3/8', three_a: '1/8', ft_a: '2/5', efg: '28.1%', pm: '-2', starter: true },
{ num: '3', name: 'Ibn Atkins', time: '18:31', pts: 12, reb: 2, ast: 1, blk: 0, stl: 3, to: 5, two_a: '3/9', three_a: '0/2', ft_a: '6/8', efg: '27.3%', pm: '+13' },
{ num: '4', name: 'Elijah Carnes', time: '19:16', pts: 8, reb: 1, ast: 1, blk: 0, stl: 1, to: 4, two_a: '2/5', three_a: '1/5', ft_a: '1/3', efg: '35.0%', pm: '-5', starter: true },
{ num: '5', name: 'Jakobe Fleming', time: '27:11', pts: 12, reb: 7, ast: 7, blk: 0, stl: 1, to: 3, two_a: '4/9', three_a: '0/0', ft_a: '4/7', efg: '44.4%', pm: '+8', starter: true },
{ num: '10', name: 'Grayson Barnett', time: '11:53', pts: 0, reb: 4, ast: 1, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '+8' },
{ num: '11', name: 'Ares Cosey', time: '6:00', pts: 2, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/1', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '-3' },
{ num: '12', name: 'Perrance Johnson', time: '5:06', pts: 0, reb: 3, ast: 0, blk: 1, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+7' },
{ num: '23', name: 'Chadwick Jordan', time: '16:59', pts: 4, reb: 6, ast: 0, blk: 0, stl: 0, to: 4, two_a: '2/3', three_a: '0/0', ft_a: '0/0', efg: '66.7%', pm: '-16', starter: true },
],
},
{
id: 's1g3',
date: 'Jun 14, 2026',
time: '10:00 AM',
court: 'Court 12',
my_team: 'Dacula - GA',
opp_team: 'Lanier - GA',
my_score: 68,
opp_score: 47,
result: 'W',
film_url: 'https://vimeo.com/1201872100',
team_stats: {
my: { pts: 68, ppp: null, ast: 9, reb: 26, oreb: null, dreb: null, blk: 0, stl: 10, to: 12, deflections: null, fouls: null, def_fouls: null, charges: 0, kills: null, efg_pct: '51.0%', to_pct: null, oreb_pct: null, ftr: null, two_made: 20, two_att: 30, two_pct: '66.7%', three_made: 3, three_att: 18, three_pct: '16.7%', ft_made: 19, ft_att: 23, ft_pct: '82.6%', scoring_opps: null, shots: null, ft_trips: null, two_rate: null, three_rate: null, ft: '19/23' },
opp: { pts: 47, ppp: null, ast: 4, reb: 25, oreb: null, dreb: null, blk: 1, stl: 7, to: 17, deflections: null, fouls: null, def_fouls: null, charges: 0, kills: null, efg_pct: '45.5%', to_pct: null, oreb_pct: null, ftr: null, two_made: 12, two_att: 22, two_pct: '54.5%', three_made: 2, three_att: 11, three_pct: '18.2%', ft_made: 17, ft_att: 28, ft_pct: '60.7%', scoring_opps: null, shots: null, ft_trips: null, two_rate: null, three_rate: null, ft: '17/28' },
},
my_players: [
{ num: '0', name: 'Jerry Levine', time: '12:46', pts: 14, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '2/3', three_a: '2/3', ft_a: '4/4', efg: '83.3%', pm: '+13' },
{ num: '1', name: 'Luke Anderson', time: '17:06', pts: 15, reb: 3, ast: 2, blk: 0, stl: 3, to: 1, two_a: '6/7', three_a: '0/1', ft_a: '3/3', efg: '75.0%', pm: '+24' },
{ num: '3', name: 'Dorian Douglas', time: '21:18', pts: 4, reb: 3, ast: 0, blk: 0, stl: 1, to: 0, two_a: '2/4', three_a: '0/3', ft_a: '0/0', efg: '28.6%', pm: '+10', starter: true },
{ num: '4', name: 'Omari Alleyne', time: '16:13', pts: 8, reb: 5, ast: 1, blk: 0, stl: 1, to: 1, two_a: '2/6', three_a: '0/3', ft_a: '4/6', efg: '22.2%', pm: '+3', starter: true },
{ num: '5', name: 'Joshua Brown', time: '21:42', pts: 5, reb: 0, ast: 0, blk: 0, stl: 1, to: 3, two_a: '1/1', three_a: '1/3', ft_a: '0/0', efg: '62.5%', pm: '+8', starter: true },
{ num: '10', name: 'Antonio Case Presley', time: '21:51', pts: 10, reb: 5, ast: 4, blk: 0, stl: 3, to: 2, two_a: '3/5', three_a: '0/3', ft_a: '4/4', efg: '37.5%', pm: '+7', starter: true },
{ num: '12', name: 'Cameron Brown', time: '2:15', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '+1' },
{ num: '14', name: 'Tristen Dixon', time: '5:08', pts: 0, reb: 1, ast: 0, blk: 0, stl: 1, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+12' },
{ num: '15', name: 'Samuel Jacques', time: '2:40', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/1', ft_a: '0/2', efg: '0%', pm: '+1' },
{ num: '21', name: 'Leslue Malibe', time: '2:34', pts: 3, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '1/1', three_a: '0/0', ft_a: '1/1', efg: '100.0%', pm: '+1' },
{ num: '23', name: 'Douglas Finley', time: '10:26', pts: 4, reb: 1, ast: 1, blk: 0, stl: 0, to: 0, two_a: '2/2', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+20' },
{ num: '24', name: 'Caleb Golding', time: '15:54', pts: 5, reb: 4, ast: 1, blk: 0, stl: 0, to: 1, two_a: '1/1', three_a: '0/0', ft_a: '3/3', efg: '100.0%', pm: '+5', starter: true },
],
opp_players: [
{ num: '0', name: 'Marcus Green', time: '7:44', pts: 3, reb: 2, ast: 0, blk: 0, stl: 0, to: 2, two_a: '0/0', three_a: '1/2', ft_a: '0/0', efg: '75.0%', pm: '+6' },
{ num: '3', name: 'Reshard Slaughter', time: '9:23', pts: 11, reb: 3, ast: 0, blk: 0, stl: 0, to: 0, two_a: '3/4', three_a: '0/1', ft_a: '5/5', efg: '60.0%', pm: '+10' },
{ num: '4', name: 'Kharmelo Morgan', time: '6:47', pts: 2, reb: 0, ast: 0, blk: 0, stl: 1, to: 1, two_a: '1/1', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+2' },
{ num: '5', name: 'EJ Hines', time: '7:08', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+2' },
{ num: '10', name: 'Noah James', time: '15:30', pts: 4, reb: 2, ast: 0, blk: 0, stl: 3, to: 2, two_a: '1/3', three_a: '0/0', ft_a: '2/2', efg: '33.3%', pm: '-21', starter: true },
{ num: '11', name: 'DJ Bembery', time: '9:49', pts: 8, reb: 1, ast: 3, blk: 0, stl: 1, to: 0, two_a: '3/4', three_a: '0/0', ft_a: '2/4', efg: '75.0%', pm: '+6' },
{ num: '12', name: 'Reed Neilan', time: '15:28', pts: 3, reb: 2, ast: 0, blk: 0, stl: 0, to: 2, two_a: '0/1', three_a: '1/2', ft_a: '0/0', efg: '50.0%', pm: '-14', starter: true },
{ num: '13', name: 'Bronson Nathaniel', time: '14:48', pts: 2, reb: 1, ast: 0, blk: 0, stl: 0, to: 2, two_a: '0/1', three_a: '0/0', ft_a: '2/2', efg: '0%', pm: '-25', starter: true },
{ num: '15', name: 'Michael Melancon', time: '7:48', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-18' },
{ num: '20', name: 'Nik Siut', time: '14:50', pts: 0, reb: 1, ast: 1, blk: 0, stl: 1, to: 3, two_a: '0/2', three_a: '0/3', ft_a: '0/0', efg: '0%', pm: '-18', starter: true },
{ num: '21', name: 'Demez Mann', time: '9:23', pts: 2, reb: 3, ast: 0, blk: 1, stl: 0, to: 0, two_a: '0/0', three_a: '0/1', ft_a: '2/6', efg: '0%', pm: '+10' },
{ num: '22', name: 'Moses Alls', time: '7:48', pts: 4, reb: 3, ast: 0, blk: 0, stl: 0, to: 0, two_a: '2/2', three_a: '0/0', ft_a: '0/2', efg: '100.0%', pm: '+6' },
{ num: '23', name: 'Dylan Stewart', time: '14:27', pts: 8, reb: 1, ast: 0, blk: 0, stl: 1, to: 2, two_a: '2/3', three_a: '0/1', ft_a: '4/7', efg: '50.0%', pm: '-27', starter: true },
{ num: '24', name: 'Barack Mayenga', time: '9:02', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-24' },
],
},
{
id: 's1g4',
date: 'Jun 14, 2026',
time: '3:00 PM',
court: 'Court 02',
my_team: 'Dacula - GA',
opp_team: 'Carrollton - GA',
my_score: 44,
opp_score: 40,
result: 'W',
film_url: 'https://vimeo.com/1201604672',
team_stats: {
my: { pts: 44, ppp: 0.88, ast: 8, reb: 33, oreb: 16, dreb: 17, blk: 1, stl: 4, to: 9, deflections: 4, fouls: 12, def_fouls: 10, charges: 0, kills: 4, efg_pct: '35.2%', to_pct: '14.3%', oreb_pct: '41.0%', ftr: 0.19, two_made: 10, two_att: 29, two_pct: '34.5%', three_made: 6, three_att: 25, three_pct: '24.0%', ft_made: 6, ft_att: 10, ft_pct: '60.0%', scoring_opps: 60, shots: 54, ft_trips: 6, two_rate: '53.7%', three_rate: '46.3%', ft: '6/10' },
opp: { pts: 40, ppp: 0.78, ast: 12, reb: 31, oreb: 8, dreb: 23, blk: 3, stl: 4, to: 12, deflections: 5, fouls: 9, def_fouls: 9, charges: 0, kills: 5, efg_pct: '39.0%', to_pct: '22.6%', oreb_pct: '32.0%', ftr: 0.22, two_made: 13, two_att: 30, two_pct: '43.3%', three_made: 2, three_att: 11, three_pct: '18.2%', ft_made: 8, ft_att: 9, ft_pct: '88.9%', scoring_opps: 46, shots: 41, ft_trips: 5, two_rate: '73.2%', three_rate: '26.8%', ft: '8/9' },
},
my_players: [
{ num: '0', name: 'Jerry Levine', time: '12:35', pts: 6, reb: 1, ast: 0, blk: 0, stl: 1, to: 0, two_a: '0/2', three_a: '2/5', ft_a: '0/0', efg: '42.9%', pm: '0' },
{ num: '1', name: 'Luke Anderson', time: '14:29', pts: 0, reb: 2, ast: 1, blk: 0, stl: 0, to: 1, two_a: '0/2', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-2' },
{ num: '3', name: 'Dorian Douglas', time: '27:34', pts: 4, reb: 3, ast: 4, blk: 0, stl: 0, to: 2, two_a: '1/4', three_a: '0/4', ft_a: '2/2', efg: '12.5%', pm: '+5', starter: true },
{ num: '4', name: 'Omari Alleyne', time: '23:04', pts: 3, reb: 5, ast: 1, blk: 0, stl: 1, to: 2, two_a: '0/5', three_a: '1/5', ft_a: '0/0', efg: '15.0%', pm: '+11', starter: true },
{ num: '5', name: 'Joshua Brown', time: '26:54', pts: 7, reb: 3, ast: 1, blk: 0, stl: 1, to: 3, two_a: '2/3', three_a: '1/5', ft_a: '0/1', efg: '43.8%', pm: '+5', starter: true },
{ num: '10', name: 'Antonio Case Presley', time: '27:09', pts: 22, reb: 8, ast: 1, blk: 1, stl: 1, to: 1, two_a: '6/10', three_a: '2/5', ft_a: '4/7', efg: '60.0%', pm: '+7', starter: true },
{ num: '23', name: 'Douglas Finley', time: '7:55', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-7' },
{ num: '24', name: 'Caleb Golding', time: '15:15', pts: 2, reb: 6, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/3', three_a: '0/0', ft_a: '0/0', efg: '33.3%', pm: '+1', starter: true },
],
opp_players: [
{ num: '0', name: 'Rhylan Ellison', time: '24:45', pts: 2, reb: 2, ast: 0, blk: 0, stl: 2, to: 1, two_a: '1/4', three_a: '0/2', ft_a: '0/0', efg: '16.7%', pm: '+2', starter: true },
{ num: '1', name: 'Dre Steele', time: '16:18', pts: 0, reb: 5, ast: 2, blk: 0, stl: 0, to: 1, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+1', starter: true },
{ num: '2', name: 'John Clinton', time: '22:29', pts: 5, reb: 1, ast: 3, blk: 0, stl: 0, to: 2, two_a: '0/4', three_a: '1/5', ft_a: '2/2', efg: '16.7%', pm: '-2' },
{ num: '3', name: 'Urhiyah George', time: '29:09', pts: 17, reb: 6, ast: 2, blk: 1, stl: 0, to: 3, two_a: '5/7', three_a: '1/1', ft_a: '4/5', efg: '81.2%', pm: '-6', starter: true },
{ num: '4', name: 'Adein Dobbs', time: '12:33', pts: 0, reb: 2, ast: 3, blk: 1, stl: 1, to: 1, two_a: '0/2', three_a: '0/2', ft_a: '0/0', efg: '0%', pm: '-10' },
{ num: '5', name: 'Peyton Moore', time: '28:23', pts: 12, reb: 5, ast: 1, blk: 1, stl: 1, to: 1, two_a: '5/9', three_a: '0/1', ft_a: '2/2', efg: '50.0%', pm: '-2', starter: true },
{ num: '35', name: 'Billy Brimer', time: '14:00', pts: 4, reb: 6, ast: 1, blk: 0, stl: 0, to: 2, two_a: '2/3', three_a: '0/0', ft_a: '0/0', efg: '66.7%', pm: '+1', starter: true },
],
},
];
// ── Session 2: June 26-28, 2026 ──────────────────────────────────────────────
const SESSION_2_GAMES = [
{
id: 's2g1',
date: 'Jun 26, 2026',
time: '2:00 PM',
court: 'Court 10',
my_team: 'Dacula HS - GA',
opp_team: 'Episcopal School of Jax - FL',
my_score: 61,
opp_score: 60,
result: 'W',
film_url: 'https://vimeo.com/1205612488',
team_stats: {
my: { pts: 61, ppp: 1.17, ast: 13, reb: 21, oreb: 12, dreb: 9, blk: 0, stl: 10, to: 8, deflections: 14, fouls: 21, def_fouls: 17, charges: 0, kills: 3, efg_pct: '51.1%', to_pct: '14.8%', oreb_pct: '42.9%', ftr: 0.39, two_made: 7, two_att: 22, two_pct: '31.8%', three_made: 11, three_att: 24, three_pct: '45.8%', ft_made: 14, ft_att: 18, ft_pct: '77.8%', scoring_opps: 56, shots: 46, ft_trips: 10, two_rate: '47.8%', three_rate: '52.2%', ft: '14/18' },
opp: { pts: 60, ppp: 1.11, ast: 14, reb: 22, oreb: 6, dreb: 16, blk: 2, stl: 3, to: 14, deflections: 5, fouls: 14, def_fouls: 14, charges: 0, kills: 5, efg_pct: '64.9%', to_pct: '27.5%', oreb_pct: '40.0%', ftr: 0.43, two_made: 15, two_att: 23, two_pct: '65.2%', three_made: 6, three_att: 14, three_pct: '42.9%', ft_made: 12, ft_att: 16, ft_pct: '75.0%', scoring_opps: 46, shots: 37, ft_trips: 9, two_rate: '62.2%', three_rate: '37.8%', ft: '12/16' },
},
my_players: [
{ num: '0', name: 'Jerry Levine', time: '18:20', pts: 9, reb: 2, ast: 1, blk: 0, stl: 1, to: 0, two_a: '0/3', three_a: '3/6', ft_a: '0/0', efg: '50.0%', pm: '-5' },
{ num: '1', name: 'Luke Anderson', time: '13:09', pts: 0, reb: 1, ast: 3, blk: 0, stl: 3, to: 1, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+15', starter: true },
{ num: '2', name: 'Kamari Trotter', time: '17:57', pts: 9, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/3', three_a: '3/5', ft_a: '0/0', efg: '56.2%', pm: '+14' },
{ num: '3', name: 'Dorian Douglas', time: '25:53', pts: 4, reb: 2, ast: 3, blk: 0, stl: 2, to: 0, two_a: '0/2', three_a: '0/3', ft_a: '0/0', efg: '0%', pm: '-3', starter: true },
{ num: '4', name: 'Omari Alleyne', time: '16:07', pts: 5, reb: 5, ast: 1, blk: 0, stl: 1, to: 3, two_a: '1/1', three_a: '1/1', ft_a: '0/0', efg: '125.0%', pm: '-5', starter: true },
{ num: '10', name: 'Antonio Case Presley', time: '26:43', pts: 24, reb: 3, ast: 0, blk: 0, stl: 1, to: 2, two_a: '3/8', three_a: '4/9', ft_a: '0/0', efg: '52.9%', pm: '+3', starter: true },
{ num: '11', name: '#11', time: '4:18', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-6' },
{ num: '14', name: 'Tristen Dixon', time: '2:57', pts: 2, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/1', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '-9' },
{ num: '23', name: 'Douglas Finley', time: '4:39', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-11' },
{ num: '24', name: 'Caleb Golding', time: '19:53', pts: 6, reb: 5, ast: 5, blk: 0, stl: 2, to: 0, two_a: '1/2', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+12', starter: true },
],
opp_players: [
{ num: '0', name: 'Noah Omalley', time: '20:57', pts: 2, reb: 1, ast: 3, blk: 0, stl: 0, to: 1, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '+3' },
{ num: '2', name: 'Rowan Myles', time: '28:30', pts: 4, reb: 7, ast: 0, blk: 2, stl: 1, to: 2, two_a: '1/3', three_a: '0/0', ft_a: '0/0', efg: '33.3%', pm: '-8', starter: true },
{ num: '3', name: 'Johnny Froats', time: '17:21', pts: 2, reb: 0, ast: 5, blk: 0, stl: 0, to: 1, two_a: '1/1', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '-3', starter: true },
{ num: '4', name: 'Drew Jackson', time: '28:10', pts: 18, reb: 5, ast: 4, blk: 0, stl: 1, to: 3, two_a: '5/6', three_a: '1/5', ft_a: '0/0', efg: '59.1%', pm: '-1', starter: true },
{ num: '11', name: 'Hays Jackson', time: '15:45', pts: 8, reb: 2, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/2', three_a: '2/3', ft_a: '0/0', efg: '60.0%', pm: '-7' },
{ num: '15', name: 'James Ryan', time: '4:33', pts: 3, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '1/2', ft_a: '0/0', efg: '75.0%', pm: '+9' },
{ num: '33', name: 'Will Rydzewski', time: '24:55', pts: 23, reb: 2, ast: 0, blk: 0, stl: 1, to: 6, two_a: '8/10', three_a: '2/3', ft_a: '0/0', efg: '84.6%', pm: '+1', starter: true },
],
},
{
id: 's2g2',
date: 'Jun 26, 2026',
time: '4:00 PM',
court: 'Court 09',
my_team: 'Dacula HS - GA',
opp_team: 'Hazel Green - AL',
my_score: 49,
opp_score: 57,
result: 'L',
film_url: 'https://vimeo.com/1205607146',
team_stats: {
my: { pts: 49, ppp: 0.89, ast: 8, reb: 30, oreb: 11, dreb: 19, blk: 0, stl: 3, to: 6, deflections: 6, fouls: 14, def_fouls: 14, charges: 0, kills: 3, efg_pct: '41.8%', to_pct: '9.8%', oreb_pct: '28.2%', ftr: 0.20, two_made: 14, two_att: 31, two_pct: '45.2%', three_made: 6, three_att: 24, three_pct: '25.0%', ft_made: 3, ft_att: 11, ft_pct: '27.3%', scoring_opps: 60, shots: 55, ft_trips: 5, two_rate: '56.4%', three_rate: '43.6%', ft: '3/11' },
opp: { pts: 57, ppp: 1.02, ast: 7, reb: 39, oreb: 11, dreb: 28, blk: 2, stl: 2, to: 8, deflections: 4, fouls: 10, def_fouls: 9, charges: 0, kills: 4, efg_pct: '49.0%', to_pct: '14.3%', oreb_pct: '36.7%', ftr: 0.44, two_made: 22, two_att: 41, two_pct: '53.7%', three_made: 1, three_att: 7, three_pct: '14.3%', ft_made: 10, ft_att: 21, ft_pct: '47.6%', scoring_opps: 59, shots: 48, ft_trips: 11, two_rate: '85.4%', three_rate: '14.6%', ft: '10/21' },
},
my_players: [
{ num: '0', name: 'Jerry Levine', time: '15:57', pts: 9, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '2/6', ft_a: '0/0', efg: '50.0%', pm: '-1' },
{ num: '1', name: 'Luke Anderson', time: '15:49', pts: 6, reb: 4, ast: 0, blk: 0, stl: 0, to: 0, two_a: '3/7', three_a: '0/3', ft_a: '0/0', efg: '30.0%', pm: '-6', starter: true },
{ num: '2', name: 'Kamari Trotter', time: '12:18', pts: 3, reb: 3, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/2', three_a: '1/2', ft_a: '0/0', efg: '37.5%', pm: '-10' },
{ num: '3', name: 'Dorian Douglas', time: '23:55', pts: 4, reb: 2, ast: 3, blk: 0, stl: 0, to: 2, two_a: '2/4', three_a: '0/1', ft_a: '0/0', efg: '40.0%', pm: '0', starter: true },
{ num: '4', name: 'Omari Alleyne', time: '18:24', pts: 3, reb: 3, ast: 1, blk: 0, stl: 1, to: 2, two_a: '0/0', three_a: '1/2', ft_a: '0/0', efg: '75.0%', pm: '-4', starter: true },
{ num: '10', name: 'Antonio Case Presley', time: '25:26', pts: 16, reb: 5, ast: 1, blk: 0, stl: 1, to: 1, two_a: '5/11', three_a: '2/9', ft_a: '0/0', efg: '40.0%', pm: '0', starter: true },
{ num: '11', name: '#11', time: '8:58', pts: 0, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/2', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-4' },
{ num: '14', name: 'Tristen Dixon', time: '5:44', pts: 0, reb: 2, ast: 0, blk: 0, stl: 1, to: 0, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-5' },
{ num: '23', name: 'Douglas Finley', time: '4:23', pts: 0, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-2' },
{ num: '24', name: 'Caleb Golding', time: '19:00', pts: 6, reb: 5, ast: 2, blk: 0, stl: 0, to: 0, two_a: '3/3', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '-8', starter: true },
],
opp_players: [
{ num: '0', name: 'Jaden Hasberry', time: '23:03', pts: 19, reb: 3, ast: 3, blk: 1, stl: 0, to: 2, two_a: '8/12', three_a: '1/4', ft_a: '0/0', efg: '59.4%', pm: '+17', starter: true },
{ num: '1', name: 'Kareem Thomas', time: '19:02', pts: 6, reb: 8, ast: 0, blk: 1, stl: 0, to: 2, two_a: '2/2', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+13', starter: true },
{ num: '2', name: 'Christian Allen', time: '19:07', pts: 12, reb: 7, ast: 1, blk: 0, stl: 1, to: 1, two_a: '5/11', three_a: '0/0', ft_a: '0/0', efg: '45.5%', pm: '+11', starter: true },
{ num: '4', name: 'Brayden Baker', time: '17:43', pts: 4, reb: 4, ast: 1, blk: 0, stl: 0, to: 1, two_a: '2/2', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+17', starter: true },
{ num: '5', name: 'Braylin Brown', time: '12:43', pts: 2, reb: 5, ast: 0, blk: 0, stl: 0, to: 1, two_a: '1/3', three_a: '0/0', ft_a: '0/0', efg: '33.3%', pm: '-1' },
{ num: '10', name: 'Christian Willingham', time: '16:11', pts: 8, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/3', three_a: '0/2', ft_a: '0/0', efg: '20.0%', pm: '-5' },
{ num: '24', name: 'Hayden Hyatt', time: '20:58', pts: 6, reb: 3, ast: 2, blk: 0, stl: 0, to: 0, two_a: '3/6', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+13', starter: true },
],
},
{
id: 's2g3',
date: 'Jun 28, 2026',
time: '9:00 AM',
court: 'Court 10',
my_team: 'Dacula HS - GA',
opp_team: 'North Mecklenburg - NC',
my_score: 52,
opp_score: 40,
result: 'W',
film_url: 'https://vimeo.com/1205613868',
team_stats: {
my: { pts: 52, ppp: 0.87, ast: 12, reb: 22, oreb: 7, dreb: 15, blk: 1, stl: 9, to: 20, deflections: 12, fouls: 16, def_fouls: 13, charges: 0, kills: 10, efg_pct: '51.3%', to_pct: '33.9%', oreb_pct: '30.4%', ftr: 0.59, two_made: 11, two_att: 28, two_pct: '39.3%', three_made: 6, three_att: 11, three_pct: '54.5%', ft_made: 12, ft_att: 23, ft_pct: '52.2%', scoring_opps: 51, shots: 39, ft_trips: 12, two_rate: '71.8%', three_rate: '28.2%', ft: '12/23' },
opp: { pts: 40, ppp: 0.65, ast: 9, reb: 27, oreb: 11, dreb: 16, blk: 1, stl: 3, to: 23, deflections: 4, fouls: 19, def_fouls: 16, charges: 0, kills: 5, efg_pct: '32.6%', to_pct: '33.3%', oreb_pct: '42.3%', ftr: 0.26, two_made: 6, two_att: 21, two_pct: '28.6%', three_made: 6, three_att: 25, three_pct: '24.0%', ft_made: 10, ft_att: 12, ft_pct: '83.3%', scoring_opps: 52, shots: 46, ft_trips: 6, two_rate: '45.7%', three_rate: '54.3%', ft: '10/12' },
},
my_players: [
{ num: '0', name: 'Jerry Levine', time: '7:53', pts: 3, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '1/2', ft_a: '0/0', efg: '50.0%', pm: '-1' },
{ num: '1', name: 'Luke Anderson', time: '17:28', pts: 9, reb: 3, ast: 0, blk: 0, stl: 2, to: 1, two_a: '1/1', three_a: '1/1', ft_a: '0/0', efg: '125.0%', pm: '0', starter: true },
{ num: '2', name: 'Kamari Trotter', time: '16:23', pts: 9, reb: 5, ast: 1, blk: 0, stl: 2, to: 3, two_a: '3/5', three_a: '1/1', ft_a: '0/0', efg: '75.0%', pm: '+12' },
{ num: '3', name: 'Dorian Douglas', time: '19:03', pts: 6, reb: 1, ast: 2, blk: 0, stl: 0, to: 2, two_a: '2/5', three_a: '0/0', ft_a: '0/0', efg: '40.0%', pm: '+5', starter: true },
{ num: '4', name: 'Omari Alleyne', time: '23:21', pts: 6, reb: 3, ast: 4, blk: 0, stl: 2, to: 5, two_a: '0/3', three_a: '2/3', ft_a: '0/0', efg: '50.0%', pm: '+17', starter: true },
{ num: '10', name: 'Antonio Case Presley', time: '26:52', pts: 15, reb: 5, ast: 3, blk: 0, stl: 2, to: 5, two_a: '3/8', three_a: '1/3', ft_a: '0/0', efg: '40.9%', pm: '+17', starter: true },
{ num: '11', name: '#11', time: '3:27', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-3' },
{ num: '14', name: 'Tristen Dixon', time: '4:45', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-1' },
{ num: '23', name: 'Douglas Finley', time: '3:43', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-2' },
{ num: '24', name: 'Caleb Golding', time: '27:28', pts: 4, reb: 4, ast: 2, blk: 1, stl: 1, to: 2, two_a: '2/4', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+16', starter: true },
],
opp_players: [
{ num: '3', name: 'Alek Lewandowski', time: '23:06', pts: 8, reb: 4, ast: 0, blk: 0, stl: 0, to: 3, two_a: '1/1', three_a: '2/11', ft_a: '0/0', efg: '33.3%', pm: '-12', starter: true },
{ num: '10', name: 'DJ Lindsey', time: '25:35', pts: 12, reb: 4, ast: 3, blk: 0, stl: 0, to: 5, two_a: '0/4', three_a: '2/10', ft_a: '0/0', efg: '21.4%', pm: '-6', starter: true },
{ num: '11', name: "Cha' Den Traylor", time: '24:25', pts: 0, reb: 3, ast: 3, blk: 0, stl: 0, to: 4, two_a: '0/2', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-13', starter: true },
{ num: '24', name: 'Ethan Pack', time: '18:56', pts: 5, reb: 5, ast: 0, blk: 0, stl: 1, to: 2, two_a: '0/4', three_a: '1/1', ft_a: '0/0', efg: '30.0%', pm: '-13', starter: true },
{ num: '50', name: 'Kingsley Ojukwu', time: '5:25', pts: 6, reb: 2, ast: 0, blk: 0, stl: 1, to: 0, two_a: '3/3', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+6' },
],
},
{
id: 's2g4',
date: 'Jun 28, 2026',
time: '1:00 PM',
court: 'Court 07',
my_team: 'Dacula HS - GA',
opp_team: 'Chapel Hill HS - GA',
my_score: null,
opp_score: null,
result: null,
film_url: 'https://vimeo.com/1205601287',
team_stats: null,
my_players: [],
opp_players: [],
},
];
// ── Sub-components ────────────────────────────────────────────────────────────
function PlayerTable({ players, teamLabel, slugMap }) {
if (!players || players.length === 0) return null;
return (
<div className="overflow-x-auto">
<p className="text-xs font-bold uppercase tracking-widest mb-2 px-1" style={{ color: ORANGE }}>{teamLabel}</p>
<table className="w-full text-xs min-w-[700px]">
<thead>
<tr className="border-b" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
{['#', 'Player', 'Time', 'PTS', 'REB', 'AST', 'BLK', 'STL', 'TO', '2Pt/A', '3Pt/A', 'FT/A', 'EFG%', '+/-'].map(h => (
<th key={h} className="text-left py-1.5 px-1 text-gray-600 font-bold uppercase tracking-wider whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{players.map((p, i) => {
const slug = slugMap[p.name];
return (
<tr key={i} className="border-b" style={{ borderColor: 'rgba(255,255,255,0.03)' }}>
<td className="py-1.5 px-1 text-gray-500">{p.num}</td>
<td className="py-1.5 px-1 text-white font-medium whitespace-nowrap">
{slug ? (
<Link to={`/player/${slug}`} className="hover:text-orange-400 transition-colors">{p.name}</Link>
) : p.name}
{p.starter ? <span className="text-orange-500 ml-0.5">*</span> : null}
</td>
<td className="py-1.5 px-1 text-gray-500">{p.time}</td>
<td className="py-1.5 px-1 font-bold text-white">{p.pts}</td>
<td className="py-1.5 px-1 text-gray-300">{p.reb}</td>
<td className="py-1.5 px-1 text-gray-300">{p.ast}</td>
<td className="py-1.5 px-1 text-gray-400">{p.blk}</td>
<td className="py-1.5 px-1 text-gray-400">{p.stl}</td>
<td className="py-1.5 px-1 text-gray-400">{p.to}</td>
<td className="py-1.5 px-1 text-gray-400">{p.two_a}</td>
<td className="py-1.5 px-1 text-gray-400">{p.three_a}</td>
<td className="py-1.5 px-1 text-gray-400">{p.ft_a}</td>
<td className="py-1.5 px-1 text-gray-400">{p.efg}</td>
<td className={`py-1.5 px-1 font-bold ${p.pm && p.pm.startsWith('+') ? 'text-green-400' : p.pm && p.pm !== '0' ? 'text-red-400' : 'text-gray-500'}`}>{p.pm}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function DaculaTeamStats({ game }) {
const [expanded, setExpanded] = useState(false);
if (!game.team_stats) return null;
const my = game.team_stats.my;
const opp = game.team_stats.opp;
const parseFt = (ftStr) => {
if (!ftStr || !ftStr.includes('/')) return { made: '—', att: '—', pct: '—' };
const [made, att] = ftStr.split('/').map(Number);
const pct = att > 0 ? ((made / att) * 100).toFixed(1) + '%' : '—';
return { made, att, pct };
};
const myFt = parseFt(my.ft);
const oppFt = parseFt(opp.ft);
const fmt = (v) => (v == null || v === '') ? '—' : v;
const summaryStats = [
{ label: 'AST', my: my.ast, opp: opp.ast },
{ label: 'REB', my: my.reb, opp: opp.reb },
{ label: 'eFG%', my: my.efg_pct, opp: opp.efg_pct },
{ label: 'FTA', my: myFt.att, opp: oppFt.att },
{ label: 'FT%', my: myFt.pct, opp: oppFt.pct },
{ label: '2FG%', my: my.two_pct, opp: opp.two_pct },
{ label: '3FG%', my: my.three_pct, opp: opp.three_pct },
{ label: 'TO', my: my.to, opp: opp.to },
];
const fullStats = [
{ label: 'Points', my: my.pts, opp: opp.pts },
{ label: 'Pts Per Possession', my: my.ppp, opp: opp.ppp },
{ label: '— Four Factors —', header: true },
{ label: 'Eff. FG%', my: my.efg_pct, opp: opp.efg_pct },
{ label: 'Turnover %', my: my.to_pct, opp: opp.to_pct },
{ label: 'Off. Reb %', my: my.oreb_pct, opp: opp.oreb_pct },
{ label: 'Free Throw Rate', my: my.ftr, opp: opp.ftr },
{ label: '— Shooting —', header: true },
{ label: '2 Pt (M/A)', my: my.two_made != null ? `${my.two_made}/${my.two_att}` : null, opp: opp.two_made != null ? `${opp.two_made}/${opp.two_att}` : null },
{ label: '2 Pt %', my: my.two_pct, opp: opp.two_pct },
{ label: '3 Pt (M/A)', my: my.three_made != null ? `${my.three_made}/${my.three_att}` : null, opp: opp.three_made != null ? `${opp.three_made}/${opp.three_att}` : null },
{ label: '3 Pt %', my: my.three_pct, opp: opp.three_pct },
{ label: 'Free Throws (M/A)', my: my.ft_made != null ? `${my.ft_made}/${my.ft_att}` : null, opp: opp.ft_made != null ? `${opp.ft_made}/${opp.ft_att}` : null },
{ label: 'FT %', my: my.ft_pct, opp: opp.ft_pct },
{ label: 'Scoring Opps', my: my.scoring_opps, opp: opp.scoring_opps },
{ label: 'Total Shots', my: my.shots, opp: opp.shots },
{ label: 'FT Trips', my: my.ft_trips, opp: opp.ft_trips },
{ label: '2 Pt Rate', my: my.two_rate, opp: opp.two_rate },
{ label: '3 Pt Rate', my: my.three_rate, opp: opp.three_rate },
{ label: '— Team Play —', header: true },
{ label: 'Assists', my: my.ast, opp: opp.ast },
{ label: 'Turnovers', my: my.to, opp: opp.to },
{ label: 'Turnover %', my: my.to_pct, opp: opp.to_pct },
{ label: 'Steals', my: my.stl, opp: opp.stl },
{ label: 'Blocks', my: my.blk, opp: opp.blk },
{ label: 'Deflections', my: my.deflections, opp: opp.deflections },
{ label: 'Fouls', my: my.fouls, opp: opp.fouls },
{ label: 'Def. Fouls', my: my.def_fouls, opp: opp.def_fouls },
{ label: 'Charges Taken', my: my.charges, opp: opp.charges },
{ label: 'Kills (3 stops)', my: my.kills, opp: opp.kills },
{ label: '— Rebounding —', header: true },
{ label: 'Total Rebounds', my: my.reb, opp: opp.reb },
{ label: 'Off. Rebounds', my: my.oreb, opp: opp.oreb },
{ label: 'Def. Rebounds', my: my.dreb, opp: opp.dreb },
{ label: 'Off. Reb %', my: my.oreb_pct, opp: opp.oreb_pct },
];
return (
<div>
{/* Scoreboard */}
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
<div className="text-center flex-1">
<p className="text-xs font-bold uppercase tracking-wider truncate" style={{ color: ORANGE }}>{game.my_team}</p>
<p className="text-4xl font-black" style={{ color: ORANGE }}>{fmt(my.pts)}</p>
</div>
<div className="text-gray-600 font-bold text-sm px-4">vs</div>
<div className="text-center flex-1">
<p className="text-xs font-bold uppercase tracking-wider truncate text-gray-400">{game.opp_team}</p>
<p className="text-4xl font-black text-white">{fmt(opp.pts)}</p>
</div>
</div>
{/* Summary stats grid — my team */}
<div className="px-5 pt-4 pb-2">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: ORANGE }}>{game.my_team}</p>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{summaryStats.map(s => (
<div key={s.label} className="text-center">
<div className="text-base font-black text-white">{fmt(s.my)}</div>
<div className="text-[10px] text-gray-600 uppercase tracking-wider leading-tight">{s.label}</div>
</div>
))}
</div>
</div>
{/* Summary stats grid — opponent */}
<div className="px-5 pt-2 pb-3 border-b" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
<p className="text-xs font-bold uppercase tracking-widest mb-3 text-gray-500">{game.opp_team}</p>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{summaryStats.map(s => (
<div key={s.label} className="text-center">
<div className="text-base font-black text-gray-400">{fmt(s.opp)}</div>
<div className="text-[10px] text-gray-700 uppercase tracking-wider leading-tight">{s.label}</div>
</div>
))}
</div>
</div>
{/* Full Game Stats toggle */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-center gap-2 w-full py-3 text-xs font-bold uppercase tracking-widest border-b transition-colors"
style={{ borderColor: 'rgba(255,255,255,0.05)', color: expanded ? ORANGE : 'rgba(255,255,255,0.35)' }}
>
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
Full Game Stats
</button>
{/* Expanded full stats table */}
{expanded && (
<div className="px-4 pb-4">
<div className="grid grid-cols-3 gap-2 py-2 text-xs font-bold uppercase tracking-wider border-b mb-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
<div style={{ color: ORANGE }} className="truncate">{game.my_team}</div>
<div className="text-center text-gray-600">Stat</div>
<div className="text-right text-gray-500 truncate">{game.opp_team}</div>
</div>
{fullStats.map(s => s.header ? (
<div key={s.label} className="text-xs font-black uppercase tracking-widest mt-3 mb-1 px-1" style={{ color: ORANGE }}>{s.label.replace(/—/g, '').trim()}</div>
) : (
<div key={s.label} className="grid grid-cols-3 gap-2 py-1.5 rounded-lg px-1" style={{ background: 'rgba(255,255,255,0.02)' }}>
<div className="text-sm font-bold text-white">{fmt(s.my)}</div>
<div className="text-center text-xs text-gray-600 self-center">{s.label}</div>
<div className="text-sm font-bold text-right text-gray-300">{fmt(s.opp)}</div>
</div>
))}
</div>
)}
</div>
);
}
function GameCard({ game }) {
const [open, setOpen] = useState(false);
const [showMyPlayers, setShowMyPlayers] = useState(false);
const [showOppPlayers, setShowOppPlayers] = useState(false);
const resultColor = game.result === 'W' ? '#4ade80' : game.result === 'L' ? '#f87171' : '#9ca3af';
return (
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'rgba(255,255,255,0.07)', background: '#0a0a0a' }}>
{/* Header */}
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-white/[0.02] transition-colors text-left"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{game.result && (
<span className="text-xs font-black px-1.5 py-0.5 rounded shrink-0" style={{ background: `${resultColor}22`, color: resultColor }}>
{game.result}
</span>
)}
<p className="font-bold text-sm truncate">
<span style={{ color: ORANGE }}>{game.my_team}</span>
<span className="text-gray-500 mx-1">vs</span>
<span className="text-white">{game.opp_team}</span>
<span className="text-gray-600 ml-2">· {game.date} · {game.time}</span>
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
{game.film_url && (
<a href={game.film_url} target="_blank" rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="p-1.5 rounded-lg text-gray-500 hover:text-white transition-colors"
style={{ background: 'rgba(255,255,255,0.05)' }}>
<ExternalLink className="w-3 h-3" />
</a>
)}
{open ? <ChevronUp className="w-4 h-4 text-gray-500" /> : <ChevronDown className="w-4 h-4 text-gray-500" />}
</div>
</button>
{open && (
<div className="border-t" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
{/* Game Film */}
{game.film_url && (
<div className="aspect-video bg-black">
<iframe
src={vimeoEmbed(game.film_url)}
className="w-full h-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
title={`${game.my_team} vs ${game.opp_team}`}
/>
</div>
)}
{/* Team Stats */}
<DaculaTeamStats game={game} />
{/* Player Stats toggles */}
{(game.my_players?.length > 0 || game.opp_players?.length > 0) && (
<div className="px-4 pb-4 pt-2 space-y-3">
{game.my_players?.length > 0 && (
<div>
<button
onClick={() => setShowMyPlayers(o => !o)}
className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest w-full py-2 border-t transition-colors"
style={{ borderColor: 'rgba(255,255,255,0.07)', color: showMyPlayers ? ORANGE : 'rgba(255,255,255,0.4)' }}
>
<Users className="w-3.5 h-3.5" />
{game.my_team} Player Stats
{showMyPlayers ? <ChevronUp className="w-3.5 h-3.5 ml-auto" /> : <ChevronDown className="w-3.5 h-3.5 ml-auto" />}
</button>
{showMyPlayers && <PlayerTable players={game.my_players} teamLabel={game.my_team} slugMap={DACULA_PORTFOLIO_SLUGS} />}
</div>
)}
{game.opp_players?.length > 0 && (
<div>
<button
onClick={() => setShowOppPlayers(o => !o)}
className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest w-full py-2 border-t transition-colors"
style={{ borderColor: 'rgba(255,255,255,0.07)', color: showOppPlayers ? ORANGE : 'rgba(255,255,255,0.4)' }}
>
<Users className="w-3.5 h-3.5" />
{game.opp_team} Player Stats
{showOppPlayers ? <ChevronUp className="w-3.5 h-3.5 ml-auto" /> : <ChevronDown className="w-3.5 h-3.5 ml-auto" />}
</button>
{showOppPlayers && <PlayerTable players={game.opp_players} teamLabel={game.opp_team} slugMap={{}} />}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
function SessionSection({ title, games, defaultOpen = false }) {
const [open, setOpen] = useState(defaultOpen);
const wins = games.filter(g => g.result === 'W').length;
const losses = games.filter(g => g.result === 'L').length;
return (
<div className="rounded-2xl border overflow-hidden" style={{ borderColor: 'rgba(255,255,255,0.07)', background: '#050505' }}>
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-white/[0.02] transition-colors text-left"
>
<div>
<p className="font-barlow font-black text-lg uppercase tracking-wide text-white">{title}</p>
<p className="text-xs text-gray-500 mt-0.5">
{games.length} games · <span className="text-green-400 font-bold">{wins}W</span> <span className="text-red-400 font-bold">{losses}L</span>
</p>
</div>
{open ? <ChevronUp className="w-5 h-5" style={{ color: ORANGE }} /> : <ChevronDown className="w-5 h-5 text-gray-500" />}
</button>
{open && (
<div className="border-t px-4 pb-4 pt-3 space-y-3" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
{games.map(game => (
<GameCard key={game.id} game={game} />
))}
</div>
)}
</div>
);
}
export default function DaculaGameStats() {
return (
<div className="space-y-4">
<SessionSection
title="GBCA Live Period — Session 1 · June 13–14, 2026"
games={SESSION_1_GAMES}
defaultOpen={false}
/>
<SessionSection
title="GBCA Live Period — Session 2 SE Regional · June 26–28, 2026"
games={SESSION_2_GAMES}
defaultOpen={true}
/>
<p className="text-xs text-gray-700 text-center pt-2">Stats powered by Hoopsalytics · * = Starter</p>
</div>
);
}src/components/georgia/GameStatsPanel.jsx import { useState, useRef } from 'react';
import { ChevronDown, ChevronUp, Lock, Download } from 'lucide-react';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
const ORANGE = '#FF6A00';
// Stats shown by default (free)
const PRIMARY_STATS = [
{ key: 'pts', label: 'PTS', format: 'num' },
{ key: 'ast', label: 'AST', format: 'num' },
{ key: 'oreb', label: 'OREB', format: 'num' },
{ key: 'efg_pct', label: 'eFG%', format: 'pct' },
{ key: 'ft_att', label: 'FTA', format: 'num' },
{ key: 'ft_pct', label: 'FT%', format: 'pct' },
{ key: 'two_fg_pct', label: '2FG%', format: 'pct' },
{ key: 'three_fg_pct', label: '3FG%', format: 'pct' },
{ key: 'to', label: 'TO', format: 'num' },
];
// Full comparison stats (side-by-side, gated)
const FULL_STATS = [
{ key: 'pts', label: 'Points' },
{ key: 'ast', label: 'Assists' },
{ key: 'to', label: 'Turnovers' },
{ key: 'oreb', label: 'Off. Rebounds' },
{ key: 'dreb', label: 'Def. Rebounds' },
{ key: 'efg_pct', label: 'eFG%' },
{ key: 'two_fg_pct', label: '2FG%' },
{ key: 'three_fg_pct', label: '3FG%' },
{ key: 'ft_pct', label: 'FT%' },
{ key: 'ft_att', label: 'FT Attempts' },
{ key: 'ft_made', label: 'FT Made' },
{ key: 'two_pt_made', label: '2PT Made' },
{ key: 'two_pt_att', label: '2PT Attempts' },
{ key: 'three_pt_made', label: '3PT Made' },
{ key: 'three_pt_att', label: '3PT Attempts' },
{ key: 'shots', label: 'Total Shots' },
{ key: 'opps', label: 'Scoring Opps' },
{ key: 'ato_ratio', label: 'A/TO Ratio' },
{ key: 'oreb_pct', label: 'OREB%' },
{ key: 'ts_pct', label: 'TS%' },
];
function fmt(val) {
if (val == null) return '—';
return val;
}
function better(key, myVal, oppVal) {
if (myVal == null || oppVal == null) return null;
const parseV = v => parseFloat(String(v).replace('%', ''));
const m = parseV(myVal);
const o = parseV(oppVal);
if (isNaN(m) || isNaN(o) || m === o) return null;
const lowerBetter = ['to'];
return lowerBetter.includes(key) ? (m < o ? 'my' : 'opp') : (m > o ? 'my' : 'opp');
}
export default function GameStatsPanel({ game, myTeamName, oppTeamName, isPurchased = false }) {
const [expanded, setExpanded] = useState(false);
const statsRef = useRef(null);
const printPDF = async () => {
if (!statsRef.current) return;
try {
const canvas = await html2canvas(statsRef.current, { backgroundColor: '#060606', scale: 2 });
const pdf = new jsPDF('p', 'mm', 'a4');
const imgData = canvas.toDataURL('image/png');
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = (canvas.height * pdfWidth) / canvas.width;
pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight);
pdf.save(`${myTeamName}_vs_${oppTeamName}_stats.pdf`);
} catch (e) {
alert('Error generating PDF: ' + e.message);
}
};
const normalize = (name) => name?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const teamStats = game.team_stats || {};
const mySlug = normalize(myTeamName);
const oppSlug = normalize(oppTeamName);
const myStats = teamStats[mySlug] || teamStats[myTeamName];
const oppStats = teamStats[oppSlug] || teamStats[oppTeamName];
if (!myStats && !oppStats) return null;
return (
<div ref={statsRef} className="border-t" style={{ borderColor: 'rgba(255,255,255,0.05)', background: '#060606' }}>
{/* Score header */}
{myStats && oppStats && (
<div className="flex items-center justify-between px-5 py-3 border-b" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
<div className="text-center flex-1">
<p className="text-xs font-bold uppercase tracking-wider truncate" style={{ color: ORANGE }}>{myTeamName}</p>
<p className="text-3xl font-black text-white">{fmt(myStats.pts)}</p>
</div>
<div className="text-gray-600 font-bold text-sm px-4">vs</div>
<div className="text-center flex-1">
<p className="text-xs font-bold uppercase tracking-wider truncate text-gray-400">{oppTeamName}</p>
<p className="text-3xl font-black text-gray-300">{fmt(oppStats.pts)}</p>
</div>
</div>
)}
{/* Filter stats: preview for unclaimed, full for purchased */}
{(() => {
const previewKeys = ['efg_pct', 'to', 'oreb', 'ft_att'];
const statsToShow = isPurchased
? PRIMARY_STATS.filter(s => s.key !== 'pts')
: PRIMARY_STATS.filter(s => previewKeys.includes(s.key) || !s.teamBased);
const gridCols = isPurchased ? 'grid-cols-5 sm:grid-cols-11' : 'grid-cols-4';
return (
<>
{/* Primary stats grid — my team */}
{myStats && (
<div className="px-5 pt-4 pb-2">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: ORANGE }}>{myTeamName}</p>
<div className={`grid ${gridCols} gap-2`}>
{statsToShow.map(stat => {
const value = myStats[stat.key];
return (
<div key={stat.key} className="text-center">
<div className="text-base font-black text-white">{fmt(value)}</div>
<div className="text-xs text-gray-600 uppercase tracking-wider leading-tight">{stat.label}</div>
</div>
);
})}
</div>
</div>
)}
{/* Primary stats grid — opponent */}
{oppStats && (
<div className="px-5 pt-2 pb-3 border-b" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
<p className="text-xs font-bold uppercase tracking-widest mb-3 text-gray-500">{oppTeamName}</p>
<div className={`grid ${gridCols} gap-2`}>
{statsToShow.map(stat => {
const value = oppStats[stat.key];
return (
<div key={stat.key} className="text-center">
<div className="text-base font-black text-gray-400">{fmt(value)}</div>
<div className="text-xs text-gray-700 uppercase tracking-wider leading-tight">{stat.label}</div>
</div>
);
})}
</div>
</div>
)}
</>
);
})()}
{/* Full Game Stats toggle + Print button */}
<div className="flex items-center justify-center gap-3 py-3 text-xs font-bold uppercase tracking-widest border-b" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-center gap-2 flex-1 transition-colors"
style={{ color: expanded ? ORANGE : 'rgba(255,255,255,0.35)' }}
>
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
Full Game Stats
</button>
<button
onClick={printPDF}
className="flex items-center justify-center gap-1 px-3 py-2 rounded-lg transition-colors"
style={{ background: 'rgba(255,106,0,0.1)', color: ORANGE }}
title="Download stats as PDF"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
{/* Side-by-side full comparison */}
{expanded && (
<div className="px-4 pb-4">
<div className="space-y-1">
{/* Header */}
<div className="grid grid-cols-3 gap-2 py-2 text-xs font-bold uppercase tracking-wider border-b mb-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
<div style={{ color: ORANGE }} className="truncate">{myTeamName}</div>
<div className="text-center text-gray-600">Stat</div>
<div className="text-right text-gray-500 truncate">{oppTeamName}</div>
</div>
{FULL_STATS.map(stat => {
const myVal = myStats?.[stat.key];
const oppVal = oppStats?.[stat.key];
const win = better(stat.key, myVal, oppVal);
return (
<div key={stat.key} className="grid grid-cols-3 gap-2 py-1.5 rounded-lg px-1" style={{ background: 'rgba(255,255,255,0.02)' }}>
<div className={`text-sm font-bold ${win === 'my' ? 'text-white' : 'text-gray-500'}`}>{fmt(myVal)}</div>
<div className="text-center text-xs text-gray-600 self-center">{stat.label}</div>
<div className={`text-sm font-bold text-right ${win === 'opp' ? 'text-gray-300' : 'text-gray-600'}`}>{fmt(oppVal)}</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}src/components/georgia/HeroSection.jsx import { motion } from 'framer-motion';
import { ArrowRight, Zap } from 'lucide-react';
const CYAN = '#00D9FF';
const CORAL = '#FF6B5B';
const NAVY = '#1A3B5C';
const ORANGE = '#FF6A00';
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.2,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 40 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
},
};
export default function HeroSection() {
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-24 pb-12">
{/* Subtle gradient accent */}
<div className="absolute -top-20 left-1/4 w-64 h-64 rounded-full blur-3xl opacity-20 pointer-events-none"
style={{ background: `radial-gradient(circle, ${CYAN} 0%, transparent 70%)` }} />
{/* Content */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="relative z-10 max-w-5xl mx-auto px-6 text-center"
>
{/* Eyebrow badge */}
<motion.div
variants={itemVariants}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-full mb-8 backdrop-blur-sm"
style={{
background: `rgba(0,217,255,0.1)`,
border: `1.5px solid ${CYAN}`,
boxShadow: `0 0 20px rgba(0,217,255,0.3)`,
}}
>
<Zap className="w-4 h-4" style={{ color: CYAN }} />
<span className="text-xs font-black uppercase tracking-widest" style={{ color: CYAN }}>
Georgia High School Boys Showcase
</span>
</motion.div>
{/* Main headline */}
<motion.div variants={itemVariants} className="mb-8">
<h1 className="font-barlow font-black leading-[1.1]" style={{ fontSize: 'clamp(56px, 9vw, 120px)', letterSpacing: '0.005em' }}>
<span style={{ color: '#FFFFFF' }}>INVEST IN</span><br />
<span style={{ display: 'inline-block', marginTop: '8px', background: 'linear-gradient(90deg, #6B7CED 0%, #A78BFA 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>YOUR ATHLETE'S</span><br />
<span style={{ display: 'inline-block', marginTop: '8px', background: 'linear-gradient(90deg, #FF6A00 0%, #FF1493 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>FUTURE</span>
</h1>
</motion.div>
{/* Subheadline */}
<motion.p
variants={itemVariants}
className="text-lg sm:text-xl max-w-2xl mx-auto mb-10 leading-relaxed font-light"
style={{ color: 'rgba(255,255,255,0.75)' }}
>
Professional-grade film coverage, recruiting portfolios, and analytics infrastructure built for Georgia State Boys Basketball. Transform exposure into opportunity.
</motion.p>
{/* CTA Buttons */}
<motion.div variants={itemVariants} className="flex flex-col sm:flex-row gap-4 justify-center mb-4">
<button
onClick={() => document.getElementById('packages')?.scrollIntoView({ behavior: 'smooth' })}
className="group relative inline-flex items-center justify-center gap-3 px-8 py-4 rounded-lg text-sm font-black uppercase tracking-wide transition-all duration-300 overflow-hidden"
style={{
background: ORANGE,
color: '#0D0D0D',
boxShadow: `0 8px 24px rgba(0,0,0,0.3)`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = `0 12px 32px rgba(0,0,0,0.4)`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = `0 8px 24px rgba(0,0,0,0.3)`;
}}
>
<span>View Packages</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</button>
<a
href="https://profxpo.com/chase-titus"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 px-8 py-4 rounded-lg text-sm font-semibold transition-all duration-300"
style={{
border: `1.5px solid ${CYAN}`,
color: CYAN,
background: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `rgba(0,217,255,0.1)`;
e.currentTarget.style.boxShadow = `0 0 20px rgba(0,217,255,0.3)`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.boxShadow = 'none';
}}
>
See Live Portfolio
<ArrowRight className="w-4 h-4" />
</a>
</motion.div>
{/* Trust indicators */}
<motion.div
variants={itemVariants}
className="flex flex-wrap justify-center gap-6 mt-12 pt-8 border-t"
style={{ borderColor: 'rgba(0,217,255,0.15)' }}
>
{[
{ label: 'Real Athletes', value: '1000+' },
{ label: 'Games Filmed', value: '700+' },
{ label: 'Coaches Reached', value: 'D1-NAIA' },
].map((stat, i) => (
<div key={i} className="text-center">
<div className="text-2xl sm:text-3xl font-black" style={{ color: CYAN }}>
{stat.value}
</div>
<div className="text-xs uppercase tracking-widest mt-1" style={{ color: 'rgba(255,255,255,0.5)' }}>
{stat.label}
</div>
</div>
))}
</motion.div>
</motion.div>
{/* Animated line accent */}
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ delay: 0.8, duration: 1.2, ease: 'easeOut' }}
className="absolute bottom-0 left-0 right-0 h-px"
style={{ background: `linear-gradient(90deg, transparent, ${CYAN}, transparent)` }}
/>
</section>
);
}src/components/georgia/InteractiveGradientBg.jsx import { useEffect, useRef } from 'react';
export default function InteractiveGradientBg() {
const canvasRef = useRef(null);
const posRef = useRef({ x: 0, y: 0 });
const smoothPosRef = useRef({ x: 0, y: 0 });
const animationRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
let resizeTimeout;
const handleResize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
const handleMouseMove = (e) => {
posRef.current = { x: e.clientX, y: e.clientY };
};
const handleTouchMove = (e) => {
if (e.touches.length > 0) {
posRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
};
const animate = () => {
// Smooth interpolation for fluid effect
smoothPosRef.current.x += (posRef.current.x - smoothPosRef.current.x) * 0.08;
smoothPosRef.current.y += (posRef.current.y - smoothPosRef.current.y) * 0.08;
// Clear canvas
ctx.fillStyle = '#0D0D0D';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Create gradient layers
const grd1 = ctx.createRadialGradient(smoothPosRef.current.x, smoothPosRef.current.y, 0, smoothPosRef.current.x, smoothPosRef.current.y, 400);
grd1.addColorStop(0, 'rgba(212, 175, 55, 0.15)');
grd1.addColorStop(0.5, 'rgba(212, 175, 55, 0.05)');
grd1.addColorStop(1, 'rgba(212, 175, 55, 0)');
// Complementary glow offset
const offsetX = smoothPosRef.current.x - canvas.width / 2;
const offsetY = smoothPosRef.current.y - canvas.height / 2;
const cx2 = canvas.width / 2 - offsetX * 0.5;
const cy2 = canvas.height / 2 - offsetY * 0.5;
const grd2 = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, 500);
grd2.addColorStop(0, 'rgba(212, 175, 55, 0.08)');
grd2.addColorStop(1, 'rgba(212, 175, 55, 0)');
ctx.fillStyle = grd1;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = grd2;
ctx.fillRect(0, 0, canvas.width, canvas.height);
animationRef.current = requestAnimationFrame(animate);
};
handleResize();
window.addEventListener('resize', handleResize);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('touchmove', handleTouchMove, { passive: true });
animate();
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchmove', handleTouchMove);
if (animationRef.current) cancelAnimationFrame(animationRef.current);
clearTimeout(resizeTimeout);
};
}, []);
return (
<canvas
ref={canvasRef}
className="fixed top-0 left-0 w-full h-full pointer-events-none"
style={{ zIndex: 0 }}
/>
);
}src/components/georgia/MariettaGameStats.jsx import { useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronDown, ChevronUp, Users, ExternalLink } from 'lucide-react';
const ORANGE = '#FF6A00';
// Marietta player portfolio slug map
const MARIETTA_PORTFOLIO_SLUGS = {
'Peyton Easley': 'peyton-easley',
'Quinton Smith': 'quinton-smith',
'Tyeric Randolph': 'tyeric-randolph',
'Julian Lanier': 'julian-lanier',
'Howell Owen': 'howell-owen',
'Easton Hicks': 'easton-hicks',
'Braylon Whitfield': 'braylon-whitfield',
'Merrick Ham': 'merrick-ham',
'Kylen Thomas': 'kylen-thomas',
'Oliver Purifoy': 'oliver-purifoy',
'Zachary Viola': 'zachary-viola',
};
function vimeoEmbed(url) {
const m = url?.match(/vimeo\.com\/(\d+)/);
return m ? `https://player.vimeo.com/video/${m[1]}` : url;
}
function normalizeTeam(name) {
return (name || '')
.toLowerCase()
.replace(/\s*-\s*[a-z]{2}$/, '')
.replace(/[^a-z0-9]/g, '');
}
function toISODate(dateStr) {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toISOString().split('T')[0];
}
function teamsMatch(a, b) {
const na = normalizeTeam(a);
const nb = normalizeTeam(b);
return na === nb || na.includes(nb) || nb.includes(na);
}
// ── Session 1: June 12-13, 2026 ──────────────────────────────────────────────
const SESSION_1_GAMES = [
{
id: 's1g1',
date: 'Jun 12, 2026',
time: '2:00 PM',
court: 'Court 03',
my_team: 'Marietta - GA',
opp_team: 'East Coweta - GA',
my_score: 71,
opp_score: 61,
result: 'W',
film_url: 'https://vimeo.com/1201941477',
team_stats: {
my: { pts: 71, ppp: 1.08, ast: 12, reb: 42, oreb: 15, dreb: 27, blk: 5, stl: 5, to: 13, deflections: 8, fouls: 11, def_fouls: 10, charges: 0, kills: 3, efg_pct: '49.1%', to_pct: '18.6%', oreb_pct: '46.9%', ftr: 0.33, two_made: 22, two_att: 45, two_pct: '48.9%', three_made: 4, three_att: 12, three_pct: '33.3%', ft_made: 15, ft_att: 19, ft_pct: '78.9%', scoring_opps: 68, shots: 57, ft_trips: 11, two_rate: '78.9%', three_rate: '21.1%', ft: '15/19' },
opp: { pts: 61, ppp: 0.92, ast: 8, reb: 25, oreb: 8, dreb: 17, blk: 6, stl: 8, to: 10, deflections: 8, fouls: 13, def_fouls: 13, charges: 0, kills: 4, efg_pct: '46.6%', to_pct: '14.7%', oreb_pct: '22.9%', ftr: 0.21, two_made: 21, two_att: 45, two_pct: '46.7%', three_made: 4, three_att: 13, three_pct: '30.8%', ft_made: 7, ft_att: 12, ft_pct: '58.3%', scoring_opps: 65, shots: 58, ft_trips: 7, two_rate: '77.6%', three_rate: '22.4%', ft: '7/12' },
},
my_players: [
{ num: '3', name: 'Peyton Easley', time: '15:28', pts: 7, reb: 2, ast: 0, blk: 0, stl: 0, to: 1, two_a: '1/2', three_a: '1/3', ft_a: '2/2', efg: '50.0%', pm: '+15' },
{ num: '4', name: 'Quinton Smith', time: '15:00', pts: 10, reb: 2, ast: 3, blk: 1, stl: 1, to: 2, two_a: '3/6', three_a: '0/0', ft_a: '4/4', efg: '50.0%', pm: '-2' },
{ num: '11', name: 'Tyeric Randolph', time: '22:20', pts: 17, reb: 17, ast: 1, blk: 2, stl: 0, to: 1, two_a: '8/15', three_a: '0/0', ft_a: '1/1', efg: '53.3%', pm: '+17', starter: true },
{ num: '12', name: 'Julian Lanier', time: '19:23', pts: 4, reb: 3, ast: 3, blk: 0, stl: 0, to: 2, two_a: '1/1', three_a: '0/1', ft_a: '2/2', efg: '50.0%', pm: '+3', starter: true },
{ num: '15', name: 'Easton Hicks', time: '2:10', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-5' },
{ num: '21', name: 'Braylon Whitfield', time: '29:00', pts: 16, reb: 5, ast: 3, blk: 0, stl: 3, to: 1, two_a: '6/7', three_a: '0/0', ft_a: '4/6', efg: '85.7%', pm: '+8', starter: true },
{ num: '22', name: 'Merrick Ham', time: '25:38', pts: 11, reb: 4, ast: 0, blk: 2, stl: 0, to: 2, two_a: '1/5', three_a: '3/8', ft_a: '0/0', efg: '42.3%', pm: '+9', starter: true },
{ num: '24', name: 'Kylen Thomas', time: '18:02', pts: 6, reb: 4, ast: 2, blk: 0, stl: 1, to: 2, two_a: '2/8', three_a: '0/0', ft_a: '2/3', efg: '25.0%', pm: '+5', starter: true },
{ num: '32', name: 'Oliver Purifoy', time: '2:55', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/1', three_a: '0/0', ft_a: '0/1', efg: '0%', pm: '0' },
],
opp_players: [
{ num: '0', name: 'Chris Williams', time: '4:17', pts: 0, reb: 1, ast: 1, blk: 0, stl: 2, to: 2, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+1' },
{ num: '1', name: 'David Askew', time: '23:55', pts: 17, reb: 6, ast: 1, blk: 4, stl: 3, to: 0, two_a: '7/13', three_a: '0/0', ft_a: '3/3', efg: '53.8%', pm: '-3', starter: true },
{ num: '2', name: 'Evan Haskins', time: '3:38', pts: 2, reb: 2, ast: 0, blk: 1, stl: 0, to: 0, two_a: '1/2', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+3' },
{ num: '3', name: 'Armari Owens', time: '29:09', pts: 17, reb: 6, ast: 1, blk: 1, stl: 0, to: 2, two_a: '6/11', three_a: '1/2', ft_a: '2/4', efg: '57.7%', pm: '-12', starter: true },
{ num: '4', name: 'Momodou Ceesay', time: '16:09', pts: 4, reb: 1, ast: 2, blk: 0, stl: 0, to: 2, two_a: '2/4', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '-11', starter: true },
{ num: '11', name: 'AJ McKissic', time: '17:48', pts: 2, reb: 1, ast: 1, blk: 0, stl: 1, to: 0, two_a: '1/3', three_a: '0/3', ft_a: '0/1', efg: '16.7%', pm: '+5', starter: true },
{ num: '12', name: 'Jamarcus Alford', time: '18:15', pts: 5, reb: 0, ast: 0, blk: 0, stl: 1, to: 2, two_a: '1/1', three_a: '1/2', ft_a: '0/2', efg: '83.3%', pm: '-9' },
{ num: '13', name: 'Jevin Finley', time: '8:06', pts: 0, reb: 1, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-14' },
{ num: '24', name: 'Braylon Marshall', time: '28:40', pts: 14, reb: 4, ast: 1, blk: 0, stl: 1, to: 2, two_a: '3/10', three_a: '2/6', ft_a: '2/2', efg: '37.5%', pm: '-10', starter: true },
],
},
{
id: 's1g2',
date: 'Jun 12, 2026',
time: '7:00 PM',
court: 'Court 05',
my_team: 'Marietta - GA',
opp_team: 'East Forsyth - GA',
my_score: 64,
opp_score: 56,
result: 'W',
film_url: 'https://vimeo.com/1201664031',
team_stats: {
my: { pts: 64, ppp: 1.12, ast: 11, reb: 30, oreb: 12, dreb: 18, blk: 0, stl: 2, to: 8, deflections: 6, fouls: 15, def_fouls: 13, charges: 0, kills: 5, efg_pct: '43.0%', to_pct: '13.8%', oreb_pct: '38.7%', ftr: 0.46, two_made: 14, two_att: 31, two_pct: '45.2%', three_made: 5, three_att: 19, three_pct: '26.3%', ft_made: 21, ft_att: 23, ft_pct: '91.3%', scoring_opps: 62, shots: 50, ft_trips: 12, two_rate: '62.0%', three_rate: '38.0%', ft: '21/23' },
opp: { pts: 56, ppp: 0.98, ast: 12, reb: 26, oreb: 7, dreb: 19, blk: 0, stl: 4, to: 13, deflections: 6, fouls: 19, def_fouls: 18, charges: 0, kills: 0, efg_pct: '52.3%', to_pct: '22.8%', oreb_pct: '28.0%', ftr: 0.36, two_made: 11, two_att: 24, two_pct: '45.8%', three_made: 8, three_att: 20, three_pct: '40.0%', ft_made: 10, ft_att: 16, ft_pct: '62.5%', scoring_opps: 52, shots: 44, ft_trips: 8, two_rate: '54.5%', three_rate: '45.5%', ft: '10/16' },
},
my_players: [
{ num: '3', name: 'Peyton Easley', time: '11:18', pts: 11, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/3', three_a: '0/3', ft_a: '9/9', efg: '16.7%', pm: '+13' },
{ num: '4', name: 'Quinton Smith', time: '14:34', pts: 3, reb: 6, ast: 1, blk: 0, stl: 0, to: 1, two_a: '0/5', three_a: '1/1', ft_a: '0/0', efg: '25.0%', pm: '+7' },
{ num: '11', name: 'Tyeric Randolph', time: '20:52', pts: 4, reb: 2, ast: 0, blk: 0, stl: 1, to: 1, two_a: '2/4', three_a: '0/2', ft_a: '0/0', efg: '33.3%', pm: '+1', starter: true },
{ num: '12', name: 'Julian Lanier', time: '18:41', pts: 8, reb: 1, ast: 3, blk: 0, stl: 0, to: 2, two_a: '0/1', three_a: '2/3', ft_a: '2/2', efg: '75.0%', pm: '-5', starter: true },
{ num: '14', name: 'Howell Owen', time: '9:51', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-3' },
{ num: '15', name: 'Easton Hicks', time: '4:16', pts: 0, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-2' },
{ num: '21', name: 'Braylon Whitfield', time: '23:53', pts: 17, reb: 3, ast: 3, blk: 0, stl: 3, to: 2, two_a: '4/7', three_a: '1/2', ft_a: '6/6', efg: '61.1%', pm: '+12', starter: true },
{ num: '22', name: 'Merrick Ham', time: '26:34', pts: 7, reb: 8, ast: 2, blk: 0, stl: 1, to: 2, two_a: '2/2', three_a: '1/7', ft_a: '0/0', efg: '38.9%', pm: '+3', starter: true },
{ num: '24', name: 'Kylen Thomas', time: '19:56', pts: 14, reb: 6, ast: 1, blk: 0, stl: 2, to: 0, two_a: '5/9', three_a: '0/0', ft_a: '4/6', efg: '55.6%', pm: '+14', starter: true },
],
opp_players: [
{ num: '1', name: 'Kani Hollis', time: '0:38', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+3' },
{ num: '4', name: 'Cooper Elzey', time: '23:33', pts: 2, reb: 1, ast: 2, blk: 0, stl: 0, to: 2, two_a: '1/2', three_a: '0/1', ft_a: '0/2', efg: '33.3%', pm: '-10', starter: true },
{ num: '5', name: 'Miles Thompson', time: '11:35', pts: 2, reb: 1, ast: 2, blk: 0, stl: 0, to: 2, two_a: '1/1', three_a: '0/0', ft_a: '0/2', efg: '100.0%', pm: '+2' },
{ num: '10', name: 'Kamani Hollis', time: '24:52', pts: 11, reb: 3, ast: 2, blk: 0, stl: 1, to: 2, two_a: '1/2', three_a: '3/5', ft_a: '0/0', efg: '78.6%', pm: '-10', starter: true },
{ num: '13', name: 'Landan Ennis', time: '11:34', pts: 2, reb: 1, ast: 0, blk: 0, stl: 0, to: 1, two_a: '1/2', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+4' },
{ num: '14', name: 'Gavin Lesch', time: '26:54', pts: 8, reb: 6, ast: 2, blk: 0, stl: 2, to: 4, two_a: '1/6', three_a: '2/10', ft_a: '0/0', efg: '25.0%', pm: '-8', starter: true },
{ num: '23', name: 'Reece Styles', time: '24:12', pts: 19, reb: 5, ast: 2, blk: 0, stl: 0, to: 1, two_a: '3/4', three_a: '3/4', ft_a: '4/4', efg: '93.8%', pm: '-11', starter: true },
{ num: '24', name: 'Kaiden Long', time: '26:38', pts: 12, reb: 5, ast: 2, blk: 0, stl: 1, to: 1, two_a: '3/7', three_a: '0/0', ft_a: '6/8', efg: '42.9%', pm: '-10', starter: true },
],
},
{
id: 's1g3',
date: 'Jun 13, 2026',
time: '9:00 AM',
court: 'Court 08',
my_team: 'Marietta - GA',
opp_team: 'Burke County - GA',
my_score: 69,
opp_score: 66,
result: 'W',
film_url: 'https://vimeo.com/1201616389',
team_stats: {
my: { pts: 69, ppp: 1.05, ast: 17, reb: 37, oreb: 14, dreb: 23, blk: 3, stl: 8, to: 18, deflections: 6, fouls: 20, def_fouls: 17, charges: 0, kills: 7, efg_pct: '49.1%', to_pct: '24.7%', oreb_pct: '45.2%', ftr: 0.27, two_made: 18, two_att: 37, two_pct: '48.6%', three_made: 6, three_att: 18, three_pct: '33.3%', ft_made: 15, ft_att: 15, ft_pct: '100.0%', scoring_opps: 63, shots: 55, ft_trips: 8, two_rate: '67.3%', three_rate: '32.7%', ft: '15/15' },
opp: { pts: 66, ppp: 1.0, ast: 10, reb: 28, oreb: 11, dreb: 17, blk: 3, stl: 2, to: 14, deflections: 8, fouls: 10, def_fouls: 10, charges: 2, kills: 5, efg_pct: '44.2%', to_pct: '21.2%', oreb_pct: '32.4%', ftr: 0.5, two_made: 17, two_att: 37, two_pct: '45.9%', three_made: 4, three_att: 15, three_pct: '26.7%', ft_made: 20, ft_att: 26, ft_pct: '76.9%', scoring_opps: 65, shots: 52, ft_trips: 13, two_rate: '71.2%', three_rate: '28.8%', ft: '20/26' },
},
my_players: [
{ num: '3', name: 'Peyton Easley', time: '8:47', pts: 5, reb: 2, ast: 1, blk: 0, stl: 1, to: 2, two_a: '1/2', three_a: '1/2', ft_a: '0/0', efg: '62.5%', pm: '+4' },
{ num: '4', name: 'Quinton Smith', time: '17:16', pts: 8, reb: 5, ast: 0, blk: 0, stl: 1, to: 4, two_a: '3/5', three_a: '0/1', ft_a: '2/2', efg: '50.0%', pm: '0' },
{ num: '11', name: 'Tyeric Randolph', time: '20:14', pts: 8, reb: 7, ast: 0, blk: 1, stl: 1, to: 0, two_a: '3/3', three_a: '0/0', ft_a: '2/2', efg: '100.0%', pm: '0', starter: true },
{ num: '12', name: 'Julian Lanier', time: '24:25', pts: 6, reb: 2, ast: 5, blk: 0, stl: 2, to: 4, two_a: '1/4', three_a: '0/3', ft_a: '4/4', efg: '14.3%', pm: '-1', starter: true },
{ num: '14', name: 'Howell Owen', time: '4:11', pts: 4, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/2', three_a: '0/1', ft_a: '2/2', efg: '33.3%', pm: '+13' },
{ num: '15', name: 'Easton Hicks', time: '4:17', pts: 0, reb: 0, ast: 1, blk: 0, stl: 1, to: 1, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-9' },
{ num: '21', name: 'Braylon Whitfield', time: '24:37', pts: 9, reb: 5, ast: 6, blk: 0, stl: 0, to: 3, two_a: '3/8', three_a: '0/1', ft_a: '3/3', efg: '33.3%', pm: '-5', starter: true },
{ num: '22', name: 'Merrick Ham', time: '23:53', pts: 21, reb: 8, ast: 1, blk: 1, stl: 0, to: 0, two_a: '3/7', three_a: '5/9', ft_a: '0/0', efg: '65.6%', pm: '+3', starter: true },
{ num: '24', name: 'Kylen Thomas', time: '20:19', pts: 8, reb: 3, ast: 3, blk: 1, stl: 2, to: 4, two_a: '3/6', three_a: '0/1', ft_a: '2/2', efg: '42.9%', pm: '+14', starter: true },
{ num: '32', name: 'Oliver Purifoy', time: '1:57', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-4' },
],
opp_players: [
{ num: '14', name: 'DeQuayvian Lovette', time: '27:02', pts: 22, reb: 7, ast: 0, blk: 0, stl: 0, to: 7, two_a: '3/14', three_a: '3/9', ft_a: '7/7', efg: '32.6%', pm: '-2', starter: true },
{ num: '20', name: 'Braylen Bellamy', time: '28:49', pts: 27, reb: 1, ast: 6, blk: 0, stl: 2, to: 3, two_a: '6/10', three_a: '1/4', ft_a: '12/16', efg: '53.6%', pm: '-3', starter: true },
{ num: '22', name: 'Marquez Sims', time: '27:10', pts: 0, reb: 4, ast: 2, blk: 0, stl: 1, to: 1, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '+2', starter: true },
{ num: '30', name: 'Jukobe Lovette', time: '7:14', pts: 0, reb: 1, ast: 1, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-6' },
{ num: '34', name: 'Richard Farmer', time: '4:25', pts: 0, reb: 1, ast: 0, blk: 1, stl: 2, to: 0, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-5' },
{ num: '42', name: 'Darrien Tullis', time: '27:10', pts: 8, reb: 5, ast: 1, blk: 1, stl: 1, to: 0, two_a: '4/5', three_a: '0/0', ft_a: '0/0', efg: '80.0%', pm: '+2', starter: true },
{ num: '44', name: 'Kendrick Carter', time: '11:13', pts: 2, reb: 1, ast: 0, blk: 1, stl: 1, to: 0, two_a: '1/2', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+2' },
{ num: '50', name: 'Takirious Reeves', time: '16:51', pts: 7, reb: 5, ast: 0, blk: 0, stl: 0, to: 2, two_a: '3/4', three_a: '0/0', ft_a: '1/3', efg: '75.0%', pm: '-5', starter: true },
],
},
{
id: 's1g4',
date: 'Jun 13, 2026',
time: '12:00 PM',
court: 'Court 08',
my_team: 'Marietta - GA',
opp_team: 'Mill Creek - GA',
my_score: 64,
opp_score: 60,
result: 'W',
film_url: 'https://vimeo.com/1201941684',
team_stats: {
my: { pts: 64, ppp: 1.05, ast: 17, reb: 33, oreb: 9, dreb: 24, blk: 2, stl: 4, to: 9, deflections: 8, fouls: 11, def_fouls: 9, charges: 0, kills: 4, efg_pct: '52.8%', to_pct: '14.5%', oreb_pct: '29.0%', ftr: 0.36, two_made: 25, two_att: 43, two_pct: '58.1%', three_made: 2, three_att: 10, three_pct: '20.0%', ft_made: 8, ft_att: 19, ft_pct: '42.1%', scoring_opps: 66, shots: 53, ft_trips: 13, two_rate: '81.1%', three_rate: '18.9%', ft: '8/19' },
opp: { pts: 60, ppp: 1.02, ast: 12, reb: 29, oreb: 7, dreb: 22, blk: 3, stl: 3, to: 9, deflections: 5, fouls: 19, def_fouls: 18, charges: 1, kills: 4, efg_pct: '49.0%', to_pct: '15.0%', oreb_pct: '22.6%', ftr: 0.22, two_made: 13, two_att: 29, two_pct: '44.8%', three_made: 8, three_att: 22, three_pct: '36.4%', ft_made: 10, ft_att: 11, ft_pct: '90.9%', scoring_opps: 57, shots: 51, ft_trips: 6, two_rate: '56.9%', three_rate: '43.1%', ft: '10/11' },
},
my_players: [
{ num: '3', name: 'Peyton Easley', time: '7:59', pts: 2, reb: 2, ast: 0, blk: 0, stl: 1, to: 4, two_a: '0/0', three_a: '0/2', ft_a: '2/2', efg: '0%', pm: '+3' },
{ num: '4', name: 'Quinton Smith', time: '19:32', pts: 5, reb: 3, ast: 4, blk: 0, stl: 0, to: 0, two_a: '2/3', three_a: '0/0', ft_a: '1/3', efg: '66.7%', pm: '+9' },
{ num: '11', name: 'Tyeric Randolph', time: '12:07', pts: 4, reb: 3, ast: 0, blk: 0, stl: 0, to: 1, two_a: '2/4', three_a: '0/0', ft_a: '0/2', efg: '50.0%', pm: '-2', starter: true },
{ num: '12', name: 'Julian Lanier', time: '25:49', pts: 8, reb: 3, ast: 5, blk: 0, stl: 0, to: 2, two_a: '4/7', three_a: '0/2', ft_a: '0/0', efg: '44.4%', pm: '-1', starter: true },
{ num: '14', name: 'Howell Owen', time: '4:36', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-12' },
{ num: '15', name: 'Easton Hicks', time: '1:37', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+1' },
{ num: '21', name: 'Braylon Whitfield', time: '27:52', pts: 14, reb: 2, ast: 5, blk: 0, stl: 2, to: 1, two_a: '7/10', three_a: '0/1', ft_a: '0/5', efg: '63.6%', pm: '+10', starter: true },
{ num: '22', name: 'Merrick Ham', time: '25:41', pts: 16, reb: 10, ast: 1, blk: 2, stl: 1, to: 0, two_a: '4/8', three_a: '2/5', ft_a: '2/2', efg: '53.8%', pm: '+12', starter: true },
{ num: '24', name: 'Kylen Thomas', time: '23:40', pts: 15, reb: 6, ast: 2, blk: 0, stl: 0, to: 1, two_a: '6/10', three_a: '0/0', ft_a: '3/5', efg: '60.0%', pm: '+2', starter: true },
{ num: '32', name: 'Oliver Purifoy', time: '1:03', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-2' },
],
opp_players: [
{ num: '1', name: 'Christopher Mitchell', time: '23:12', pts: 6, reb: 2, ast: 3, blk: 0, stl: 0, to: 2, two_a: '0/2', three_a: '2/3', ft_a: '0/0', efg: '60.0%', pm: '-10', starter: true },
{ num: '2', name: 'Brandon Bell', time: '23:29', pts: 20, reb: 5, ast: 3, blk: 0, stl: 1, to: 4, two_a: '3/6', three_a: '2/3', ft_a: '8/8', efg: '66.7%', pm: '-4', starter: true },
{ num: '5', name: 'Raymond Buck', time: '7:19', pts: 6, reb: 5, ast: 0, blk: 0, stl: 1, to: 0, two_a: '2/2', three_a: '0/0', ft_a: '2/2', efg: '100.0%', pm: '+4' },
{ num: '10', name: 'Kevin Cochran', time: '1:43', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+2' },
{ num: '12', name: 'Cam Gaines', time: '28:16', pts: 10, reb: 5, ast: 4, blk: 1, stl: 0, to: 0, two_a: '2/4', three_a: '2/9', ft_a: '0/1', efg: '38.5%', pm: '-6', starter: true },
{ num: '13', name: 'Alex Ayala', time: '21:04', pts: 8, reb: 5, ast: 0, blk: 0, stl: 1, to: 0, two_a: '4/8', three_a: '0/1', ft_a: '0/0', efg: '44.4%', pm: '-4' },
{ num: '14', name: 'David Scott', time: '26:33', pts: 8, reb: 6, ast: 1, blk: 2, stl: 0, to: 2, two_a: '1/5', three_a: '2/2', ft_a: '0/0', efg: '57.1%', pm: '-10', starter: true },
{ num: '15', name: 'Ryan Bell', time: '18:20', pts: 2, reb: 0, ast: 1, blk: 0, stl: 0, to: 1, two_a: '1/2', three_a: '0/4', ft_a: '0/0', efg: '16.7%', pm: '+8', starter: true },
],
},
];
// ── Session 2: June 26-28, 2026 ──────────────────────────────────────────────
const SESSION_2_GAMES = [
{
id: 's2g1',
date: 'Jun 26, 2026',
time: '2:00 PM',
court: 'Court 01',
my_team: 'Marietta - GA',
opp_team: 'Edgewater - FL',
my_score: 41,
opp_score: 63,
result: 'L',
film_url: 'https://vimeo.com/1205619916',
team_stats: {
my: { pts: 41, ast: 7, reb: 33, blk: 1, stl: 5, to: 10, efg_pct: '30.4%', two_made: 11, two_att: 28, two_pct: '39.3%', three_made: 3, three_att: 23, three_pct: '13.0%', shots: 51, two_rate: '54.9%', three_rate: '45.1%' },
opp: { pts: 63, ast: 12, reb: 23, blk: 2, stl: 9, to: 6, efg_pct: '59.4%', two_made: 18, two_att: 28, two_pct: '64.3%', three_made: 9, three_att: 25, three_pct: '36.0%', shots: 53, two_rate: '52.8%', three_rate: '47.2%' },
},
my_players: [
{ num: '3', name: 'Peyton Easley', time: '15:47', pts: 0, reb: 3, ast: 2, blk: 0, stl: 1, to: 0, two_a: '0/1', three_a: '0/4', ft_a: '0/0', efg: '0%', pm: '-16' },
{ num: '4', name: 'Quinton Smith', time: '14:53', pts: 3, reb: 5, ast: 2, blk: 0, stl: 0, to: 0, two_a: '1/4', three_a: '0/0', ft_a: '0/0', efg: '25.0%', pm: '-11' },
{ num: '11', name: 'Tyeric Randolph', time: '20:57', pts: 9, reb: 5, ast: 0, blk: 0, stl: 0, to: 0, two_a: '4/8', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '-15', starter: true },
{ num: '12', name: 'Julian Lanier', time: '23:50', pts: 7, reb: 3, ast: 2, blk: 0, stl: 2, to: 4, two_a: '1/5', three_a: '1/3', ft_a: '0/0', efg: '31.2%', pm: '-16', starter: true },
{ num: '14', name: 'Howell Owen', time: '9:30', pts: 0, reb: 0, ast: 0, blk: 1, stl: 0, to: 0, two_a: '0/0', three_a: '0/3', ft_a: '0/0', efg: '0%', pm: '-11' },
{ num: '15', name: 'Easton Hicks', time: '4:00', pts: 4, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '+4' },
{ num: '21', name: 'Braylon Whitfield', time: '22:38', pts: 10, reb: 8, ast: 0, blk: 0, stl: 1, to: 4, two_a: '4/5', three_a: '0/4', ft_a: '0/0', efg: '44.4%', pm: '-14', starter: true },
{ num: '22', name: 'Merrick Ham', time: '21:47', pts: 8, reb: 5, ast: 0, blk: 0, stl: 1, to: 0, two_a: '1/3', three_a: '2/8', ft_a: '0/0', efg: '36.4%', pm: '-16', starter: true },
{ num: '24', name: 'Kylen Thomas', time: '16:33', pts: 0, reb: 4, ast: 1, blk: 0, stl: 0, to: 2, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-15', starter: true },
],
opp_players: [
{ num: '2', name: 'Terrance Walker', time: '24:12', pts: 12, reb: 6, ast: 4, blk: 0, stl: 3, to: 1, two_a: '3/5', three_a: '2/6', ft_a: '0/0', efg: '54.5%', pm: '+22', starter: true },
{ num: '4', name: 'Antonio Richmond', time: '12:27', pts: 0, reb: 0, ast: 0, blk: 0, stl: 2, to: 0, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+4', starter: true },
{ num: '5', name: 'Josiah Brutus', time: '13:34', pts: 4, reb: 2, ast: 1, blk: 0, stl: 0, to: 0, two_a: '2/2', three_a: '0/2', ft_a: '0/0', efg: '50.0%', pm: '+14' },
{ num: '10', name: 'Brandon Coleman', time: '7:37', pts: 2, reb: 2, ast: 0, blk: 0, stl: 0, to: 1, two_a: '1/2', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+7' },
{ num: '11', name: 'Tahj Powery-Malone', time: '16:58', pts: 2, reb: 3, ast: 2, blk: 0, stl: 1, to: 2, two_a: '1/3', three_a: '0/3', ft_a: '0/0', efg: '16.7%', pm: '+10', starter: true },
{ num: '20', name: 'Bryce Benton', time: '14:55', pts: 11, reb: 4, ast: 0, blk: 0, stl: 1, to: 0, two_a: '1/2', three_a: '3/3', ft_a: '0/0', efg: '110.0%', pm: '+5' },
{ num: '21', name: 'Amir Elmahmoud', time: '24:06', pts: 10, reb: 1, ast: 3, blk: 2, stl: 0, to: 1, two_a: '2/2', three_a: '2/5', ft_a: '0/0', efg: '71.4%', pm: '+19', starter: true },
{ num: '23', name: 'Julian Fox', time: '20:11', pts: 18, reb: 3, ast: 1, blk: 0, stl: 1, to: 0, two_a: '6/6', three_a: '2/5', ft_a: '0/0', efg: '81.8%', pm: '+19', starter: true },
{ num: '24', name: 'Demetrick Smith', time: '15:56', pts: 4, reb: 2, ast: 1, blk: 0, stl: 1, to: 1, two_a: '2/5', three_a: '0/1', ft_a: '0/0', efg: '33.3%', pm: '+10' },
],
},
{
id: 's2g2',
date: 'Jun 26, 2026',
time: '8:00 PM',
court: 'Court 11',
my_team: 'Marietta - GA',
opp_team: 'Byrnes - SC',
my_score: 35,
opp_score: 63,
result: 'L',
film_url: 'https://vimeo.com/1205555747',
team_stats: {
my: { pts: 35, ast: 8, reb: 30, blk: 7, stl: 9, to: 15, efg_pct: '29.2%', two_made: 11, two_att: 32, two_pct: '34.4%', three_made: 2, three_att: 16, three_pct: '12.5%', shots: 48, two_rate: '66.7%', three_rate: '33.3%' },
opp: { pts: 63, ast: 12, reb: 36, blk: 2, stl: 9, to: 13, efg_pct: '54.5%', two_made: 14, two_att: 28, two_pct: '50.0%', three_made: 11, three_att: 28, three_pct: '39.3%', shots: 56, two_rate: '50.0%', three_rate: '50.0%' },
},
my_players: [
{ num: '3', name: 'Peyton Easley', time: '15:47', pts: 3, reb: 2, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '1/4', ft_a: '0/0', efg: '37.5%', pm: '-17' },
{ num: '4', name: 'Quinton Smith', time: '17:26', pts: 2, reb: 4, ast: 0, blk: 2, stl: 2, to: 0, two_a: '1/9', three_a: '0/1', ft_a: '0/0', efg: '10.0%', pm: '-16' },
{ num: '10', name: 'Zachary Viola', time: '2:35', pts: 0, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-6' },
{ num: '11', name: 'Tyeric Randolph', time: '20:05', pts: 0, reb: 8, ast: 0, blk: 2, stl: 1, to: 5, two_a: '0/2', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-14', starter: true },
{ num: '12', name: 'Julian Lanier', time: '19:53', pts: 0, reb: 2, ast: 5, blk: 1, stl: 1, to: 0, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-14', starter: true },
{ num: '14', name: 'Howell Owen', time: '6:33', pts: 6, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '2/3', three_a: '0/1', ft_a: '2/2', efg: '50.0%', pm: '-12' },
{ num: '15', name: 'Easton Hicks', time: '3:54', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-6' },
{ num: '21', name: 'Braylon Whitfield', time: '18:25', pts: 9, reb: 6, ast: 1, blk: 2, stl: 0, to: 0, two_a: '4/9', three_a: '0/3', ft_a: '1/3', efg: '33.3%', pm: '-11', starter: true },
{ num: '22', name: 'Merrick Ham', time: '21:52', pts: 9, reb: 5, ast: 0, blk: 0, stl: 1, to: 2, two_a: '3/5', three_a: '1/6', ft_a: '0/0', efg: '40.9%', pm: '-17', starter: true },
{ num: '24', name: 'Kylen Thomas', time: '19:29', pts: 6, reb: 1, ast: 1, blk: 0, stl: 3, to: 7, two_a: '1/2', three_a: '0/0', ft_a: '4/6', efg: '50.0%', pm: '-21', starter: true },
{ num: '32', name: 'Oliver Purifoy', time: '3:54', pts: 0, reb: 1, ast: 0, blk: 0, stl: 1, to: 1, two_a: '0/2', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-6' },
],
opp_players: [
{ num: '0', name: 'Fabian McClintock', time: '12:42', pts: 10, reb: 2, ast: 1, blk: 0, stl: 0, to: 2, two_a: '5/12', three_a: '0/0', ft_a: '0/0', efg: '41.7%', pm: '+7', starter: true },
{ num: '1', name: 'Colt Fowler', time: '23:59', pts: 9, reb: 7, ast: 0, blk: 0, stl: 1, to: 3, two_a: '0/1', three_a: '3/6', ft_a: '0/0', efg: '64.3%', pm: '+20', starter: true },
{ num: '2', name: 'Walker Greene', time: '12:56', pts: 2, reb: 2, ast: 2, blk: 0, stl: 1, to: 1, two_a: '1/1', three_a: '0/5', ft_a: '0/0', efg: '16.7%', pm: '+15' },
{ num: '3', name: 'Kamden Hack', time: '15:40', pts: 3, reb: 6, ast: 1, blk: 0, stl: 1, to: 0, two_a: '0/3', three_a: '1/2', ft_a: '0/0', efg: '30.0%', pm: '+16' },
{ num: '4', name: 'Darius Stover', time: '15:22', pts: 2, reb: 2, ast: 1, blk: 0, stl: 1, to: 2, two_a: '1/1', three_a: '0/1', ft_a: '0/0', efg: '50.0%', pm: '+12' },
{ num: '5', name: 'Josh Dixon', time: '21:42', pts: 16, reb: 5, ast: 2, blk: 0, stl: 2, to: 0, two_a: '2/4', three_a: '4/8', ft_a: '0/0', efg: '66.7%', pm: '+18', starter: true },
{ num: '11', name: 'Kellen Leverett', time: '25:59', pts: 11, reb: 3, ast: 4, blk: 0, stl: 3, to: 0, two_a: '0/0', three_a: '3/6', ft_a: '2/3', efg: '75.0%', pm: '+25', starter: true },
{ num: '12', name: "J'sean Sanders", time: '13:59', pts: 10, reb: 6, ast: 0, blk: 2, stl: 0, to: 2, two_a: '5/6', three_a: '0/0', ft_a: '0/0', efg: '83.3%', pm: '+17', starter: true },
{ num: '20', name: 'Gabe Hinton', time: '2:35', pts: 0, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+6' },
{ num: '23', name: 'Gabe Hicks', time: '5:02', pts: 0, reb: 3, ast: 0, blk: 0, stl: 0, to: 3, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+4' },
],
},
{
id: 's2g3',
date: 'Jun 28, 2026',
time: '11:00 AM',
court: 'Court 07',
my_team: 'Marietta - GA',
opp_team: 'Pembroke Pines Charter - FL',
my_score: 45,
opp_score: 49,
result: 'L',
film_url: 'https://vimeo.com/1205600419',
team_stats: {
my: { pts: 45, efg_pct: '43.0%', two_made: 17, two_att: 32, two_pct: '53.1%', three_made: 1, three_att: 11, three_pct: '9.1%', shots: 43, two_rate: '74.4%', three_rate: '25.6%', reb: 30, ast: 10, blk: 2, stl: 4, to: 18 },
opp: { pts: 49, efg_pct: '57.9%', two_made: 13, two_att: 21, two_pct: '61.9%', three_made: 6, three_att: 17, three_pct: '35.3%', shots: 38, two_rate: '55.3%', three_rate: '44.7%', reb: 17, ast: 12, blk: 0, stl: 12, to: 12 },
},
my_players: [
{ num: '3', name: 'Peyton Easley', time: '4:43', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-10' },
{ num: '4', name: 'Quinton Smith', time: '19:51', pts: 6, reb: 1, ast: 0, blk: 0, stl: 1, to: 2, two_a: '3/5', three_a: '0/0', ft_a: '0/0', efg: '60.0%', pm: '-2' },
{ num: '11', name: 'Tyeric Randolph', time: '19:59', pts: 6, reb: 8, ast: 2, blk: 0, stl: 0, to: 1, two_a: '3/6', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+1', starter: true },
{ num: '12', name: 'Julian Lanier', time: '20:43', pts: 0, reb: 2, ast: 2, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/3', ft_a: '0/0', efg: '0%', pm: '-2', starter: true },
{ num: '14', name: 'Howell Owen', time: '7:52', pts: 0, reb: 1, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/2', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '+3' },
{ num: '15', name: 'Easton Hicks', time: '6:31', pts: 5, reb: 1, ast: 1, blk: 0, stl: 0, to: 1, two_a: '2/2', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '-2' },
{ num: '21', name: 'Braylon Whitfield', time: '27:40', pts: 10, reb: 8, ast: 2, blk: 0, stl: 1, to: 5, two_a: '3/7', three_a: '0/1', ft_a: '0/0', efg: '37.5%', pm: '-8', starter: true },
{ num: '22', name: 'Merrick Ham', time: '21:06', pts: 12, reb: 6, ast: 1, blk: 2, stl: 2, to: 3, two_a: '4/6', three_a: '1/5', ft_a: '0/0', efg: '50.0%', pm: '-4', starter: true },
{ num: '24', name: 'Kylen Thomas', time: '21:30', pts: 6, reb: 2, ast: 1, blk: 0, stl: 0, to: 4, two_a: '2/4', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+4', starter: true },
],
opp_players: [
{ num: '0', name: 'Joaquin Leon', time: '5:24', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-6' },
{ num: '3', name: 'Sebastion Moreno', time: '26:30', pts: 10, reb: 1, ast: 4, blk: 0, stl: 1, to: 0, two_a: '2/3', three_a: '2/4', ft_a: '0/0', efg: '71.4%', pm: '+1', starter: true },
{ num: '4', name: 'Jude Wade', time: '21:02', pts: 6, reb: 1, ast: 1, blk: 0, stl: 0, to: 2, two_a: '3/3', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+3' },
{ num: '5', name: 'Damari Foster', time: '25:25', pts: 7, reb: 1, ast: 2, blk: 0, stl: 1, to: 0, two_a: '1/4', three_a: '1/4', ft_a: '0/0', efg: '31.2%', pm: '+3', starter: true },
{ num: '10', name: 'Naeem Nasai', time: '20:14', pts: 8, reb: 4, ast: 0, blk: 0, stl: 1, to: 1, two_a: '3/3', three_a: '0/1', ft_a: '0/0', efg: '75.0%', pm: '+1', starter: true },
{ num: '12', name: 'Alex Vardakis', time: '29:20', pts: 12, reb: 1, ast: 1, blk: 0, stl: 6, to: 3, two_a: '3/7', three_a: '2/3', ft_a: '0/0', efg: '60.0%', pm: '+9', starter: true },
{ num: '15', name: 'Caio Leopoldo', time: '16:34', pts: 6, reb: 4, ast: 4, blk: 0, stl: 3, to: 5, two_a: '1/1', three_a: '1/4', ft_a: '0/0', efg: '50.0%', pm: '+10', starter: true },
{ num: '25', name: '#25', time: '5:28', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-1' },
],
},
{
id: 's2g4',
date: 'Jun 28, 2026',
time: '1:00 PM',
court: 'Court 06',
my_team: 'Marietta - GA',
opp_team: 'Mater Lakes HS - FL',
my_score: 48,
opp_score: 56,
result: 'L',
film_url: 'https://vimeo.com/1205593334',
team_stats: {
my: { pts: 48, efg_pct: '35.8%', two_made: 16, two_att: 41, two_pct: '39.0%', three_made: 2, three_att: 12, three_pct: '16.7%', shots: 53, two_rate: '77.4%', three_rate: '22.6%', reb: 35, ast: 11, blk: 0, stl: 2, to: 10 },
opp: { pts: 56, efg_pct: '52.4%', two_made: 16, two_att: 23, two_pct: '69.6%', three_made: 4, three_att: 19, three_pct: '21.1%', shots: 42, two_rate: '54.8%', three_rate: '45.2%', reb: 25, ast: 12, blk: 4, stl: 6, to: 8 },
},
my_players: [
{ num: '3', name: 'Peyton Easley', time: '12:45', pts: 0, reb: 1, ast: 0, blk: 0, stl: 1, to: 1, two_a: '0/0', three_a: '0/2', ft_a: '0/0', efg: '0%', pm: '-11' },
{ num: '4', name: 'Quinton Smith', time: '23:24', pts: 6, reb: 3, ast: 2, blk: 0, stl: 0, to: 2, two_a: '2/6', three_a: '0/1', ft_a: '0/0', efg: '28.6%', pm: '-8' },
{ num: '11', name: 'Tyeric Randolph', time: '15:56', pts: 2, reb: 3, ast: 1, blk: 0, stl: 0, to: 0, two_a: '1/1', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+2', starter: true },
{ num: '12', name: 'Julian Lanier', time: '4:23', pts: 0, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-1', starter: true },
{ num: '14', name: 'Howell Owen', time: '12:54', pts: 14, reb: 5, ast: 1, blk: 0, stl: 0, to: 1, two_a: '3/5', three_a: '2/4', ft_a: '0/0', efg: '66.7%', pm: '-1' },
{ num: '15', name: 'Easton Hicks', time: '9:13', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-7' },
{ num: '21', name: 'Braylon Whitfield', time: '24:12', pts: 12, reb: 7, ast: 3, blk: 0, stl: 1, to: 1, two_a: '4/13', three_a: '0/2', ft_a: '0/0', efg: '26.7%', pm: '-6', starter: true },
{ num: '22', name: 'Merrick Ham', time: '26:14', pts: 8, reb: 3, ast: 3, blk: 0, stl: 0, to: 2, two_a: '3/5', three_a: '0/3', ft_a: '0/0', efg: '37.5%', pm: '-8', starter: true },
{ num: '24', name: 'Kylen Thomas', time: '20:54', pts: 6, reb: 5, ast: 0, blk: 0, stl: 0, to: 1, two_a: '3/10', three_a: '0/0', ft_a: '0/0', efg: '30.0%', pm: '0', starter: true },
],
opp_players: [
{ num: '0', name: 'Denver Turner', time: '22:51', pts: 12, reb: 4, ast: 0, blk: 1, stl: 1, to: 1, two_a: '5/8', three_a: '0/2', ft_a: '0/0', efg: '50.0%', pm: '+4', starter: true },
{ num: '1', name: 'Ricardo Diaz', time: '18:11', pts: 9, reb: 2, ast: 0, blk: 0, stl: 1, to: 2, two_a: '2/2', three_a: '1/2', ft_a: '0/0', efg: '87.5%', pm: '+9' },
{ num: '2', name: 'Delan Montgomery', time: '16:50', pts: 10, reb: 6, ast: 1, blk: 2, stl: 2, to: 1, two_a: '3/4', three_a: '0/2', ft_a: '0/0', efg: '50.0%', pm: '+7', starter: true },
{ num: '3', name: 'Julian Perez', time: '18:51', pts: 6, reb: 2, ast: 1, blk: 0, stl: 1, to: 0, two_a: '2/2', three_a: '0/1', ft_a: '0/0', efg: '66.7%', pm: '+2', starter: true },
{ num: '4', name: 'Khamani Pessoa', time: '12:41', pts: 0, reb: 4, ast: 3, blk: 0, stl: 1, to: 1, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+5' },
{ num: '5', name: 'Miguel Orbe', time: '26:09', pts: 15, reb: 3, ast: 4, blk: 0, stl: 0, to: 1, two_a: '2/4', three_a: '3/11', ft_a: '0/0', efg: '43.3%', pm: '+6', starter: true },
{ num: '12', name: 'Charleson Baker', time: '2:30', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-3' },
{ num: '13', name: 'Anthony Torres', time: '23:02', pts: 4, reb: 2, ast: 2, blk: 1, stl: 0, to: 2, two_a: '2/2', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+9', starter: true },
{ num: '15', name: 'Brandon Bloch', time: '8:50', pts: 0, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '+1' },
],
},
];
function PlayerTable({ players, teamLabel }) {
return (
<div className="overflow-x-auto">
<p className="text-xs font-bold uppercase tracking-widest mb-2 px-1" style={{ color: ORANGE }}>{teamLabel}</p>
<table className="w-full text-xs min-w-[700px]">
<thead>
<tr className="border-b" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
{['#', 'Player', 'Time', 'PTS', 'REB', 'AST', 'BLK', 'STL', 'TO', '2Pt/A', '3Pt/A', 'FT/A', 'EFG%', '+/-'].map(h => (
<th key={h} className="text-left py-1.5 px-1 text-gray-600 font-bold uppercase tracking-wider whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{players.map((p, i) => {
const slug = MARIETTA_PORTFOLIO_SLUGS[p.name];
return (
<tr key={i} className="border-b" style={{ borderColor: 'rgba(255,255,255,0.03)' }}>
<td className="py-1.5 px-1 text-gray-500">{p.num}</td>
<td className="py-1.5 px-1 text-white font-medium whitespace-nowrap">
{slug ? (
<Link to={`/player/${slug}`} className="hover:text-orange-400 transition-colors">{p.name}</Link>
) : (
p.name
)}{p.starter ? <span className="text-orange-500 ml-0.5">*</span> : null}
</td>
<td className="py-1.5 px-1 text-gray-500">{p.time}</td>
<td className="py-1.5 px-1 font-bold text-white">{p.pts}</td>
<td className="py-1.5 px-1 text-gray-300">{p.reb}</td>
<td className="py-1.5 px-1 text-gray-300">{p.ast}</td>
<td className="py-1.5 px-1 text-gray-400">{p.blk}</td>
<td className="py-1.5 px-1 text-gray-400">{p.stl}</td>
<td className="py-1.5 px-1 text-gray-400">{p.to}</td>
<td className="py-1.5 px-1 text-gray-400">{p.two_a}</td>
<td className="py-1.5 px-1 text-gray-400">{p.three_a}</td>
<td className="py-1.5 px-1 text-gray-400">{p.ft_a}</td>
<td className="py-1.5 px-1 text-gray-400">{p.efg}</td>
<td className={`py-1.5 px-1 font-bold ${p.pm && p.pm.startsWith('+') ? 'text-green-400' : p.pm && p.pm !== '0' ? 'text-red-400' : 'text-gray-500'}`}>{p.pm}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function MariettaTeamStats({ game }) {
const [expanded, setExpanded] = useState(false);
const my = game.team_stats.my;
const opp = game.team_stats.opp;
const parseFt = (ftStr) => {
if (!ftStr || !ftStr.includes('/')) return { made: '—', att: '—', pct: '—' };
const [made, att] = ftStr.split('/').map(Number);
const pct = att > 0 ? ((made / att) * 100).toFixed(1) + '%' : '—';
return { made, att, pct };
};
const myFt = parseFt(my?.ft);
const oppFt = parseFt(opp?.ft);
const fmt = (v) => (v == null || v === '') ? '—' : v;
const summaryStats = [
{ label: 'AST', my: my?.ast, opp: opp?.ast },
{ label: 'REB', my: my?.reb, opp: opp?.reb },
{ label: 'eFG%', my: my?.efg_pct, opp: opp?.efg_pct },
{ label: 'FTA', my: myFt.att, opp: oppFt.att },
{ label: 'FT%', my: myFt.pct, opp: oppFt.pct },
{ label: '2FG%', my: my?.two_pct, opp: opp?.two_pct },
{ label: '3FG%', my: my?.three_pct, opp: opp?.three_pct },
{ label: 'TO', my: my?.to, opp: opp?.to },
];
const fullStats = [
{ label: 'Points', my: my?.pts, opp: opp?.pts },
{ label: 'Pts Per Possession', my: my?.ppp, opp: opp?.ppp },
{ label: '— Four Factors —', header: true },
{ label: 'Eff. FG%', my: my?.efg_pct, opp: opp?.efg_pct },
{ label: 'Turnover %', my: my?.to_pct, opp: opp?.to_pct },
{ label: 'Off. Reb %', my: my?.oreb_pct, opp: opp?.oreb_pct },
{ label: 'Free Throw Rate', my: my?.ftr, opp: opp?.ftr },
{ label: '— Shooting —', header: true },
{ label: '2 Pt (M/A)', my: my?.two_made != null ? `${my.two_made}/${my.two_att}` : null, opp: opp?.two_made != null ? `${opp.two_made}/${opp.two_att}` : null },
{ label: '2 Pt %', my: my?.two_pct, opp: opp?.two_pct },
{ label: '3 Pt (M/A)', my: my?.three_made != null ? `${my.three_made}/${my.three_att}` : null, opp: opp?.three_made != null ? `${opp.three_made}/${opp.three_att}` : null },
{ label: '3 Pt %', my: my?.three_pct, opp: opp?.three_pct },
{ label: 'Free Throws (M/A)', my: my?.ft_made != null ? `${my.ft_made}/${my.ft_att}` : null, opp: opp?.ft_made != null ? `${opp.ft_made}/${opp.ft_att}` : null },
{ label: 'FT %', my: my?.ft_pct, opp: opp?.ft_pct },
{ label: 'Scoring Opps', my: my?.scoring_opps, opp: opp?.scoring_opps },
{ label: 'Total Shots', my: my?.shots, opp: opp?.shots },
{ label: 'FT Trips', my: my?.ft_trips, opp: opp?.ft_trips },
{ label: '2 Pt Rate', my: my?.two_rate, opp: opp?.two_rate },
{ label: '3 Pt Rate', my: my?.three_rate, opp: opp?.three_rate },
{ label: '— Team Play —', header: true },
{ label: 'Assists', my: my?.ast, opp: opp?.ast },
{ label: 'Turnovers', my: my?.to, opp: opp?.to },
{ label: 'Turnover %', my: my?.to_pct, opp: opp?.to_pct },
{ label: 'Steals', my: my?.stl, opp: opp?.stl },
{ label: 'Blocks', my: my?.blk, opp: opp?.blk },
{ label: 'Deflections', my: my?.deflections, opp: opp?.deflections },
{ label: 'Fouls', my: my?.fouls, opp: opp?.fouls },
{ label: 'Def. Fouls', my: my?.def_fouls, opp: opp?.def_fouls },
{ label: 'Charges Taken', my: my?.charges, opp: opp?.charges },
{ label: 'Kills (3 stops)', my: my?.kills, opp: opp?.kills },
{ label: '— Rebounding —', header: true },
{ label: 'Total Rebounds', my: my?.reb, opp: opp?.reb },
{ label: 'Off. Rebounds', my: my?.oreb, opp: opp?.oreb },
{ label: 'Def. Rebounds', my: my?.dreb, opp: opp?.dreb },
{ label: 'Off. Reb %', my: my?.oreb_pct, opp: opp?.oreb_pct },
];
return (
<div>
{/* Scoreboard */}
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
<div className="text-center flex-1">
<p className="text-xs font-bold uppercase tracking-wider truncate" style={{ color: ORANGE }}>{game.my_team}</p>
<p className="text-4xl font-black" style={{ color: ORANGE }}>{fmt(my?.pts)}</p>
</div>
<div className="text-gray-600 font-bold text-sm px-4">vs</div>
<div className="text-center flex-1">
<p className="text-xs font-bold uppercase tracking-wider truncate text-gray-400">{game.opp_team}</p>
<p className="text-4xl font-black text-white">{fmt(opp?.pts)}</p>
</div>
</div>
{/* Summary stats grid — my team */}
<div className="px-5 pt-4 pb-2">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: ORANGE }}>{game.my_team}</p>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{summaryStats.map(s => (
<div key={s.label} className="text-center">
<div className="text-base font-black text-white">{fmt(s.my)}</div>
<div className="text-[10px] text-gray-600 uppercase tracking-wider leading-tight">{s.label}</div>
</div>
))}
</div>
</div>
{/* Summary stats grid — opponent */}
<div className="px-5 pt-2 pb-3 border-b" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
<p className="text-xs font-bold uppercase tracking-widest mb-3 text-gray-500">{game.opp_team}</p>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{summaryStats.map(s => (
<div key={s.label} className="text-center">
<div className="text-base font-black text-gray-400">{fmt(s.opp)}</div>
<div className="text-[10px] text-gray-700 uppercase tracking-wider leading-tight">{s.label}</div>
</div>
))}
</div>
</div>
{/* Full Game Stats toggle */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-center gap-2 w-full py-3 text-xs font-bold uppercase tracking-widest border-b transition-colors"
style={{ borderColor: 'rgba(255,255,255,0.05)', color: expanded ? ORANGE : 'rgba(255,255,255,0.35)' }}
>
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
Full Game Stats
</button>
{/* Expanded full stats table */}
{expanded && (
<div className="px-4 pb-4">
<div className="grid grid-cols-3 gap-2 py-2 text-xs font-bold uppercase tracking-wider border-b mb-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
<div style={{ color: ORANGE }} className="truncate">{game.my_team}</div>
<div className="text-center text-gray-600">Stat</div>
<div className="text-right text-gray-500 truncate">{game.opp_team}</div>
</div>
{fullStats.map(s => s.header ? (
<div key={s.label} className="text-xs font-black uppercase tracking-widest mt-3 mb-1 px-1" style={{ color: ORANGE }}>{s.label.replace(/—/g, '').trim()}</div>
) : (
<div key={s.label} className="grid grid-cols-3 gap-2 py-1.5 rounded-lg px-1" style={{ background: 'rgba(255,255,255,0.02)' }}>
<div className="text-sm font-bold text-white">{fmt(s.my)}</div>
<div className="text-center text-xs text-gray-600 self-center">{s.label}</div>
<div className="text-sm font-bold text-right text-gray-300">{fmt(s.opp)}</div>
</div>
))}
</div>
)}
</div>
);
}
function GameCard({ game }) {
const [open, setOpen] = useState(false);
const [showMyPlayers, setShowMyPlayers] = useState(false);
const [showOppPlayers, setShowOppPlayers] = useState(false);
return (
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'rgba(255,255,255,0.07)', background: '#0a0a0a' }}>
{/* Header */}
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-white/[0.02] transition-colors text-left"
>
<p className="font-bold text-sm truncate flex-1 min-w-0">
<span style={{ color: ORANGE }}>{game.my_team}</span>
<span className="text-gray-500 mx-1">vs</span>
<span className="text-white">{game.opp_team}</span>
<span className="text-gray-600 ml-2">· {game.date} · {game.time}</span>
</p>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
{game.film_url && (
<a href={game.film_url} target="_blank" rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="p-1.5 rounded-lg text-gray-500 hover:text-white transition-colors"
style={{ background: 'rgba(255,255,255,0.05)' }}>
<ExternalLink className="w-3 h-3" />
</a>
)}
{open ? <ChevronUp className="w-4 h-4 text-gray-500" /> : <ChevronDown className="w-4 h-4 text-gray-500" />}
</div>
</button>
{open && (
<div className="border-t" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
{/* Game Film */}
{game.film_url && (
<div className="aspect-video bg-black">
<iframe
src={vimeoEmbed(game.film_url)}
className="w-full h-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
title={`${game.my_team} vs ${game.opp_team}`}
/>
</div>
)}
{/* Team Stats */}
<MariettaTeamStats game={game} />
{/* Player Stats toggles */}
{game.my_players.length > 0 && (
<div className="px-4 pb-4 pt-2 space-y-3">
{/* My team players */}
<div>
<button
onClick={() => setShowMyPlayers(o => !o)}
className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest w-full py-2 border-t transition-colors"
style={{ borderColor: 'rgba(255,255,255,0.07)', color: showMyPlayers ? ORANGE : 'rgba(255,255,255,0.4)' }}
>
<Users className="w-3.5 h-3.5" />
{game.my_team} Player Stats
{showMyPlayers ? <ChevronUp className="w-3.5 h-3.5 ml-auto" /> : <ChevronDown className="w-3.5 h-3.5 ml-auto" />}
</button>
{showMyPlayers && <PlayerTable players={game.my_players} teamLabel={game.my_team} />}
</div>
{/* Opponent players */}
<div>
<button
onClick={() => setShowOppPlayers(o => !o)}
className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest w-full py-2 border-t transition-colors"
style={{ borderColor: 'rgba(255,255,255,0.07)', color: showOppPlayers ? ORANGE : 'rgba(255,255,255,0.4)' }}
>
<Users className="w-3.5 h-3.5" />
{game.opp_team} Player Stats
{showOppPlayers ? <ChevronUp className="w-3.5 h-3.5 ml-auto" /> : <ChevronDown className="w-3.5 h-3.5 ml-auto" />}
</button>
{showOppPlayers && <PlayerTable players={game.opp_players} teamLabel={game.opp_team} />}
</div>
</div>
)}
</div>
)}
</div>
);
}
function SessionSection({ title, games, defaultOpen = false }) {
const [open, setOpen] = useState(defaultOpen);
const wins = games.filter(g => g.result === 'W').length;
const losses = games.filter(g => g.result === 'L').length;
return (
<div className="rounded-2xl border overflow-hidden" style={{ borderColor: 'rgba(255,255,255,0.07)', background: '#050505' }}>
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-white/[0.02] transition-colors text-left"
>
<div>
<p className="font-barlow font-black text-lg uppercase tracking-wide text-white">
{title}
</p>
<p className="text-xs text-gray-500 mt-0.5">
{games.length} games · <span className="text-green-400 font-bold">{wins}W</span> <span className="text-red-400 font-bold">{losses}L</span>
</p>
</div>
{open ? <ChevronUp className="w-5 h-5" style={{ color: ORANGE }} /> : <ChevronDown className="w-5 h-5 text-gray-500" />}
</button>
{open && (
<div className="border-t px-4 pb-4 pt-3 space-y-3" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
{games.map(game => (
<GameCard key={game.id} game={game} />
))}
</div>
)}
</div>
);
}
export default function MariettaGameStats() {
return (
<div className="space-y-4">
<SessionSection
title="GBCA Live Period — Session 1 · June 12–13, 2026"
games={SESSION_1_GAMES}
defaultOpen={false}
/>
<SessionSection
title="GBCA Live Period — Session 2 SE Regional · June 26–28, 2026"
games={SESSION_2_GAMES}
defaultOpen={true}
/>
<p className="text-xs text-gray-700 text-center pt-2">Stats powered by Hoopsalytics · * = Starter</p>
</div>
);
}src/components/georgia/MiltonGameStats.jsx import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ChevronDown, ChevronUp, Users, ExternalLink } from 'lucide-react';
import { base44 } from '@/api/base44Client';
const ORANGE = '#FF6A00';
// Milton player portfolio slug map
const MILTON_PORTFOLIO_SLUGS = {
'Ty Nealis': 'ty-nealis',
'Mason Pridgett': 'mason-pridgett',
'Pierce Strom': 'pierce-strom',
'Kaden King': 'kaden-king',
'William Golden': 'william-golden',
'Michael Ogunyemi': 'michael-ogunyemi',
'Beau Selby': 'beau-selby',
'CJ Omoyele': 'cj-omoyele',
'Graham Whitehart': 'graham-whitehart',
'Jackson Harrison': 'jackson-harrison',
'Jamison Durr': 'jamison-durr',
'Solomon Bratton': 'solomon-bratton',
'Cole Wright': 'cole-wright',
'Hezron Luyindula': 'hezron-luyindula',
};
function vimeoEmbed(url) {
const m = url?.match(/vimeo\.com\/(\d+)/);
return m ? `https://player.vimeo.com/video/${m[1]}` : url;
}
function normalizeTeam(name) {
return (name || '')
.toLowerCase()
.replace(/\s*-\s*[a-z]{2}$/, '') // strip state suffix like " - GA"
.replace(/[^a-z0-9]/g, '');
}
function toISODate(dateStr) {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toISOString().split('T')[0];
}
function teamsMatch(a, b) {
const na = normalizeTeam(a);
const nb = normalizeTeam(b);
return na === nb || na.includes(nb) || nb.includes(na);
}
// ── Session 1: June 12-14, 2026 ──────────────────────────────────────────────
const SESSION_1_GAMES = [
{
id: 's1g1',
date: 'Jun 12, 2026',
time: '6:00 PM',
court: 'Court 05',
game_id: '36159',
my_team: 'Milton - GA',
opp_team: 'Cedar Grove - GA',
my_score: 72,
opp_score: 38,
result: 'W',
film_url: null,
team_stats: {
my: { pts: 72, ppp: 1.18, ast: 16, reb: 42, oreb: 17, dreb: 25, blk: 2, stl: 13, to: 12, deflections: 10, fouls: 9, def_fouls: 9, charges: 0, kills: 11, efg_pct: '53.4%', to_pct: '16.9%', oreb_pct: '51.5%', ftr: 0.31, two_made: 24, two_att: 43, two_pct: '55.8%', three_made: 5, three_att: 16, three_pct: '31.2%', ft_made: 9, ft_att: 18, ft_pct: '50.0%', scoring_opps: 69, shots: 59, ft_trips: 10, two_rate: '72.9%', three_rate: '27.1%', ft: '9/18' },
opp: { pts: 38, ppp: 0.62, ast: 9, reb: 20, oreb: 4, dreb: 16, blk: 1, stl: 7, to: 17, deflections: 3, fouls: 16, def_fouls: 14, charges: 0, kills: 2, efg_pct: '36.4%', to_pct: '27.9%', oreb_pct: '13.8%', ftr: 0.18, two_made: 7, two_att: 17, two_pct: '41.2%', three_made: 6, three_att: 27, three_pct: '22.2%', ft_made: 6, ft_att: 8, ft_pct: '75.0%', scoring_opps: 48, shots: 44, ft_trips: 4, two_rate: '38.6%', three_rate: '61.4%', ft: '6/8' },
},
my_players: [
{ num: '0', name: 'Ty Nealis', time: '6:16', pts: 0, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/2', efg: '—', pm: '+9' },
{ num: '1', name: 'Mason Pridgett', time: '22:49', pts: 2, reb: 2, ast: 4, blk: 0, stl: 1, to: 2, two_a: '1/6', three_a: '0/1', ft_a: '0/0', efg: '14.3%', pm: '+27', starter: true },
{ num: '3', name: 'Pierce Strom', time: '13:35', pts: 9, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '3/4', ft_a: '0/0', efg: '112.5%', pm: '+21', starter: true },
{ num: '4', name: 'Kaden King', time: '2:32', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+6' },
{ num: '5', name: 'William Golden', time: '23:18', pts: 17, reb: 8, ast: 5, blk: 0, stl: 3, to: 2, two_a: '5/8', three_a: '1/3', ft_a: '4/4', efg: '59.1%', pm: '+29', starter: true },
{ num: '10', name: 'Michael Ogunyemi', time: '4:43', pts: 4, reb: 2, ast: 0, blk: 0, stl: 1, to: 0, two_a: '2/3', three_a: '0/0', ft_a: '0/0', efg: '66.7%', pm: '+8' },
{ num: '11', name: 'Beau Selby', time: '9:01', pts: 0, reb: 1, ast: 0, blk: 0, stl: 1, to: 0, two_a: '0/0', three_a: '0/2', ft_a: '0/0', efg: '0%', pm: '+1' },
{ num: '12', name: 'CJ Omoyele', time: '8:54', pts: 0, reb: 0, ast: 2, blk: 1, stl: 1, to: 1, two_a: '0/3', three_a: '0/0', ft_a: '0/4', efg: '0%', pm: '0' },
{ num: '13', name: 'Graham Whitehart', time: '9:58', pts: 0, reb: 0, ast: 0, blk: 0, stl: 1, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+2' },
{ num: '14', name: 'Jackson Harrison', time: '19:19', pts: 5, reb: 6, ast: 0, blk: 0, stl: 1, to: 1, two_a: '1/2', three_a: '1/6', ft_a: '0/0', efg: '31.2%', pm: '+21', starter: true },
{ num: '15', name: 'Jamison Durr', time: '1:21', pts: 2, reb: 1, ast: 0, blk: 0, stl: 1, to: 1, two_a: '0/2', three_a: '0/0', ft_a: '2/2', efg: '0%', pm: '+4' },
{ num: '22', name: 'Solomon Bratton', time: '24:37', pts: 31, reb: 17, ast: 5, blk: 1, stl: 3, to: 3, two_a: '14/17', three_a: '0/0', ft_a: '3/4', efg: '82.4%', pm: '+34', starter: true },
{ num: '23', name: 'Cole Wright', time: '3:30', pts: 2, reb: 3, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/2', three_a: '0/0', ft_a: '0/2', efg: '50.0%', pm: '+8' },
],
opp_players: [
{ num: '0', name: 'Daemeon Holmes', time: '22:54', pts: 13, reb: 4, ast: 1, blk: 0, stl: 1, to: 2, two_a: '1/3', three_a: '3/6', ft_a: '2/4', efg: '61.1%', pm: '-22', starter: true },
{ num: '2', name: 'Nicholas McNeill', time: '3:30', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-8' },
{ num: '3', name: 'Edan Nathan', time: '10:35', pts: 0, reb: 2, ast: 2, blk: 0, stl: 0, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-6' },
{ num: '4', name: 'Kenny Childs', time: '7:05', pts: 0, reb: 0, ast: 0, blk: 0, stl: 1, to: 0, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-4' },
{ num: '5', name: 'Jaimarion Mathis', time: '20:16', pts: 8, reb: 2, ast: 2, blk: 0, stl: 0, to: 0, two_a: '1/3', three_a: '2/6', ft_a: '0/0', efg: '44.4%', pm: '-26', starter: true },
{ num: '10', name: 'Kevin Rogers Jr.', time: '16:56', pts: 4, reb: 2, ast: 2, blk: 1, stl: 1, to: 2, two_a: '1/4', three_a: '0/2', ft_a: '2/2', efg: '16.7%', pm: '-18', starter: true },
{ num: '11', name: 'Tayson Blunt', time: '17:11', pts: 4, reb: 1, ast: 0, blk: 0, stl: 0, to: 2, two_a: '1/1', three_a: '0/2', ft_a: '2/2', efg: '33.3%', pm: '-28', starter: true },
{ num: '12', name: 'Dakota Freeman', time: '26:18', pts: 9, reb: 3, ast: 0, blk: 0, stl: 3, to: 7, two_a: '3/4', three_a: '1/6', ft_a: '0/0', efg: '45.0%', pm: '-27', starter: true },
{ num: '15', name: 'Isaiah Cosby', time: '2:32', pts: 0, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-2' },
{ num: '20', name: 'Josiah Street', time: '2:29', pts: 0, reb: 1, ast: 2, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+5' },
{ num: '22', name: 'Camrin Gee', time: '7:19', pts: 0, reb: 0, ast: 0, blk: 0, stl: 1, to: 1, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-13' },
{ num: '23', name: 'Jacob Hall', time: '10:15', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/2', ft_a: '0/0', efg: '0%', pm: '-15' },
{ num: '25', name: 'Dash Freeman', time: '2:32', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-6' },
],
},
{
id: 's1g2',
date: 'Jun 13, 2026',
time: '2:00 PM',
court: 'Court 05',
my_team: 'Milton - GA',
opp_team: 'Cherokee - GA',
my_score: 77,
opp_score: 52,
result: 'W',
film_url: null,
team_stats: {
my: { pts: 77, ppp: 1.13, ast: 19, reb: 33, oreb: 12, dreb: 21, blk: 2, stl: 12, to: 13, deflections: 10, fouls: 13, def_fouls: 12, charges: 1, kills: 7, efg_pct: '54.4%', to_pct: '18.6%', oreb_pct: '36.4%', ftr: 0.46, two_made: 25, two_att: 42, two_pct: '59.5%', three_made: 4, three_att: 15, three_pct: '26.7%', ft_made: 15, ft_att: 26, ft_pct: '57.7%', scoring_opps: 73, shots: 57, ft_trips: 16, two_rate: '73.7%', three_rate: '26.3%', ft: '15/26' },
opp: { pts: 52, ppp: 0.76, ast: 7, reb: 25, oreb: 4, dreb: 21, blk: 0, stl: 6, to: 22, deflections: 7, fouls: 21, def_fouls: 19, charges: 0, kills: 2, efg_pct: '45.7%', to_pct: '32.4%', oreb_pct: '16.0%', ftr: 0.26, two_made: 15, two_att: 27, two_pct: '55.6%', three_made: 4, three_att: 19, three_pct: '21.1%', ft_made: 10, ft_att: 12, ft_pct: '83.3%', scoring_opps: 54, shots: 46, ft_trips: 8, two_rate: '58.7%', three_rate: '41.3%', ft: '10/12' },
},
my_players: [
{ num: '0', name: 'Ty Nealis', time: '9:45', pts: 4, reb: 3, ast: 1, blk: 0, stl: 1, to: 0, two_a: '2/3', three_a: '0/0', ft_a: '0/1', efg: '66.7%', pm: '+3' },
{ num: '1', name: 'Mason Pridgett', time: '25:22', pts: 11, reb: 3, ast: 11, blk: 0, stl: 4, to: 2, two_a: '4/6', three_a: '0/0', ft_a: '3/3', efg: '66.7%', pm: '+21', starter: true },
{ num: '3', name: 'Pierce Strom', time: '21:30', pts: 5, reb: 1, ast: 2, blk: 0, stl: 1, to: 1, two_a: '1/2', three_a: '1/4', ft_a: '0/0', efg: '41.7%', pm: '+29', starter: true },
{ num: '4', name: 'Kaden King', time: '2:04', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-3' },
{ num: '5', name: 'William Golden', time: '26:23', pts: 17, reb: 4, ast: 2, blk: 1, stl: 1, to: 3, two_a: '7/9', three_a: '0/2', ft_a: '3/7', efg: '63.6%', pm: '+30', starter: true },
{ num: '10', name: 'Michael Ogunyemi', time: '3:50', pts: 0, reb: 0, ast: 0, blk: 0, stl: 1, to: 1, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+4' },
{ num: '11', name: 'Beau Selby', time: '5:33', pts: 2, reb: 2, ast: 0, blk: 0, stl: 0, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '2/2', efg: '—', pm: '+7' },
{ num: '13', name: 'Graham Whitehart', time: '9:52', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/2', three_a: '0/2', ft_a: '0/0', efg: '0%', pm: '+6' },
{ num: '14', name: 'Jackson Harrison', time: '20:20', pts: 18, reb: 3, ast: 1, blk: 0, stl: 2, to: 1, two_a: '4/6', three_a: '3/6', ft_a: '1/3', efg: '70.8%', pm: '+18', starter: true },
{ num: '15', name: 'Jamison Durr', time: '2:38', pts: 0, reb: 0, ast: 0, blk: 0, stl: 1, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-1' },
{ num: '22', name: 'Solomon Bratton', time: '20:47', pts: 20, reb: 13, ast: 2, blk: 1, stl: 1, to: 2, two_a: '7/13', three_a: '0/0', ft_a: '6/10', efg: '53.8%', pm: '+14', starter: true },
{ num: '23', name: 'Cole Wright', time: '1:52', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-3' },
],
opp_players: [
{ num: '0', name: 'Atlas Walker-Bunda', time: '3:23', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-8' },
{ num: '2', name: 'Korben Polk', time: '22:10', pts: 8, reb: 4, ast: 1, blk: 0, stl: 2, to: 5, two_a: '1/2', three_a: '1/6', ft_a: '3/5', efg: '31.2%', pm: '-16', starter: true },
{ num: '4', name: 'Braylon Luster', time: '24:19', pts: 18, reb: 6, ast: 4, blk: 0, stl: 2, to: 3, two_a: '7/15', three_a: '0/4', ft_a: '4/4', efg: '36.8%', pm: '-17', starter: true },
{ num: '5', name: 'Lucas Fainter', time: '14:01', pts: 4, reb: 1, ast: 0, blk: 0, stl: 1, to: 1, two_a: '2/2', three_a: '0/1', ft_a: '0/0', efg: '66.7%', pm: '-28', starter: true },
{ num: '10', name: 'Nate Wise', time: '3:50', pts: 0, reb: 1, ast: 0, blk: 0, stl: 1, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-4' },
{ num: '15', name: 'Tyler Barnett', time: '25:10', pts: 2, reb: 5, ast: 0, blk: 0, stl: 0, to: 1, two_a: '1/1', three_a: '0/2', ft_a: '0/0', efg: '33.3%', pm: '-21', starter: true },
{ num: '20', name: 'Zane Hereford', time: '25:17', pts: 11, reb: 3, ast: 0, blk: 0, stl: 0, to: 2, two_a: '3/6', three_a: '1/3', ft_a: '2/2', efg: '50.0%', pm: '-21', starter: true },
{ num: '22', name: 'Declan Wright', time: '2:38', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+1' },
{ num: '24', name: 'Buddha Key', time: '22:40', pts: 6, reb: 1, ast: 2, blk: 0, stl: 0, to: 4, two_a: '0/0', three_a: '2/3', ft_a: '0/0', efg: '100.0%', pm: '-8' },
{ num: '30', name: 'Brody Stewart', time: '3:50', pts: 3, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/1', three_a: '0/0', ft_a: '1/1', efg: '100.0%', pm: '-4' },
{ num: '32', name: 'Brayden West', time: '2:38', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+1' },
],
},
{
id: 's1g3',
date: 'Jun 13, 2026',
time: '7:00 PM',
court: 'Court 08',
game_id: '36324',
my_team: 'Milton - GA',
opp_team: 'Alexander - GA',
my_score: 57,
opp_score: 49,
result: 'W',
film_url: null,
team_stats: {
my: { pts: 57, ppp: 1.12, ast: 11, reb: 40, oreb: 20, dreb: 20, blk: 1, stl: 4, to: 11, deflections: 2, fouls: 10, def_fouls: 8, charges: 0, kills: 6, efg_pct: '47.1%', to_pct: '17.5%', oreb_pct: '55.6%', ftr: 0.33, two_made: 14, two_att: 33, two_pct: '42.4%', three_made: 7, three_att: 19, three_pct: '36.8%', ft_made: 8, ft_att: 17, ft_pct: '47.1%', scoring_opps: 62, shots: 52, ft_trips: 10, two_rate: '63.5%', three_rate: '36.5%', ft: '8/17' },
opp: { pts: 49, ppp: 0.96, ast: 7, reb: 26, oreb: 10, dreb: 16, blk: 4, stl: 7, to: 10, deflections: 5, fouls: 20, def_fouls: 17, charges: 0, kills: 5, efg_pct: '45.9%', to_pct: '16.9%', oreb_pct: '33.3%', ftr: 0.16, two_made: 15, two_att: 35, two_pct: '42.9%', three_made: 5, three_att: 14, three_pct: '35.7%', ft_made: 4, ft_att: 8, ft_pct: '50.0%', scoring_opps: 53, shots: 49, ft_trips: 4, two_rate: '71.4%', three_rate: '28.6%', ft: '4/8' },
},
my_players: [
{ num: '1', name: 'Mason Pridgett', time: '30:00', pts: 2, reb: 3, ast: 3, blk: 0, stl: 1, to: 5, two_a: '1/3', three_a: '0/1', ft_a: '0/0', efg: '25.0%', pm: '+8', starter: true },
{ num: '3', name: 'Pierce Strom', time: '23:27', pts: 3, reb: 1, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '1/5', ft_a: '0/0', efg: '30.0%', pm: '+7', starter: true },
{ num: '5', name: 'William Golden', time: '30:00', pts: 13, reb: 8, ast: 1, blk: 1, stl: 1, to: 1, two_a: '5/11', three_a: '1/2', ft_a: '0/7', efg: '50.0%', pm: '+8', starter: true },
{ num: '12', name: 'CJ Omoyele', time: '2:37', pts: 2, reb: 1, ast: 0, blk: 0, stl: 0, to: 1, two_a: '1/2', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '-2' },
{ num: '13', name: 'Graham Whitehart', time: '6:23', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '+1' },
{ num: '14', name: 'Jackson Harrison', time: '27:31', pts: 22, reb: 5, ast: 1, blk: 0, stl: 1, to: 2, two_a: '2/5', three_a: '5/10', ft_a: '3/5', efg: '63.3%', pm: '+10', starter: true },
{ num: '22', name: 'Solomon Bratton', time: '30:00', pts: 15, reb: 17, ast: 5, blk: 0, stl: 1, to: 2, two_a: '5/12', three_a: '0/0', ft_a: '5/5', efg: '41.7%', pm: '+8', starter: true },
],
opp_players: [
{ num: '1', name: 'Chauncey Young', time: '21:17', pts: 0, reb: 3, ast: 0, blk: 1, stl: 1, to: 3, two_a: '0/3', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-12', starter: true },
{ num: '2', name: 'TaJai Cook', time: '17:12', pts: 2, reb: 0, ast: 1, blk: 1, stl: 1, to: 0, two_a: '1/1', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '-13' },
{ num: '3', name: 'Donovin Martin', time: '27:37', pts: 20, reb: 8, ast: 3, blk: 0, stl: 1, to: 1, two_a: '8/15', three_a: '0/4', ft_a: '4/4', efg: '42.1%', pm: '-7', starter: true },
{ num: '5', name: 'TJ Pigott', time: '16:16', pts: 0, reb: 4, ast: 1, blk: 0, stl: 1, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+5', starter: true },
{ num: '12', name: 'Aiden Allaire', time: '25:27', pts: 12, reb: 4, ast: 1, blk: 2, stl: 1, to: 0, two_a: '3/6', three_a: '2/4', ft_a: '0/4', efg: '60.0%', pm: '+10', starter: true },
{ num: '22', name: 'Jake Roberts', time: '8:56', pts: 6, reb: 0, ast: 0, blk: 0, stl: 1, to: 2, two_a: '0/0', three_a: '2/3', ft_a: '0/0', efg: '100.0%', pm: '-14' },
{ num: '23', name: 'Derek Williams', time: '5:43', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-13' },
{ num: '24', name: 'KJ Reed', time: '18:13', pts: 9, reb: 3, ast: 0, blk: 0, stl: 1, to: 1, two_a: '3/9', three_a: '1/2', ft_a: '0/0', efg: '40.9%', pm: '+6', starter: true },
{ num: '25', name: 'Jerry English', time: '4:33', pts: 0, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-4' },
{ num: '33', name: 'AmerJay Hunter', time: '4:40', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+2' },
],
},
{
id: 's1g4',
date: 'Jun 14, 2026',
time: '11:00 AM',
court: 'Court 08',
my_team: 'Milton - GA',
opp_team: 'Pace Academy - GA',
my_score: 40,
opp_score: 52,
result: 'L',
film_url: 'https://vimeo.com/1202832997',
team_stats: {
my: { pts: 40, ppp: 0.89, ast: 9, reb: 17, oreb: 4, dreb: 13, blk: 0, stl: 6, to: 4, deflections: 10, fouls: 12, def_fouls: 10, charges: 0, kills: 4, efg_pct: '41.5%', to_pct: '8.9%', oreb_pct: '16.7%', ftr: 0.20, two_made: 11, two_att: 21, two_pct: '52.4%', three_made: 4, three_att: 20, three_pct: '20.0%', ft_made: 6, ft_att: 8, ft_pct: '75.0%', scoring_opps: 45, shots: 41, ft_trips: 4, two_rate: '51.2%', three_rate: '48.8%', ft: '6/8' },
opp: { pts: 52, ppp: 1.16, ast: 8, reb: 27, oreb: 7, dreb: 20, blk: 1, stl: 2, to: 9, deflections: 10, fouls: 10, def_fouls: 10, charges: 0, kills: 6, efg_pct: '54.2%', to_pct: '20.0%', oreb_pct: '35.0%', ftr: 0.39, two_made: 12, two_att: 20, two_pct: '60.0%', three_made: 5, three_att: 16, three_pct: '31.2%', ft_made: 13, ft_att: 14, ft_pct: '92.9%', scoring_opps: 44, shots: 36, ft_trips: 8, two_rate: '55.6%', three_rate: '44.4%', ft: '13/14' },
},
my_players: [
{ num: '0', name: 'Ty Nealis', time: '2:42', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-3' },
{ num: '1', name: 'Mason Pridgett', time: '30:00', pts: 2, reb: 2, ast: 3, blk: 0, stl: 1, to: 1, two_a: '0/3', three_a: '0/1', ft_a: '2/2', efg: '0%', pm: '-12', starter: true },
{ num: '3', name: 'Pierce Strom', time: '20:59', pts: 6, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '2/6', ft_a: '0/0', efg: '50.0%', pm: '-10', starter: true },
{ num: '5', name: 'William Golden', time: '30:00', pts: 16, reb: 0, ast: 0, blk: 0, stl: 1, to: 0, two_a: '7/7', three_a: '0/3', ft_a: '2/2', efg: '70.0%', pm: '-12', starter: true },
{ num: '11', name: 'Beau Selby', time: '2:36', pts: 0, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+1' },
{ num: '12', name: 'CJ Omoyele', time: '0:29', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '0' },
{ num: '13', name: 'Graham Whitehart', time: '3:11', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '0' },
{ num: '14', name: 'Jackson Harrison', time: '30:00', pts: 8, reb: 3, ast: 3, blk: 0, stl: 2, to: 0, two_a: '1/2', three_a: '2/9', ft_a: '0/0', efg: '36.4%', pm: '-12', starter: true },
{ num: '22', name: 'Solomon Bratton', time: '30:00', pts: 8, reb: 7, ast: 2, blk: 0, stl: 2, to: 3, two_a: '3/7', three_a: '0/0', ft_a: '2/4', efg: '42.9%', pm: '-12', starter: true },
],
opp_players: [
{ num: '0', name: 'Jeffrey Stephens', time: '19:01', pts: 0, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+7', starter: true },
{ num: '1', name: 'Jace Dunn', time: '4:37', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/2', ft_a: '0/0', efg: '0%', pm: '+1' },
{ num: '2', name: 'Brandon Dixon', time: '1:19', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '0' },
{ num: '3', name: 'Amir Cuyler', time: '7:19', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+3' },
{ num: '4', name: 'Jaden McCullough', time: '30:00', pts: 18, reb: 4, ast: 1, blk: 0, stl: 0, to: 3, two_a: '3/6', three_a: '2/5', ft_a: '6/6', efg: '54.5%', pm: '+12', starter: true },
{ num: '5', name: 'Brielen Craft', time: '26:42', pts: 11, reb: 2, ast: 1, blk: 0, stl: 2, to: 0, two_a: '1/3', three_a: '2/5', ft_a: '3/3', efg: '50.0%', pm: '+13', starter: true },
{ num: '12', name: 'Henry Dickert', time: '25:44', pts: 2, reb: 2, ast: 2, blk: 0, stl: 0, to: 0, two_a: '1/1', three_a: '0/1', ft_a: '0/0', efg: '50.0%', pm: '+5', starter: true },
{ num: '21', name: 'Evan Reed', time: '1:32', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-1' },
{ num: '24', name: 'Kaiden Harley', time: '7:21', pts: 9, reb: 4, ast: 0, blk: 1, stl: 0, to: 0, two_a: '2/3', three_a: '1/1', ft_a: '2/3', efg: '87.5%', pm: '+10' },
{ num: '25', name: 'Gavin Fountain', time: '26:22', pts: 12, reb: 7, ast: 4, blk: 0, stl: 0, to: 4, two_a: '5/7', three_a: '0/2', ft_a: '2/2', efg: '55.6%', pm: '+10', starter: true },
],
},
];
// ── Session 2 SE Regional: June 26-28, 2026 ──────────────────────────────────
const SESSION_2_GAMES = [
{
id: 's2g1',
date: 'Jun 26, 2026',
time: '6:00 PM',
court: 'Court 05',
game_id: '37241',
my_team: 'Milton HS - GA',
opp_team: 'Mooresville - NC',
my_score: 57,
opp_score: 46,
result: 'W',
film_url: 'https://vimeo.com/1205581912',
team_stats: {
my: { pts: 57, ppp: 1.10, ast: 14, reb: 29, oreb: 12, dreb: 17, blk: 3, stl: 6, to: 1, deflections: 8, fouls: 11, def_fouls: 11, charges: 0, kills: 4, efg_pct: '45.5%', to_pct: '1.8%', oreb_pct: '33.3%', ftr: 0.27, two_made: 16, two_att: 33, two_pct: '48.5%', three_made: 6, three_att: 22, three_pct: '27.3%', ft_made: 7, ft_att: 15, ft_pct: '46.7%', scoring_opps: 63, shots: 55, ft_trips: 8, two_rate: '60.0%', three_rate: '40.0%', ft: '7/15' },
opp: { pts: 46, ppp: 0.90, ast: 10, reb: 35, oreb: 11, dreb: 24, blk: 1, stl: 0, to: 12, deflections: 2, fouls: 13, def_fouls: 11, charges: 0, kills: 2, efg_pct: '42.2%', to_pct: '21.1%', oreb_pct: '39.3%', ftr: 0.29, two_made: 16, two_att: 32, two_pct: '50.0%', three_made: 2, three_att: 13, three_pct: '15.4%', ft_made: 8, ft_att: 13, ft_pct: '61.5%', scoring_opps: 53, shots: 45, ft_trips: 8, two_rate: '71.1%', three_rate: '28.9%', ft: '8/13' },
},
my_players: [
{ num: '0', name: 'Ty Nealis', time: '3:41', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+5' },
{ num: '1', name: 'Mason Pridgett', time: '27:50', pts: 4, reb: 6, ast: 2, blk: 0, stl: 2, to: 0, two_a: '1/6', three_a: '0/0', ft_a: '0/0', efg: '16.7%', pm: '+6', starter: true },
{ num: '3', name: 'Pierce Strom', time: '20:58', pts: 11, reb: 2, ast: 0, blk: 0, stl: 1, to: 0, two_a: '1/2', three_a: '3/5', ft_a: '0/0', efg: '78.6%', pm: '+12', starter: true },
{ num: '5', name: 'William Golden', time: '28:05', pts: 14, reb: 5, ast: 5, blk: 0, stl: 0, to: 0, two_a: '7/10', three_a: '0/1', ft_a: '0/0', efg: '63.6%', pm: '+11', starter: true },
{ num: '11', name: 'Beau Selby', time: '1:56', pts: 2, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/2', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '-4' },
{ num: '12', name: 'CJ Omoyele', time: '4:59', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-2' },
{ num: '13', name: 'Graham Whitehart', time: '8:53', pts: 3, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '1/3', ft_a: '0/0', efg: '50.0%', pm: '+1' },
{ num: '14', name: 'Jackson Harrison', time: '25:44', pts: 11, reb: 1, ast: 1, blk: 0, stl: 1, to: 0, two_a: '3/7', three_a: '0/9', ft_a: '0/0', efg: '18.8%', pm: '+15', starter: true },
{ num: '22', name: 'Solomon Bratton', time: '27:48', pts: 12, reb: 11, ast: 6, blk: 3, stl: 2, to: 0, two_a: '3/6', three_a: '2/4', ft_a: '0/0', efg: '60.0%', pm: '+11', starter: true },
],
opp_players: [
{ num: '2', name: 'Jaylen Bailey', time: '10:24', pts: 4, reb: 1, ast: 2, blk: 0, stl: 0, to: 0, two_a: '1/1', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+2' },
{ num: '3', name: 'Sherod McCormick', time: '28:37', pts: 12, reb: 3, ast: 4, blk: 0, stl: 0, to: 2, two_a: '2/9', three_a: '1/6', ft_a: '0/0', efg: '23.3%', pm: '-9', starter: true },
{ num: '4', name: 'Malachi Shipp', time: '18:13', pts: 7, reb: 4, ast: 3, blk: 0, stl: 0, to: 4, two_a: '3/5', three_a: '0/0', ft_a: '0/0', efg: '60.0%', pm: '-8', starter: true },
{ num: '5', name: 'Kevin Cornelius', time: '5:35', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '-5' },
{ num: '11', name: 'Ethan Petty', time: '10:42', pts: 4, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '2/3', three_a: '0/0', ft_a: '0/0', efg: '66.7%', pm: '+1' },
{ num: '12', name: 'Noah Wilson', time: '16:26', pts: 7, reb: 9, ast: 0, blk: 0, stl: 0, to: 4, two_a: '2/3', three_a: '1/4', ft_a: '0/0', efg: '50.0%', pm: '-14', starter: true },
{ num: '20', name: 'Cody Morrison', time: '12:48', pts: 0, reb: 4, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/1', three_a: '0/3', ft_a: '0/0', efg: '0%', pm: '0' },
{ num: '24', name: 'Braylen Curry', time: '20:52', pts: 6, reb: 5, ast: 0, blk: 0, stl: 0, to: 0, two_a: '3/5', three_a: '0/0', ft_a: '0/0', efg: '60.0%', pm: '-16', starter: true },
{ num: '55', name: 'Xavier Hall', time: '26:18', pts: 6, reb: 3, ast: 1, blk: 1, stl: 0, to: 1, two_a: '3/4', three_a: '0/0', ft_a: '0/0', efg: '75.0%', pm: '-6', starter: true },
],
},
{
id: 's2g2',
date: 'Jun 27, 2026',
time: '2:00 PM',
court: 'Court 08',
game_id: '37325',
my_team: 'Milton HS - GA',
opp_team: 'West Charlotte - NC',
my_score: 50,
opp_score: 54,
result: 'L',
film_url: 'https://vimeo.com/1205604087',
team_stats: {
my: { pts: 50, ppp: 0.93, ast: 16, reb: 25, oreb: 6, dreb: 19, blk: 0, stl: 2, to: 7, deflections: 2, fouls: 21, def_fouls: 20, charges: 0, kills: 4, efg_pct: '46.8%', to_pct: '13.0%', oreb_pct: '20.7%', ftr: 0.28, two_made: 7, two_att: 19, two_pct: '36.8%', three_made: 10, three_att: 28, three_pct: '35.7%', ft_made: 6, ft_att: 13, ft_pct: '46.2%', scoring_opps: 54, shots: 47, ft_trips: 7, two_rate: '40.4%', three_rate: '59.6%', ft: '6/13' },
opp: { pts: 54, ppp: 1.00, ast: 15, reb: 32, oreb: 9, dreb: 23, blk: 3, stl: 3, to: 6, deflections: 7, fouls: 20, def_fouls: 16, charges: 0, kills: 6, efg_pct: '44.3%', to_pct: '12.0%', oreb_pct: '32.1%', ftr: 0.57, two_made: 18, two_att: 37, two_pct: '48.6%', three_made: 1, three_att: 7, three_pct: '14.3%', ft_made: 15, ft_att: 25, ft_pct: '60.0%', scoring_opps: 58, shots: 44, ft_trips: 14, two_rate: '84.1%', three_rate: '15.9%', ft: '15/25' },
},
my_players: [
{ num: '1', name: 'Mason Pridgett', time: '30:00', pts: 0, reb: 6, ast: 5, blk: 0, stl: 0, to: 2, two_a: '0/1', three_a: '0/2', ft_a: '0/0', efg: '0%', pm: '-9', starter: true },
{ num: '3', name: 'Pierce Strom', time: '30:00', pts: 17, reb: 0, ast: 0, blk: 0, stl: 1, to: 0, two_a: '0/0', three_a: '5/8', ft_a: '0/0', efg: '93.8%', pm: '+5', starter: true },
{ num: '5', name: 'William Golden', time: '30:00', pts: 10, reb: 9, ast: 4, blk: 0, stl: 1, to: 1, two_a: '4/9', three_a: '0/1', ft_a: '0/0', efg: '40.0%', pm: '-2', starter: true },
{ num: '11', name: 'Beau Selby', time: '6:27', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-2' },
{ num: '12', name: 'CJ Omoyele', time: '4:25', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-8' },
{ num: '13', name: 'Graham Whitehart', time: '10:48', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-4' },
{ num: '14', name: 'Jackson Harrison', time: '30:00', pts: 15, reb: 1, ast: 2, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '5/15', ft_a: '0/0', efg: '46.9%', pm: '-5', starter: true },
{ num: '22', name: 'Solomon Bratton', time: '30:00', pts: 8, reb: 5, ast: 5, blk: 0, stl: 0, to: 4, two_a: '3/7', three_a: '0/0', ft_a: '0/0', efg: '42.9%', pm: '0', starter: true },
],
opp_players: [
{ num: '2', name: 'Chacho Womack', time: '30:00', pts: 12, reb: 5, ast: 2, blk: 0, stl: 0, to: 0, two_a: '2/8', three_a: '1/3', ft_a: '0/0', efg: '31.8%', pm: '+8', starter: true },
{ num: '3', name: 'Amen Pressley', time: '30:00', pts: 4, reb: 5, ast: 7, blk: 0, stl: 1, to: 0, two_a: '2/4', three_a: '0/0', ft_a: '0/0', efg: '50.0%', pm: '+1', starter: true },
{ num: '5', name: 'Mychal Brown', time: '16:26', pts: 5, reb: 1, ast: 3, blk: 0, stl: 0, to: 1, two_a: '2/2', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+6' },
{ num: '11', name: 'Major Cross', time: '24:35', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '0', starter: true },
{ num: '12', name: 'Carter Sullivan', time: '13:20', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/4', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+3' },
{ num: '14', name: 'Sean Johnson', time: '30:00', pts: 10, reb: 7, ast: 1, blk: 1, stl: 1, to: 1, two_a: '4/6', three_a: '0/0', ft_a: '0/0', efg: '66.7%', pm: '+4', starter: true },
{ num: '22', name: 'John Dudley', time: '4:19', pts: 2, reb: 0, ast: 0, blk: 1, stl: 0, to: 0, two_a: '1/1', three_a: '0/1', ft_a: '0/0', efg: '50.0%', pm: '+1' },
{ num: '24', name: '#24', time: '30:00', pts: 21, reb: 8, ast: 2, blk: 1, stl: 1, to: 3, two_a: '7/11', three_a: '0/3', ft_a: '0/0', efg: '50.0%', pm: '+2', starter: true },
],
},
{
id: 's2g3',
date: 'Jun 27, 2026',
time: '6:00 PM',
court: 'Court 04',
game_id: '37358',
my_team: 'Milton HS - GA',
opp_team: 'Oxford HS - AL',
my_score: 58,
opp_score: 48,
result: 'W',
film_url: 'https://vimeo.com/1205631238',
team_stats: {
my: { pts: 58, ppp: 1.04, ast: 10, reb: 26, oreb: 9, dreb: 17, blk: 3, stl: 11, to: 15, deflections: 8, fouls: 18, def_fouls: 13, charges: 0, kills: 5, efg_pct: '54.5%', to_pct: '25.4%', oreb_pct: '40.9%', ftr: 0.32, two_made: 15, two_att: 24, two_pct: '62.5%', three_made: 6, three_att: 20, three_pct: '30.0%', ft_made: 10, ft_att: 14, ft_pct: '71.4%', scoring_opps: 52, shots: 44, ft_trips: 8, two_rate: '54.5%', three_rate: '45.5%', ft: '10/14' },
opp: { pts: 48, ppp: 0.91, ast: 7, reb: 19, oreb: 6, dreb: 13, blk: 3, stl: 2, to: 16, deflections: 8, fouls: 18, def_fouls: 14, charges: 0, kills: 5, efg_pct: '51.4%', to_pct: '30.2%', oreb_pct: '26.1%', ftr: 0.41, two_made: 13, two_att: 28, two_pct: '46.4%', three_made: 4, three_att: 9, three_pct: '44.4%', ft_made: 10, ft_att: 15, ft_pct: '66.7%', scoring_opps: 46, shots: 37, ft_trips: 9, two_rate: '75.7%', three_rate: '24.3%', ft: '10/15' },
},
my_players: [
{ num: '0', name: 'Ty Nealis', time: '4:18', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-6' },
{ num: '1', name: 'Mason Pridgett', time: '25:45', pts: 7, reb: 3, ast: 3, blk: 1, stl: 0, to: 2, two_a: '1/2', three_a: '1/3', ft_a: '0/0', efg: '50.0%', pm: '+18', starter: true },
{ num: '3', name: 'Pierce Strom', time: '23:22', pts: 13, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/1', three_a: '4/8', ft_a: '0/0', efg: '66.7%', pm: '+5', starter: true },
{ num: '4', name: 'Kaden King', time: '1:21', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-4' },
{ num: '5', name: 'William Golden', time: '28:43', pts: 10, reb: 6, ast: 3, blk: 1, stl: 2, to: 3, two_a: '3/4', three_a: '0/0', ft_a: '0/0', efg: '75.0%', pm: '+14', starter: true },
{ num: '11', name: 'Beau Selby', time: '2:33', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-6' },
{ num: '12', name: 'CJ Omoyele', time: '6:12', pts: 0, reb: 1, ast: 0, blk: 0, stl: 1, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+18' },
{ num: '13', name: 'Graham Whitehart', time: '4:28', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-6' },
{ num: '14', name: 'Jackson Harrison', time: '24:24', pts: 5, reb: 3, ast: 1, blk: 0, stl: 4, to: 3, two_a: '1/3', three_a: '1/6', ft_a: '0/0', efg: '27.8%', pm: '+13', starter: true },
{ num: '15', name: 'Jamison Durr', time: '1:21', pts: 0, reb: 0, ast: 0, blk: 0, stl: 1, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-4' },
{ num: '20', name: 'Hezron Luyindula', time: '0:53', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-2' },
{ num: '22', name: 'Solomon Bratton', time: '25:35', pts: 23, reb: 9, ast: 3, blk: 1, stl: 2, to: 2, two_a: '10/14', three_a: '0/2', ft_a: '0/0', efg: '62.5%', pm: '+14', starter: true },
{ num: '23', name: 'Cole Wright', time: '1:21', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-4' },
],
opp_players: [
{ num: '1', name: 'Jmi Caver', time: '2:01', pts: 2, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/1', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+2' },
{ num: '2', name: 'Caileb Wilson', time: '25:15', pts: 6, reb: 3, ast: 1, blk: 0, stl: 0, to: 5, two_a: '2/4', three_a: '0/1', ft_a: '0/0', efg: '40.0%', pm: '-10', starter: true },
{ num: '3', name: 'Conner Richerzhagen', time: '13:30', pts: 3, reb: 3, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/2', three_a: '1/2', ft_a: '0/0', efg: '37.5%', pm: '-9' },
{ num: '4', name: 'Marcus Perry', time: '24:56', pts: 16, reb: 3, ast: 1, blk: 2, stl: 0, to: 3, two_a: '4/9', three_a: '2/3', ft_a: '0/0', efg: '58.3%', pm: '-5', starter: true },
{ num: '5', name: 'Pierce Van Meter', time: '11:56', pts: 0, reb: 0, ast: 3, blk: 0, stl: 0, to: 2, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-1' },
{ num: '10', name: 'Chris Latson', time: '17:07', pts: 6, reb: 2, ast: 0, blk: 0, stl: 1, to: 2, two_a: '2/5', three_a: '0/1', ft_a: '0/0', efg: '33.3%', pm: '-11', starter: true },
{ num: '11', name: 'Jermaine Caver', time: '21:51', pts: 4, reb: 3, ast: 2, blk: 1, stl: 0, to: 2, two_a: '1/3', three_a: '0/1', ft_a: '0/0', efg: '25.0%', pm: '-11', starter: true },
{ num: '12', name: 'Guy Tyler', time: '2:01', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+2' },
{ num: '21', name: 'Cooper Romano', time: '15:34', pts: 5, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '1/1', ft_a: '0/0', efg: '150.0%', pm: '-14' },
{ num: '22', name: 'Noah Pesnell', time: '10:02', pts: 4, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '2/2', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+1', starter: true },
{ num: '23', name: 'Elijah Wilson', time: '2:01', pts: 2, reb: 0, ast: 0, blk: 0, stl: 1, to: 0, two_a: '1/1', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+2' },
],
},
{
id: 's2g4',
date: 'Jun 28, 2026',
time: '11:00 AM',
court: 'Court 04',
game_id: '37411',
my_team: 'Milton HS - GA',
opp_team: 'Calvary Baptist - LA',
my_score: 56,
opp_score: 54,
result: 'W',
film_url: 'https://vimeo.com/1205631813',
team_stats: {
my: { pts: 56, ppp: 1.17, ast: 9, reb: 27, oreb: 9, dreb: 18, blk: 4, stl: 5, to: 6, deflections: 6, fouls: 8, def_fouls: 8, charges: 0, kills: 3, efg_pct: '52.3%', to_pct: '12.0%', oreb_pct: '33.3%', ftr: 0.30, two_made: 11, two_att: 24, two_pct: '45.8%', three_made: 8, three_att: 20, three_pct: '40.0%', ft_made: 10, ft_att: 13, ft_pct: '76.9%', scoring_opps: 51, shots: 44, ft_trips: 7, two_rate: '54.5%', three_rate: '45.5%', ft: '10/13' },
opp: { pts: 54, ppp: 1.08, ast: 12, reb: 19, oreb: 1, dreb: 18, blk: 1, stl: 5, to: 7, deflections: 4, fouls: 10, def_fouls: 10, charges: 0, kills: 2, efg_pct: '61.5%', to_pct: '15.2%', oreb_pct: '5.3%', ftr: 0.28, two_made: 18, two_att: 26, two_pct: '69.2%', three_made: 4, three_att: 13, three_pct: '30.8%', ft_made: 6, ft_att: 11, ft_pct: '54.5%', scoring_opps: 46, shots: 39, ft_trips: 7, two_rate: '66.7%', three_rate: '33.3%', ft: '6/11' },
},
my_players: [
{ num: '0', name: 'Ty Nealis', time: '14:16', pts: 0, reb: 2, ast: 0, blk: 0, stl: 1, to: 2, two_a: '0/1', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+5' },
{ num: '1', name: 'Mason Pridgett', time: '17:23', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/5', three_a: '0/0', ft_a: '0/0', efg: '0%', pm: '+4', starter: true },
{ num: '3', name: 'Pierce Strom', time: '20:17', pts: 20, reb: 3, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/1', three_a: '6/11', ft_a: '0/0', efg: '83.3%', pm: '+4', starter: true },
{ num: '5', name: 'William Golden', time: '30:00', pts: 8, reb: 1, ast: 9, blk: 2, stl: 2, to: 0, two_a: '2/4', three_a: '0/1', ft_a: '0/0', efg: '40.0%', pm: '+2', starter: true },
{ num: '12', name: 'CJ Omoyele', time: '3:37', pts: 0, reb: 0, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-8' },
{ num: '13', name: 'Graham Whitehart', time: '11:47', pts: 0, reb: 2, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-6' },
{ num: '14', name: 'Jackson Harrison', time: '22:36', pts: 10, reb: 2, ast: 1, blk: 0, stl: 2, to: 0, two_a: '2/2', three_a: '2/7', ft_a: '0/0', efg: '55.6%', pm: '+7', starter: true },
{ num: '22', name: 'Solomon Bratton', time: '30:00', pts: 18, reb: 10, ast: 5, blk: 2, stl: 0, to: 4, two_a: '6/10', three_a: '0/0', ft_a: '0/0', efg: '60.0%', pm: '+2', starter: true },
],
opp_players: [
{ num: '0', name: 'Jaiden Hall', time: '23:59', pts: 15, reb: 2, ast: 2, blk: 0, stl: 2, to: 2, two_a: '6/6', three_a: '1/5', ft_a: '0/0', efg: '68.2%', pm: '-3', starter: true },
{ num: '1', name: 'Nick Shelton', time: '19:00', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 1, two_a: '0/1', three_a: '0/1', ft_a: '0/0', efg: '0%', pm: '-12', starter: true },
{ num: '2', name: 'Josiah Thibodeaux', time: '1:48', pts: 0, reb: 1, ast: 0, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '-2' },
{ num: '3', name: 'Calvin Dagley', time: '8:24', pts: 2, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '1/2', three_a: '0/1', ft_a: '0/0', efg: '33.3%', pm: '+4' },
{ num: '4', name: 'Robert Wright Jr', time: '26:34', pts: 16, reb: 3, ast: 4, blk: 0, stl: 0, to: 2, two_a: '6/7', three_a: '0/0', ft_a: '0/0', efg: '85.7%', pm: '-11', starter: true },
{ num: '5', name: 'Tre Mcdaniel', time: '12:18', pts: 4, reb: 0, ast: 0, blk: 1, stl: 2, to: 0, two_a: '2/2', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '-7', starter: true },
{ num: '10', name: 'Jasper Henderson', time: '3:25', pts: 2, reb: 0, ast: 1, blk: 0, stl: 0, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+9' },
{ num: '11', name: 'TJ Jamison', time: '28:11', pts: 13, reb: 6, ast: 2, blk: 0, stl: 0, to: 1, two_a: '2/7', three_a: '3/6', ft_a: '0/0', efg: '50.0%', pm: '0', starter: true },
{ num: '12', name: 'Ashton Jackson', time: '8:35', pts: 0, reb: 0, ast: 2, blk: 0, stl: 1, to: 0, two_a: '0/0', three_a: '0/0', ft_a: '0/0', efg: '—', pm: '+7' },
{ num: '21', name: 'Isaiah Sanders', time: '17:41', pts: 2, reb: 5, ast: 0, blk: 0, stl: 0, to: 0, two_a: '1/1', three_a: '0/0', ft_a: '0/0', efg: '100.0%', pm: '+5' },
],
},
];
// ── Sub-components ────────────────────────────────────────────────────────────
function PlayerTable({ players, teamLabel }) {
return (
<div className="overflow-x-auto">
<p className="text-xs font-bold uppercase tracking-widest mb-2 px-1" style={{ color: ORANGE }}>{teamLabel}</p>
<table className="w-full text-xs min-w-[700px]">
<thead>
<tr className="border-b" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
{['#', 'Player', 'Time', 'PTS', 'REB', 'AST', 'BLK', 'STL', 'TO', '2Pt/A', '3Pt/A', 'FT/A', 'EFG%', '+/-'].map(h => (
<th key={h} className="text-left py-1.5 px-1 text-gray-600 font-bold uppercase tracking-wider whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{players.map((p, i) => {
const slug = MILTON_PORTFOLIO_SLUGS[p.name];
return (
<tr key={i} className="border-b" style={{ borderColor: 'rgba(255,255,255,0.03)' }}>
<td className="py-1.5 px-1 text-gray-500">{p.num}</td>
<td className="py-1.5 px-1 text-white font-medium whitespace-nowrap">
{slug ? (
<Link to={`/player/${slug}`} className="hover:text-orange-400 transition-colors">{p.name}</Link>
) : (
p.name
)}{p.starter ? <span className="text-orange-500 ml-0.5">*</span> : null}
</td>
<td className="py-1.5 px-1 text-gray-500">{p.time}</td>
<td className="py-1.5 px-1 font-bold text-white">{p.pts}</td>
<td className="py-1.5 px-1 text-gray-300">{p.reb}</td>
<td className="py-1.5 px-1 text-gray-300">{p.ast}</td>
<td className="py-1.5 px-1 text-gray-400">{p.blk}</td>
<td className="py-1.5 px-1 text-gray-400">{p.stl}</td>
<td className="py-1.5 px-1 text-gray-400">{p.to}</td>
<td className="py-1.5 px-1 text-gray-400">{p.two_a}</td>
<td className="py-1.5 px-1 text-gray-400">{p.three_a}</td>
<td className="py-1.5 px-1 text-gray-400">{p.ft_a}</td>
<td className="py-1.5 px-1 text-gray-400">{p.efg}</td>
<td className={`py-1.5 px-1 font-bold ${p.pm && p.pm.startsWith('+') ? 'text-green-400' : p.pm && p.pm !== '0' ? 'text-red-400' : 'text-gray-500'}`}>{p.pm}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function MiltonTeamStats({ game }) {
const [expanded, setExpanded] = useState(false);
const my = game.team_stats.my;
const opp = game.team_stats.opp;
const parseFt = (ftStr) => {
if (!ftStr || !ftStr.includes('/')) return { made: '—', att: '—', pct: '—' };
const [made, att] = ftStr.split('/').map(Number);
const pct = att > 0 ? ((made / att) * 100).toFixed(1) + '%' : '—';
return { made, att, pct };
};
const myFt = parseFt(my.ft);
const oppFt = parseFt(opp.ft);
const fmt = (v) => (v == null || v === '') ? '—' : v;
const summaryStats = [
{ label: 'AST', my: my.ast, opp: opp.ast },
{ label: 'REB', my: my.reb, opp: opp.reb },
{ label: 'eFG%', my: my.efg_pct, opp: opp.efg_pct },
{ label: 'FTA', my: myFt.att, opp: oppFt.att },
{ label: 'FT%', my: myFt.pct, opp: oppFt.pct },
{ label: '2FG%', my: my.two_pct, opp: opp.two_pct },
{ label: '3FG%', my: my.three_pct, opp: opp.three_pct },
{ label: 'TO', my: my.to, opp: opp.to },
];
const fullStats = [
{ label: 'Points', my: my.pts, opp: opp.pts },
{ label: 'Pts Per Possession', my: my.ppp, opp: opp.ppp },
{ label: '— Four Factors —', header: true },
{ label: 'Eff. FG%', my: my.efg_pct, opp: opp.efg_pct },
{ label: 'Turnover %', my: my.to_pct, opp: opp.to_pct },
{ label: 'Off. Reb %', my: my.oreb_pct, opp: opp.oreb_pct },
{ label: 'Free Throw Rate', my: my.ftr, opp: opp.ftr },
{ label: '— Shooting —', header: true },
{ label: '2 Pt (M/A)', my: my.two_made != null ? `${my.two_made}/${my.two_att}` : null, opp: opp.two_made != null ? `${opp.two_made}/${opp.two_att}` : null },
{ label: '2 Pt %', my: my.two_pct, opp: opp.two_pct },
{ label: '3 Pt (M/A)', my: my.three_made != null ? `${my.three_made}/${my.three_att}` : null, opp: opp.three_made != null ? `${opp.three_made}/${opp.three_att}` : null },
{ label: '3 Pt %', my: my.three_pct, opp: opp.three_pct },
{ label: 'Free Throws (M/A)', my: my.ft_made != null ? `${my.ft_made}/${my.ft_att}` : null, opp: opp.ft_made != null ? `${opp.ft_made}/${opp.ft_att}` : null },
{ label: 'FT %', my: my.ft_pct, opp: opp.ft_pct },
{ label: 'Scoring Opps', my: my.scoring_opps, opp: opp.scoring_opps },
{ label: 'Total Shots', my: my.shots, opp: opp.shots },
{ label: 'FT Trips', my: my.ft_trips, opp: opp.ft_trips },
{ label: '2 Pt Rate', my: my.two_rate, opp: opp.two_rate },
{ label: '3 Pt Rate', my: my.three_rate, opp: opp.three_rate },
{ label: '— Team Play —', header: true },
{ label: 'Assists', my: my.ast, opp: opp.ast },
{ label: 'Turnovers', my: my.to, opp: opp.to },
{ label: 'Turnover %', my: my.to_pct, opp: opp.to_pct },
{ label: 'Steals', my: my.stl, opp: opp.stl },
{ label: 'Blocks', my: my.blk, opp: opp.blk },
{ label: 'Deflections', my: my.deflections, opp: opp.deflections },
{ label: 'Fouls', my: my.fouls, opp: opp.fouls },
{ label: 'Def. Fouls', my: my.def_fouls, opp: opp.def_fouls },
{ label: 'Charges Taken', my: my.charges, opp: opp.charges },
{ label: 'Kills (3 stops)', my: my.kills, opp: opp.kills },
{ label: '— Rebounding —', header: true },
{ label: 'Total Rebounds', my: my.reb, opp: opp.reb },
{ label: 'Off. Rebounds', my: my.oreb, opp: opp.oreb },
{ label: 'Def. Rebounds', my: my.dreb, opp: opp.dreb },
{ label: 'Off. Reb %', my: my.oreb_pct, opp: opp.oreb_pct },
];
return (
<div>
{/* Scoreboard */}
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
<div className="text-center flex-1">
<p className="text-xs font-bold uppercase tracking-wider truncate" style={{ color: ORANGE }}>{game.my_team}</p>
<p className="text-4xl font-black" style={{ color: ORANGE }}>{fmt(my.pts)}</p>
</div>
<div className="text-gray-600 font-bold text-sm px-4">vs</div>
<div className="text-center flex-1">
<p className="text-xs font-bold uppercase tracking-wider truncate text-gray-400">{game.opp_team}</p>
<p className="text-4xl font-black text-white">{fmt(opp.pts)}</p>
</div>
</div>
{/* Summary stats grid — my team */}
<div className="px-5 pt-4 pb-2">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: ORANGE }}>{game.my_team}</p>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{summaryStats.map(s => (
<div key={s.label} className="text-center">
<div className="text-base font-black text-white">{fmt(s.my)}</div>
<div className="text-[10px] text-gray-600 uppercase tracking-wider leading-tight">{s.label}</div>
</div>
))}
</div>
</div>
{/* Summary stats grid — opponent */}
<div className="px-5 pt-2 pb-3 border-b" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
<p className="text-xs font-bold uppercase tracking-widest mb-3 text-gray-500">{game.opp_team}</p>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{summaryStats.map(s => (
<div key={s.label} className="text-center">
<div className="text-base font-black text-gray-400">{fmt(s.opp)}</div>
<div className="text-[10px] text-gray-700 uppercase tracking-wider leading-tight">{s.label}</div>
</div>
))}
</div>
</div>
{/* Full Game Stats toggle */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-center gap-2 w-full py-3 text-xs font-bold uppercase tracking-widest border-b transition-colors"
style={{ borderColor: 'rgba(255,255,255,0.05)', color: expanded ? ORANGE : 'rgba(255,255,255,0.35)' }}
>
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
Full Game Stats
</button>
{/* Expanded full stats table */}
{expanded && (
<div className="px-4 pb-4">
<div className="grid grid-cols-3 gap-2 py-2 text-xs font-bold uppercase tracking-wider border-b mb-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
<div style={{ color: ORANGE }} className="truncate">{game.my_team}</div>
<div className="text-center text-gray-600">Stat</div>
<div className="text-right text-gray-500 truncate">{game.opp_team}</div>
</div>
{fullStats.map(s => s.header ? (
<div key={s.label} className="text-xs font-black uppercase tracking-widest mt-3 mb-1 px-1" style={{ color: ORANGE }}>{s.label.replace(/—/g, '').trim()}</div>
) : (
<div key={s.label} className="grid grid-cols-3 gap-2 py-1.5 rounded-lg px-1" style={{ background: 'rgba(255,255,255,0.02)' }}>
<div className="text-sm font-bold text-white">{fmt(s.my)}</div>
<div className="text-center text-xs text-gray-600 self-center">{s.label}</div>
<div className="text-sm font-bold text-right text-gray-300">{fmt(s.opp)}</div>
</div>
))}
</div>
)}
</div>
);
}
function GameCard({ game }) {
const [open, setOpen] = useState(false);
const [showMyPlayers, setShowMyPlayers] = useState(false);
const [showOppPlayers, setShowOppPlayers] = useState(false);
return (
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'rgba(255,255,255,0.07)', background: '#0a0a0a' }}>
{/* Header */}
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-white/[0.02] transition-colors text-left"
>
<p className="font-bold text-sm truncate flex-1 min-w-0">
<span style={{ color: ORANGE }}>{game.my_team}</span>
<span className="text-gray-500 mx-1">vs</span>
<span className="text-white">{game.opp_team}</span>
<span className="text-gray-600 ml-2">· {game.date} · {game.time}</span>
</p>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
{game.film_url && (
<a href={game.film_url} target="_blank" rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="p-1.5 rounded-lg text-gray-500 hover:text-white transition-colors"
style={{ background: 'rgba(255,255,255,0.05)' }}>
<ExternalLink className="w-3 h-3" />
</a>
)}
{open ? <ChevronUp className="w-4 h-4 text-gray-500" /> : <ChevronDown className="w-4 h-4 text-gray-500" />}
</div>
</button>
{open && (
<div className="border-t" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
{/* Game Film */}
{game.film_url && (
<div className="aspect-video bg-black">
<iframe
src={vimeoEmbed(game.film_url)}
className="w-full h-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
title={`${game.my_team} vs ${game.opp_team}`}
/>
</div>
)}
{/* Team Stats */}
<MiltonTeamStats game={game} />
{/* Player Stats toggles */}
<div className="px-4 pb-4 pt-2 space-y-3">
{/* My team players */}
<div>
<button
onClick={() => setShowMyPlayers(o => !o)}
className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest w-full py-2 border-t transition-colors"
style={{ borderColor: 'rgba(255,255,255,0.07)', color: showMyPlayers ? ORANGE : 'rgba(255,255,255,0.4)' }}
>
<Users className="w-3.5 h-3.5" />
{game.my_team} Player Stats
{showMyPlayers ? <ChevronUp className="w-3.5 h-3.5 ml-auto" /> : <ChevronDown className="w-3.5 h-3.5 ml-auto" />}
</button>
{showMyPlayers && <PlayerTable players={game.my_players} teamLabel={game.my_team} />}
</div>
{/* Opponent players */}
<div>
<button
onClick={() => setShowOppPlayers(o => !o)}
className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest w-full py-2 border-t transition-colors"
style={{ borderColor: 'rgba(255,255,255,0.07)', color: showOppPlayers ? ORANGE : 'rgba(255,255,255,0.4)' }}
>
<Users className="w-3.5 h-3.5" />
{game.opp_team} Player Stats
{showOppPlayers ? <ChevronUp className="w-3.5 h-3.5 ml-auto" /> : <ChevronDown className="w-3.5 h-3.5 ml-auto" />}
</button>
{showOppPlayers && <PlayerTable players={game.opp_players} teamLabel={game.opp_team} />}
</div>
</div>
</div>
)}
</div>
);
}
function SessionSection({ title, games, defaultOpen = false }) {
const [open, setOpen] = useState(defaultOpen);
const wins = games.filter(g => g.result === 'W').length;
const losses = games.filter(g => g.result === 'L').length;
return (
<div className="rounded-2xl border overflow-hidden" style={{ borderColor: 'rgba(255,255,255,0.07)', background: '#050505' }}>
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-white/[0.02] transition-colors text-left"
>
<div>
<p className="font-barlow font-black text-lg uppercase tracking-wide text-white">
{title}
</p>
<p className="text-xs text-gray-500 mt-0.5">
{games.length} games · <span className="text-green-400 font-bold">{wins}W</span> <span className="text-red-400 font-bold">{losses}L</span>
</p>
</div>
{open ? <ChevronUp className="w-5 h-5" style={{ color: ORANGE }} /> : <ChevronDown className="w-5 h-5 text-gray-500" />}
</button>
{open && (
<div className="border-t px-4 pb-4 pt-3 space-y-3" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
{games.map(game => (
<GameCard key={game.id} game={game} />
))}
</div>
)}
</div>
);
}
export default function MiltonGameStats() {
const [films, setFilms] = useState([]);
useEffect(() => {
const load = async () => {
try {
const allGames = await base44.entities.GeorgiaGame.list();
const miltonFilms = allGames
.filter(g => {
const isMilton = normalizeTeam(g.team1).includes('milton') || normalizeTeam(g.team2).includes('milton');
return isMilton && g.embed_link;
})
.map(g => ({
date: toISODate(g.date),
opponent: normalizeTeam(g.team1).includes('milton') ? g.team2 : g.team1,
embed_link: g.embed_link,
}));
setFilms(miltonFilms);
} catch (e) {
console.error('Error loading game films:', e);
}
};
load();
}, []);
const findFilm = (game) => {
const isoDate = toISODate(game.date);
const match = films.find(f => f.date === isoDate && teamsMatch(f.opponent, game.opp_team));
return match?.embed_link || game.film_url;
};
const mergeFilm = (games) => games.map(g => ({ ...g, film_url: findFilm(g) }));
return (
<div className="space-y-4">
<SessionSection
title="GBCA Live Period — Session 1 · June 12–14, 2026"
games={mergeFilm(SESSION_1_GAMES)}
defaultOpen={false}
/>
<SessionSection
title="GBCA Live Period — Session 2 SE Regional · June 26–28, 2026"
games={mergeFilm(SESSION_2_GAMES)}
defaultOpen={true}
/>
<p className="text-xs text-gray-700 text-center pt-2">Stats powered by Hoopsalytics · * = Starter</p>
</div>
);
}src/components/georgia/MiltonSponsors.jsx const SPONSORS = [
{ name: 'Harvest Grocer', logo: 'https://media.base44.com/images/public/69bb16a38ac0086814d94e6f/fece4a897_2.png' },
{ name: 'Brew & Bean Coffee Shop', logo: 'https://media.base44.com/images/public/69bb16a38ac0086814d94e6f/c50e070bb_5.png' },
{ name: 'Crave Restaurant', logo: 'https://media.base44.com/images/public/69bb16a38ac0086814d94e6f/5bf67904d_6.png' },
{ name: 'Rimberio Construction', logo: 'https://media.base44.com/images/public/69bb16a38ac0086814d94e6f/044b3dc64_1.png' },
{ name: 'Family Dentist', logo: 'https://media.base44.com/images/public/69bb16a38ac0086814d94e6f/742114927_3.png' },
{ name: 'Alignwell', logo: 'https://media.base44.com/images/public/69bb16a38ac0086814d94e6f/68d400d13_4.png' },
];
export default function MiltonSponsors() {
return (
<section className="mt-12 pt-8 border-t" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
<div className="text-center mb-6">
<p className="font-barlow font-black text-3xl uppercase tracking-wide text-white">We Thank Our Local Sponsors</p>
<p className="mt-2 text-sm text-gray-500">Proud supporters of Milton basketball.</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4">
{SPONSORS.map((sponsor) => (
<div key={sponsor.name} className="aspect-square rounded-xl overflow-hidden border p-3 sm:p-5" style={{ background: '#0a0a0a', borderColor: 'rgba(255,255,255,0.08)' }}>
<img src={sponsor.logo} alt={`${sponsor.name} logo`} className="w-full h-full object-contain" />
</div>
))}
</div>
</section>
);
}src/components/georgia/PortfolioDemo.jsx import { motion } from 'framer-motion';
import { ExternalLink } from 'lucide-react';
const ORANGE = '#FF6A00';
const ORANGE_BORDER = 'rgba(255,106,0,0.22)';
const ORANGE_DIM = 'rgba(255,106,0,0.12)';
const PORTFOLIO_URL = 'https://goaio.live/player/solomon-bratton';
export default function PortfolioDemo() {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="rounded-2xl overflow-hidden"
style={{ border: `1px solid ${ORANGE_BORDER}`, boxShadow: `0 0 40px ${ORANGE_DIM}` }}
>
{/* Browser-style header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b" style={{ background: '#1a1a1a', borderColor: 'rgba(255,255,255,0.06)' }}>
<div className="flex items-center gap-2.5">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500/70" />
<div className="w-3 h-3 rounded-full bg-orange-400/70" />
<div className="w-3 h-3 rounded-full bg-green-500/70" />
</div>
<span className="text-xs font-mono" style={{ color: 'rgba(255,255,255,0.35)' }}>
goaio.live/player/solomon-bratton
</span>
</div>
<a
href={PORTFOLIO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs transition-colors"
style={{ color: ORANGE }}
>
<ExternalLink className="w-3.5 h-3.5" /> Open Live
</a>
</div>
{/* Static iframe — live portfolio */}
<div className="w-full" style={{ background: '#0a0a0a' }}>
<iframe
src={PORTFOLIO_URL}
title="Solomon Bratton — Live Blueprint Portfolio"
className="w-full border-0"
style={{ height: '560px' }}
loading="eager"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
<div className="px-5 py-3.5 border-t" style={{ background: '#141414', borderColor: 'rgba(255,255,255,0.06)' }}>
<p className="text-xs" style={{ color: 'rgba(255,255,255,0.4)' }}>
<span className="font-semibold" style={{ color: ORANGE }}>Solomon Bratton</span> — Live Blueprint Portfolio · Auto-generated from game footage
</p>
</div>
</motion.div>
);
}src/components/georgia/PortfolioLinkCard.jsx import { motion } from 'framer-motion';
import { ArrowRight } from 'lucide-react';
const ORANGE = '#FF6A00';
const ORANGE_BORDER = 'rgba(255,106,0,0.22)';
const ORANGE_DIM = 'rgba(255,106,0,0.12)';
const PORTFOLIO_URL = 'https://goaio.live/player/solomon-bratton';
export default function PortfolioLinkCard() {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="rounded-2xl overflow-hidden"
style={{ background: '#0a0a0a', border: `1px solid ${ORANGE_BORDER}` }}
>
<a
href={PORTFOLIO_URL}
target="_blank"
rel="noopener noreferrer"
className="block w-full text-left transition-all hover:scale-[1.01]"
>
<div className="flex flex-col items-center justify-center text-center gap-4 px-6 py-16">
<div
className="flex items-center justify-center w-16 h-16 rounded-2xl"
style={{ background: ORANGE_DIM, border: `1px solid ${ORANGE_BORDER}` }}
>
<ArrowRight className="w-7 h-7" style={{ color: ORANGE }} />
</div>
<div>
<p className="font-barlow font-black text-2xl text-white mb-1">Sample Portfolio</p>
<p className="text-sm" style={{ color: 'rgba(255,255,255,0.45)' }}>
Click here to view Solomon Bratton's live portfolio
</p>
</div>
<span
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-black uppercase tracking-widest"
style={{ background: ORANGE, color: '#000' }}
>
View Portfolio <ArrowRight className="w-4 h-4" />
</span>
</div>
</a>
</motion.div>
);
}src/components/georgia/PortfolioSearch.jsx import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, ArrowRight, Camera, Loader2, AlertCircle, Share2, ChevronDown, ChevronUp } from 'lucide-react';
import { base44 } from '@/api/base44Client';
import { motion, AnimatePresence } from 'framer-motion';
import SharePortfolioModal from './SharePortfolioModal';
const ORANGE = '#FF6A00';
const BORDER = 'rgba(255,255,255,0.08)';
export default function PortfolioSearch({ autoFocus = false }) {
const [query, setQuery] = useState('');
const [discountOpen, setDiscountOpen] = useState(false);
const [allPlayers, setAllPlayers] = useState(null);
const [loadingPlayers, setLoadingPlayers] = useState(false);
const [sharingPlayer, setSharingPlayer] = useState(null);
const inputRef = useRef(null);
const navigate = useNavigate();
useEffect(() => {
if (autoFocus && inputRef.current) inputRef.current.focus();
}, [autoFocus]);
// Load all published players once on mount
useEffect(() => {
setLoadingPlayers(true);
base44.entities.Player.filter({ is_published: true }, '-created_date', 5000)
.then(players => setAllPlayers(players))
.catch(() => setAllPlayers([]))
.finally(() => setLoadingPlayers(false));
}, []);
const results = (() => {
if (!allPlayers || !query.trim()) return null;
const q = query.trim().toLowerCase();
return allPlayers.filter(p => (p.full_name || '').toLowerCase().includes(q));
})();
const handlePlayerClick = (player) => {
if (player.portfolio_url_slug) {
navigate(`/player/${player.portfolio_url_slug}`);
}
};
return (
<div className="w-full max-w-2xl mx-auto">
{/* $25 Discount callout — collapsible */}
<motion.div
initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}
className="mb-6 overflow-hidden"
style={{ border: `1px solid rgba(255,106,0,0.35)`, background: 'rgba(255,106,0,0.07)' }}
>
<button
type="button"
onClick={() => setDiscountOpen(o => !o)}
className="w-full flex items-center justify-between gap-3 p-4 text-left"
>
<div className="flex items-center gap-3">
<Camera className="w-5 h-5 shrink-0" style={{ color: ORANGE }} />
<p className="font-barlow font-black text-sm uppercase tracking-widest text-white" style={{ letterSpacing: '0.12em' }}>
📸 Save $25 — Visit the GameOn AIO Table
</p>
</div>
<div className="shrink-0 flex items-center gap-2">
<span className="font-barlow font-black text-lg" style={{ color: ORANGE }}>$25 off</span>
{discountOpen
? <ChevronUp className="w-4 h-4" style={{ color: ORANGE }} />
: <ChevronDown className="w-4 h-4" style={{ color: ORANGE }} />}
</div>
</button>
<AnimatePresence initial={false}>
{discountOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
style={{ overflow: 'hidden' }}
>
<div className="px-4 pb-4 pt-0 border-t" style={{ borderColor: 'rgba(255,106,0,0.2)' }}>
<p className="text-sm leading-relaxed mt-3" style={{ color: 'rgba(255,255,255,0.6)' }}>
Come by the <span className="text-white font-semibold">GameOn AIO courtside table</span> and get your official player photo taken by our team. That photo goes straight onto your portfolio — and you get <span style={{ color: ORANGE }} className="font-bold">$25 off</span> at checkout automatically.
</p>
<div className="mt-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full animate-pulse" style={{ background: ORANGE }} />
<span className="text-xs font-semibold uppercase tracking-widest" style={{ color: ORANGE }}>No code needed — discount applied at the table</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Search input */}
<motion.div
initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }}
className="relative mb-6"
>
<div className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none">
{loadingPlayers
? <Loader2 className="w-4 h-4 animate-spin" style={{ color: 'rgba(255,255,255,0.3)' }} />
: <Search className="w-4 h-4" style={{ color: 'rgba(255,255,255,0.3)' }} />}
</div>
<input
ref={inputRef}
type="text"
placeholder="Search your name…"
value={query}
onChange={e => setQuery(e.target.value)}
className="w-full pl-11 pr-4 py-4 text-base text-white placeholder-white/30 focus:outline-none"
style={{ background: '#0a0a0a', border: `1px solid ${BORDER}`, fontFamily: 'var(--font-inter)' }}
onFocus={e => e.currentTarget.style.borderColor = 'rgba(255,106,0,0.5)'}
onBlur={e => e.currentTarget.style.borderColor = BORDER}
/>
</motion.div>
{/* Results */}
<AnimatePresence>
{results !== null && (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }}>
{results.length === 0 ? (
<div className="flex items-center gap-3 p-5" style={{ border: `1px solid ${BORDER}`, background: '#0a0a0a' }}>
<AlertCircle className="w-5 h-5 shrink-0" style={{ color: 'rgba(255,255,255,0.3)' }} />
<div>
<p className="text-sm font-semibold text-white">No portfolio found</p>
<p className="text-xs mt-0.5" style={{ color: 'rgba(255,255,255,0.4)' }}>
Your portfolio may still be processing — check back after the tournament, or visit the GameOn AIO table.
</p>
</div>
</div>
) : (
<div className="space-y-2">
<p className="text-xs uppercase tracking-widest mb-3" style={{ color: 'rgba(255,255,255,0.3)', letterSpacing: '0.16em' }}>
{results.length} result{results.length !== 1 ? 's' : ''} found
</p>
{results.map(player => (
<div key={player.id} className="flex items-center gap-2">
<button onClick={() => handlePlayerClick(player)}
disabled={!player.portfolio_url_slug}
className="flex-1 text-left flex items-center gap-4 p-4 transition-all"
style={{ border: `1px solid ${BORDER}`, background: '#0a0a0a', cursor: player.portfolio_url_slug ? 'pointer' : 'default' }}
onMouseEnter={e => { if (player.portfolio_url_slug) e.currentTarget.style.borderColor = 'rgba(255,106,0,0.4)'; }}
onMouseLeave={e => e.currentTarget.style.borderColor = BORDER}
>
{/* Photo or initials */}
<div className="w-12 h-12 shrink-0 overflow-hidden" style={{ border: `1px solid ${BORDER}` }}>
{player.profile_photo_url
? <img src={player.profile_photo_url} alt={player.full_name} className="w-full h-full object-cover" />
: <div className="w-full h-full flex items-center justify-center font-barlow font-black text-lg" style={{ background: '#161616', color: ORANGE }}>
{(player.full_name || '?')[0]}
</div>
}
</div>
<div className="flex-1 min-w-0">
<p className="font-barlow font-black text-white text-base uppercase">{player.full_name}</p>
<p className="text-xs mt-0.5" style={{ color: 'rgba(255,255,255,0.4)' }}>
{[player.position, player.high_school, player.graduation_year ? `Class of ${player.graduation_year}` : null].filter(Boolean).join(' · ')}
</p>
</div>
{player.portfolio_url_slug && (
<div className="flex items-center gap-1 text-xs font-black uppercase tracking-widest shrink-0" style={{ color: ORANGE }}>
View <ArrowRight className="w-3.5 h-3.5" />
</div>
)}
</button>
{player.portfolio_url_slug && (
<button
onClick={() => setSharingPlayer(player)}
className="flex items-center justify-center w-12 h-full py-4 shrink-0 transition-all"
style={{ border: `1px solid ${BORDER}`, background: '#0a0a0a', minHeight: '72px' }}
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(255,106,0,0.4)'}
onMouseLeave={e => e.currentTarget.style.borderColor = BORDER}
title="Share portfolio"
>
<Share2 className="w-4 h-4" style={{ color: ORANGE }} />
</button>
)}
</div>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
{sharingPlayer && (
<SharePortfolioModal player={sharingPlayer} onClose={() => setSharingPlayer(null)} />
)}
</div>
);
}src/components/georgia/RecruitingFactsGrid.jsx import { motion } from 'framer-motion';
const ORANGE = '#FF6A00';
const facts = [
{
stat: '92%',
label: 'of college coaches now require verified game analytics before extending an offer',
icon: '📊',
},
{
stat: '3×',
label: 'more likely to receive D1/D2 interest with an authenticated film + stats portfolio',
icon: '🎯',
},
{
stat: '400%',
label: 'increase in coach engagement when film is paired with verified per-game boxscores',
icon: '📈',
},
];
export default function RecruitingFactsGrid() {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.6 }}
className="w-full mb-8"
>
<p className="text-center text-[10px] font-black uppercase tracking-[0.2em] mb-3" style={{ color: 'rgba(255,255,255,0.3)' }}>
Why your portfolio matters
</p>
<div className="grid grid-cols-3 gap-2">
{facts.map((f, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 + i * 0.08, duration: 0.5 }}
className="flex flex-col items-center text-center p-3 rounded-xl"
style={{ background: 'rgba(255,106,0,0.05)', border: '1px solid rgba(255,106,0,0.15)' }}
>
<span className="text-lg mb-1">{f.icon}</span>
<span className="font-barlow font-black text-2xl leading-none mb-1" style={{ color: ORANGE }}>{f.stat}</span>
<p className="text-[10px] leading-snug" style={{ color: 'rgba(255,255,255,0.4)' }}>{f.label}</p>
</motion.div>
))}
</div>
</motion.div>
);
}src/components/georgia/RosterPlayerCard.jsx import { useState } from 'react';
import { Link } from 'react-router-dom';
import { CheckCircle2, Star, ShoppingCart, Copy, Check, Mail, MessageSquare, Share2 } from 'lucide-react';
const ORANGE = '#FF6A00';
export default function RosterPlayerCard({ player, user }) {
const [copied, setCopied] = useState(false);
const premium = player.portfolio && (player.portfolio.portfolio_tier === 'premium' || player.portfolio.portfolio_tier === 'under_review');
const slug = player.portfolio?.portfolio_url_slug || player.portfolio_url_slug;
const portfolioUrl = slug ? `https://goaio.live/player/${slug}` : null;
const claimUrl = `https://goaio.live/georgiaplayer`;
// The link to share — either the existing profile or the claim page
const shareUrl = portfolioUrl || claimUrl;
const playerName = player.full_name || 'Player';
const shareText = portfolioUrl
? `${playerName}'s player portfolio: ${portfolioUrl}`
: `Hey ${playerName}, claim your player portfolio here: ${claimUrl}`;
const handleCopy = () => {
navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleEmail = () => {
const subject = encodeURIComponent(portfolioUrl ? `${playerName}'s Player Portfolio` : `Claim Your Player Portfolio`);
const body = encodeURIComponent(shareText);
window.open(`mailto:?subject=${subject}&body=${body}`);
};
const handleText = () => {
window.open(`sms:?&body=${encodeURIComponent(shareText)}`);
};
const isCoachOrAdmin = user && (user.role === 'admin' || user.role === 'coach');
return (
<div className="rounded-xl border overflow-hidden transition-all"
style={{ background: '#0a0a0a', borderColor: premium ? 'rgba(255,106,0,0.25)' : 'rgba(255,255,255,0.06)' }}>
{/* Header with photo and name */}
<div className="flex items-start gap-4 px-4 pt-3 pb-2">
{/* Photo */}
<div className="w-10 h-10 rounded-full overflow-hidden shrink-0 border mt-0.5"
style={{ borderColor: premium ? 'rgba(255,106,0,0.4)' : 'rgba(255,255,255,0.06)', background: 'rgba(255,255,255,0.04)' }}>
{player.photo_url || player.portfolio?.profile_photo_url
? <img src={player.photo_url || player.portfolio?.profile_photo_url} alt={playerName} className="w-full h-full object-cover" />
: <div className="w-full h-full flex items-center justify-center font-barlow font-black text-sm text-gray-500">{playerName[0]}</div>}
</div>
{/* Name and details */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{slug ? (
<Link to={`/player/${slug}`} className="font-semibold text-sm" style={{ color: premium ? 'white' : 'rgba(255,255,255,0.7)' }}>{playerName}</Link>
) : (
<p className="font-semibold text-sm" style={{ color: premium ? 'white' : 'rgba(255,255,255,0.7)' }}>{playerName}</p>
)}
{premium && <Star className="w-3.5 h-3.5 shrink-0" style={{ color: ORANGE }} />}
<span className="text-xs text-gray-600 ml-auto shrink-0">
{[player.portfolio?.height, player.portfolio?.weight_lbs ? `${player.portfolio.weight_lbs}lbs` : null].filter(Boolean).join(' · ')}
</span>
</div>
<p className="text-xs text-gray-600">
{[player.jersey_number ? `#${player.jersey_number}` : null, player.position, player.portfolio?.class_year || player.class_year].filter(Boolean).join(' · ')}
</p>
</div>
</div>
{/* Share and Claim buttons side by side */}
<div className="flex items-center gap-2 px-4 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
<button onClick={handleCopy} title="Share portfolio link"
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg text-xs font-bold transition-all"
style={{ background: 'rgba(255,255,255,0.06)', color: copied ? '#00FF85' : 'rgba(255,255,255,0.5)', border: '1px solid rgba(255,255,255,0.1)' }}>
{copied ? <Check className="w-3 h-3" /> : <Share2 className="w-3 h-3" />}
{copied ? 'Copied!' : 'Share'}
</button>
{premium && slug ? (
<Link to={`/player/${slug}`}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold transition-all"
style={{ background: 'rgba(0,255,133,0.1)', color: '#00FF85', border: '1px solid rgba(0,255,133,0.25)' }}>
<CheckCircle2 className="w-3 h-3" /> Claimed
</Link>
) : (
<Link to="/georgiaplayer"
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold transition-all"
style={{ background: 'rgba(255,106,0,0.1)', color: ORANGE, border: '1px solid rgba(255,106,0,0.3)' }}>
<ShoppingCart className="w-3 h-3" /> Claim
</Link>
)}
</div>
{/* Coach share bar — shown for unclaimed players OR always for admins/coaches */}
{isCoachOrAdmin && (
<div className="flex items-center gap-2 px-4 py-2 border-t"
style={{ borderColor: 'rgba(255,255,255,0.05)', background: 'rgba(255,255,255,0.02)' }}>
<p className="text-xs text-gray-600 flex-1 truncate">
{premium ? 'Share portfolio:' : 'Send claim link:'} <span className="text-gray-500">{shareUrl}</span>
</p>
<button onClick={handleCopy} title="Copy link"
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-semibold transition-all"
style={{ background: 'rgba(255,255,255,0.06)', color: copied ? '#00FF85' : 'rgba(255,255,255,0.5)' }}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
{copied ? 'Copied' : 'Copy'}
</button>
<button onClick={handleEmail} title="Send via email"
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-semibold transition-all"
style={{ background: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.5)' }}>
<Mail className="w-3 h-3" /> Email
</button>
<button onClick={handleText} title="Send via text"
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-semibold transition-all"
style={{ background: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.5)' }}>
<MessageSquare className="w-3 h-3" /> Text
</button>
</div>
)}
</div>
);
}src/components/georgia/SharePortfolioModal.jsx import { useState } from 'react';
import { X, Mail, MessageSquare, Copy, Check } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
const ORANGE = '#FF6A00';
const BORDER = 'rgba(255,255,255,0.08)';
export default function SharePortfolioModal({ player, onClose }) {
const [copied, setCopied] = useState(false);
const portfolioUrl = `${window.location.origin}/player/${player.portfolio_url_slug}`;
const message = `Hey! Check out ${player.full_name}'s basketball portfolio — film, stats, and highlights all in one place: ${portfolioUrl}`;
const handleEmail = () => {
const subject = encodeURIComponent(`${player.full_name}'s Basketball Portfolio`);
const body = encodeURIComponent(message);
window.open(`mailto:?subject=${subject}&body=${body}`, '_blank');
};
const handleSMS = () => {
const body = encodeURIComponent(message);
window.open(`sms:?body=${body}`, '_blank');
};
const handleCopy = async () => {
await navigator.clipboard.writeText(portfolioUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(6px)' }}
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
className="relative w-full max-w-sm overflow-hidden"
style={{ background: '#0D0D0D', border: `1px solid rgba(255,106,0,0.3)`, boxShadow: '0 0 40px rgba(255,106,0,0.1)' }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4 border-b" style={{ borderColor: BORDER }}>
<div>
<p className="text-[10px] font-black uppercase tracking-widest mb-1" style={{ color: ORANGE, letterSpacing: '0.18em' }}>
Share Portfolio
</p>
<h3 className="font-barlow font-black text-xl text-white leading-tight">{player.full_name}</h3>
<p className="text-xs mt-0.5" style={{ color: 'rgba(255,255,255,0.4)' }}>Send to a parent or coach</p>
</div>
<button onClick={onClose} style={{ color: 'rgba(255,255,255,0.3)' }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'}
onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.3)'}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Options */}
<div className="px-6 py-5 space-y-3">
<button onClick={handleEmail}
className="w-full flex items-center gap-4 px-4 py-3.5 transition-all text-left"
style={{ background: '#141414', border: `1px solid ${BORDER}` }}
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(255,106,0,0.4)'}
onMouseLeave={e => e.currentTarget.style.borderColor = BORDER}
>
<div className="w-9 h-9 flex items-center justify-center shrink-0"
style={{ background: 'rgba(255,106,0,0.1)', border: `1px solid rgba(255,106,0,0.2)` }}>
<Mail className="w-4 h-4" style={{ color: ORANGE }} />
</div>
<div>
<p className="font-barlow font-black text-sm text-white uppercase tracking-wide">Send via Email</p>
<p className="text-xs mt-0.5" style={{ color: 'rgba(255,255,255,0.4)' }}>Opens your email app with a pre-filled message</p>
</div>
</button>
<button onClick={handleSMS}
className="w-full flex items-center gap-4 px-4 py-3.5 transition-all text-left"
style={{ background: '#141414', border: `1px solid ${BORDER}` }}
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(255,106,0,0.4)'}
onMouseLeave={e => e.currentTarget.style.borderColor = BORDER}
>
<div className="w-9 h-9 flex items-center justify-center shrink-0"
style={{ background: 'rgba(255,106,0,0.1)', border: `1px solid rgba(255,106,0,0.2)` }}>
<MessageSquare className="w-4 h-4" style={{ color: ORANGE }} />
</div>
<div>
<p className="font-barlow font-black text-sm text-white uppercase tracking-wide">Send via Text / SMS</p>
<p className="text-xs mt-0.5" style={{ color: 'rgba(255,255,255,0.4)' }}>Opens your messages app with the portfolio link</p>
</div>
</button>
<button onClick={handleCopy}
className="w-full flex items-center gap-4 px-4 py-3.5 transition-all text-left"
style={{ background: '#141414', border: `1px solid ${BORDER}` }}
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(255,106,0,0.4)'}
onMouseLeave={e => e.currentTarget.style.borderColor = BORDER}
>
<div className="w-9 h-9 flex items-center justify-center shrink-0"
style={{ background: copied ? 'rgba(0,200,100,0.1)' : 'rgba(255,106,0,0.1)', border: `1px solid ${copied ? 'rgba(0,200,100,0.2)' : 'rgba(255,106,0,0.2)'}` }}>
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" style={{ color: ORANGE }} />}
</div>
<div>
<p className="font-barlow font-black text-sm text-white uppercase tracking-wide">
{copied ? 'Link Copied!' : 'Copy Link'}
</p>
<p className="text-xs mt-0.5 truncate max-w-[200px]" style={{ color: 'rgba(255,255,255,0.4)' }}>{portfolioUrl}</p>
</div>
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}src/components/georgia/TeamCheckout.jsx import { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { Loader2 } from 'lucide-react';
const ORANGE = '#FF6A00';
export default function TeamCheckout({ teamSlug, teamName, onComplete }) {
const [promoCode, setPromoCode] = useState('');
const [promoError, setPromoError] = useState('');
const [promoLoading, setPromoLoading] = useState(false);
const [paymentLoading, setPaymentLoading] = useState(false);
const handlePaymentCheckout = async () => {
if (window.self !== window.top) {
alert('Checkout is only available on the published app. Please open the live site to complete your purchase.');
return;
}
setPaymentLoading(true);
try {
const baseUrl = window.location.origin + window.location.pathname;
const response = await base44.functions.invoke('claimTeamCheckout', {
team_slug: teamSlug,
team_name: teamName,
success_url: baseUrl,
cancel_url: baseUrl,
});
if (response.data?.url) {
window.location.href = response.data.url;
} else {
alert(response.data?.error || 'Failed to start checkout. Please try again.');
setPaymentLoading(false);
}
} catch (e) {
alert('Failed to start checkout. Please try again.');
setPaymentLoading(false);
}
};
const handlePromoSubmit = async () => {
if (window.self !== window.top) {
alert('Checkout is only available on the published app. Please open the live site to complete your purchase.');
return;
}
setPromoLoading(true);
setPromoError('');
try {
const baseUrl = window.location.origin + window.location.pathname;
const response = await base44.functions.invoke('claimTeamCheckout', {
team_slug: teamSlug,
team_name: teamName,
success_url: baseUrl,
cancel_url: baseUrl,
promo_code: promoCode.toUpperCase(),
});
if (response.data?.error) {
setPromoError(response.data.error);
setPromoCode('');
setPromoLoading(false);
return;
}
if (response.data?.url) {
window.location.href = response.data.url;
} else {
setPromoError('Failed to start checkout');
setPromoCode('');
setPromoLoading(false);
}
} catch (e) {
console.error('Promo error:', e);
setPromoError('Error validating promo code');
setPromoCode('');
setPromoLoading(false);
}
};
return (
<div className="min-h-screen bg-black text-white flex items-center justify-center px-4">
<div className="w-full max-w-md rounded-2xl border p-8 overflow-hidden" style={{ background: '#0a0a0a', borderColor: 'rgba(255,255,255,0.07)' }}>
<h2 className="font-barlow font-black text-2xl text-white mb-2">Claim {teamName}</h2>
<p className="text-gray-500 text-sm mb-6">Unlock full access to game film, analytics, and stats.</p>
<div className="mb-6 pb-6 border-b" style={{ borderColor: 'rgba(255,255,255,0.07)' }}>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wide mb-3">Payment Option</p>
<div className="rounded-xl p-4 mb-4" style={{ background: 'rgba(255,106,0,0.08)', border: `1px solid ${ORANGE}` }}>
<div className="flex items-center justify-between">
<span className="text-white font-semibold">One-Time Payment</span>
<span className="font-barlow font-black text-2xl" style={{ color: ORANGE }}>$100</span>
</div>
<p className="text-xs text-gray-500 mt-2">Secure checkout powered by Stripe</p>
</div>
<button
onClick={handlePaymentCheckout}
disabled={paymentLoading || promoLoading}
className="w-full py-3 rounded-xl font-black uppercase tracking-widest transition-all text-sm flex items-center justify-center gap-2 disabled:opacity-50"
style={{ background: ORANGE, color: '#000' }}>
{paymentLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Pay Now'}
</button>
</div>
<div>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wide mb-3">Or Use Promo Code</p>
<input
type="text"
value={promoCode}
onChange={e => { setPromoCode(e.target.value.toUpperCase()); setPromoError(''); }}
placeholder="ENTER PROMO CODE"
disabled={promoLoading || paymentLoading}
onKeyDown={e => e.key === 'Enter' && !promoLoading && !paymentLoading && handlePromoSubmit()}
className="w-full px-4 py-3 rounded-xl bg-[#111] text-white text-sm placeholder-white/30 focus:outline-none mb-2 disabled:opacity-50"
style={{ border: `1px solid ${promoError ? '#FF3B30' : 'rgba(255,255,255,0.09)'}` }}
/>
{promoError && <p className="text-red-400 text-xs mb-2">{promoError}</p>}
<button
onClick={handlePromoSubmit}
disabled={promoLoading || paymentLoading}
className="w-full py-2.5 rounded-xl font-black uppercase tracking-widest transition-all text-sm flex items-center justify-center gap-2 disabled:opacity-50"
style={{ background: ORANGE, color: '#000' }}>
{promoLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Apply Code'}
</button>
</div>
</div>
</div>
);
}