80683e71b4
P0 fixes + wiring real data into the S42 Player Intelligence architecture. - P0 dropdown z-index: the nav's backdrop-filter stacking context let the Ticker/HeartbeatBar paint over the avatar/More dropdowns and eat clicks. nav now position:relative zIndex:2; menus zIndex:100. Avatar Settings -> /settings. - Real MLB stats: mlbStatsAdapter.searchPlayer + getPlayerStats (name->id-> season+gamelog). playerIntelService.resolvePlayerStats normalizes into the archetype classifier; getPlayerIntel returns found:true + real season + archetype classified from real stats. NBA via nbaStatsClient (degrades). - Game cards: slateAdapter.groupPropsByPlayer (playerStrips, name once) + mapPitchers (MLB probables), folded into mapScheduleToGameCards. Legacy GameCard line grid renders BookChip (brand colors) not grey text. - Grade card intel: analyzeViaEngine1.buildIntelFields computes stat-context + form/usage/matchup/rest from the existing feature vector (zero extra I/O); gradeAdapter lights up the card sections. Archetype deferred (needs season line at grade time). - Depth chart foundation: depthChartService (getLineup/getDepthChart/ getCascadeProjection) + /api/stats/lineup|depth|cascade, graceful + injectable. - Mobile: player hero name overflow-wrap + 24px on <=640px (was clipping). Backend 2011 -> 2045 tests (+34), 163 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { usePathname } from 'next/navigation';
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { Wordmark, Ticker } from '@/components/vyndr';
|
||
import { HeartbeatBar } from '@/components/vyndr/LiveLayer';
|
||
import NotificationBell from '@/components/NotificationBell';
|
||
// Nav labels are English literals for now; nav-string i18n lands in Phase G
|
||
// (Session 38) once the locale dictionaries carry slate/terminal/etc. keys.
|
||
|
||
// VYNDR 2.0 nav (§6, Session 34). Primary links are SLATE / TERMINAL / SCAN /
|
||
// LEDGER; everything else lives under a More dropdown. All nav chrome is
|
||
// JetBrains Mono, uppercase, 11px — system language, not SaaS sans. Active
|
||
// route is grade-green (--g-a); a Ticker runs under the bar.
|
||
const PRIMARY = [
|
||
{ id: 'slate', label: 'Slate', href: '/dashboard' },
|
||
{ id: 'terminal', label: 'Terminal', href: '/terminal' },
|
||
{ id: 'scan', label: 'Scan', href: '/scan' },
|
||
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
|
||
];
|
||
const MORE = [
|
||
{ label: 'Explore', href: '/explore' },
|
||
{ label: 'Compare', href: '/compare' },
|
||
{ label: 'Tracker', href: '/tracker' },
|
||
{ label: 'The Report', href: '/blog' },
|
||
{ label: 'Invite', href: '/invite' },
|
||
{ label: 'Pricing', href: '/pricing' },
|
||
{ label: 'Settings', href: '/settings' },
|
||
];
|
||
|
||
const TICKER_ITEMS = [
|
||
{ tag: 'A+', text: 'Signal detected · Wembanyama Points', glow: true },
|
||
{ tag: 'LIVE', text: 'NYK vs SA · Q3 4:22', color: 'var(--live)' },
|
||
{ tag: 'CASCADE', text: 'Murray OUT → Jokic usage', delta: '▲+12%', color: 'var(--g-b)' },
|
||
{ tag: 'MOVE', text: 'Tatum o27.5 → o26.5', delta: '▼-1.0', color: 'var(--amber)' },
|
||
];
|
||
|
||
function isActive(pathname: string, href: string) {
|
||
return pathname === href || pathname.startsWith(href + '/');
|
||
}
|
||
|
||
const linkStyle = (active: boolean) => ({
|
||
fontFamily: 'var(--mono)',
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
letterSpacing: '0.08em',
|
||
textTransform: 'uppercase' as const,
|
||
color: active ? 'var(--g-a)' : 'var(--text-1)',
|
||
textDecoration: 'none',
|
||
whiteSpace: 'nowrap' as const,
|
||
transition: 'color .15s',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
padding: '6px 4px',
|
||
});
|
||
|
||
export default function Nav() {
|
||
const { user, tier, scansRemaining, signOut } = useAuth();
|
||
const pathname = usePathname() || '';
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
const [moreOpen, setMoreOpen] = useState(false);
|
||
const [mobileOpen, setMobileOpen] = useState(false);
|
||
|
||
const showReadCounter =
|
||
pathname.startsWith('/scan') || pathname.startsWith('/dashboard');
|
||
const moreActive = MORE.some((l) => isActive(pathname, l.href));
|
||
|
||
return (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, zIndex: 50 }}>
|
||
<nav
|
||
style={{
|
||
// Session 43 (P0) — the nav's backdrop-filter creates a stacking
|
||
// context; the Ticker + HeartbeatBar render after it as siblings, so
|
||
// without this they painted OVER the avatar/More dropdowns (which
|
||
// overflow below the 60px bar) and ate the clicks. position+zIndex
|
||
// floats the whole nav (and its dropdowns) above those living-layer
|
||
// bars so menu items are clickable again.
|
||
position: 'relative',
|
||
zIndex: 2,
|
||
height: 60,
|
||
borderBottom: '1px solid var(--border)',
|
||
background: 'rgba(6, 6, 11, 0.86)',
|
||
backdropFilter: 'blur(10px)',
|
||
WebkitBackdropFilter: 'blur(10px)',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
maxWidth: 1320,
|
||
margin: '0 auto',
|
||
padding: '0 24px',
|
||
height: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 18,
|
||
}}
|
||
>
|
||
{/* Left — wordmark + primary links */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
||
<a href="/" aria-label="VYNDR — home" style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||
<Wordmark size="md" cursor beta />
|
||
</a>
|
||
<div className="nav-desktop" style={{ display: 'none', gap: 14, alignItems: 'center' }}>
|
||
{PRIMARY.map((l) => (
|
||
<a
|
||
key={l.id}
|
||
href={l.href}
|
||
className="glitch-hover"
|
||
style={linkStyle(isActive(pathname, l.href))}
|
||
onMouseEnter={(e) => {
|
||
if (!isActive(pathname, l.href)) e.currentTarget.style.color = 'var(--text-0)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isActive(pathname, l.href)) e.currentTarget.style.color = 'var(--text-1)';
|
||
}}
|
||
>
|
||
{l.label}
|
||
</a>
|
||
))}
|
||
{/* More dropdown */}
|
||
<div style={{ position: 'relative' }}>
|
||
<button
|
||
onClick={() => setMoreOpen((o) => !o)}
|
||
aria-haspopup="menu"
|
||
aria-expanded={moreOpen}
|
||
style={linkStyle(moreActive)}
|
||
>
|
||
More ▾
|
||
</button>
|
||
{moreOpen && (
|
||
<div
|
||
role="menu"
|
||
onMouseLeave={() => setMoreOpen(false)}
|
||
style={{
|
||
position: 'absolute',
|
||
left: 0,
|
||
top: 'calc(100% + 8px)',
|
||
zIndex: 100,
|
||
minWidth: 180,
|
||
background: 'var(--bg-2)',
|
||
border: '1px solid var(--border-hi)',
|
||
borderRadius: 8,
|
||
padding: 6,
|
||
boxShadow: '0 12px 32px rgba(0,0,0,.5)',
|
||
}}
|
||
>
|
||
{MORE.map((l) => (
|
||
<a
|
||
key={l.href}
|
||
href={l.href}
|
||
role="menuitem"
|
||
onClick={() => setMoreOpen(false)}
|
||
style={{
|
||
display: 'block',
|
||
padding: '9px 10px',
|
||
borderRadius: 6,
|
||
fontFamily: 'var(--mono)',
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
letterSpacing: '0.06em',
|
||
textTransform: 'uppercase',
|
||
color: isActive(pathname, l.href) ? 'var(--g-a)' : 'var(--text-1)',
|
||
textDecoration: 'none',
|
||
}}
|
||
>
|
||
{l.label}
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right — search, bell, read meter / plan, avatar */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<a
|
||
href="/scan"
|
||
title="Query the system"
|
||
className="mono"
|
||
style={{
|
||
display: 'none',
|
||
alignItems: 'center',
|
||
gap: 7,
|
||
height: 28,
|
||
padding: '0 10px',
|
||
borderRadius: 7,
|
||
border: '1px solid var(--border-hi)',
|
||
background: 'var(--bg-2)',
|
||
color: 'var(--text-1)',
|
||
textDecoration: 'none',
|
||
fontSize: 11,
|
||
letterSpacing: '0.06em',
|
||
}}
|
||
data-search-trigger
|
||
>
|
||
<span style={{ color: 'var(--g-a)' }}>›</span>
|
||
<span>Query</span>
|
||
</a>
|
||
|
||
{/* Preferences (language / odds format / accessibility) — §9/§10 */}
|
||
<button
|
||
onClick={() => typeof window !== 'undefined' && window.__prefs && window.__prefs()}
|
||
aria-label="Language & accessibility preferences"
|
||
title="Language & accessibility"
|
||
style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid var(--border-hi)', background: 'var(--bg-2)', color: 'var(--text-1)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||
</svg>
|
||
</button>
|
||
|
||
{user && <NotificationBell />}
|
||
|
||
{user ? (
|
||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||
{showReadCounter && scansRemaining != null && tier === 'free' && (
|
||
// Read meter is a live paywall trigger (§12): tapping it (or
|
||
// hitting 0) opens the paywall.
|
||
<button
|
||
onClick={() => typeof window !== 'undefined' && window.__goPaywall && window.__goPaywall()}
|
||
className="mono"
|
||
aria-label={`${scansRemaining} of 5 reads remaining — upgrade`}
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
padding: 0,
|
||
color: scansRemaining <= 1 ? 'var(--g-c)' : 'var(--text-1)',
|
||
}}
|
||
>
|
||
{scansRemaining}/5 · MO
|
||
</button>
|
||
)}
|
||
{tier !== 'free' && (
|
||
<span
|
||
className="mono"
|
||
title="Unlimited on your plan"
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
color: 'var(--g-a)',
|
||
border: '1px solid rgba(0,212,160,.3)',
|
||
borderRadius: 100,
|
||
padding: '4px 10px',
|
||
letterSpacing: '0.04em',
|
||
textTransform: 'uppercase',
|
||
}}
|
||
>
|
||
∞ {tier}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => setMenuOpen((o) => !o)}
|
||
aria-haspopup="menu"
|
||
aria-expanded={menuOpen}
|
||
style={{
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: '50%',
|
||
background: 'linear-gradient(135deg, var(--acc-1), var(--bg-3))',
|
||
border: '1px solid var(--border-hi)',
|
||
color: 'var(--g-a)',
|
||
cursor: 'pointer',
|
||
fontFamily: 'var(--mono)',
|
||
fontWeight: 800,
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{user.email?.charAt(0).toUpperCase() || 'V'}
|
||
</button>
|
||
{menuOpen && (
|
||
<div
|
||
role="menu"
|
||
onMouseLeave={() => setMenuOpen(false)}
|
||
style={{
|
||
position: 'absolute',
|
||
right: 0,
|
||
top: 'calc(100% + 8px)',
|
||
zIndex: 100,
|
||
minWidth: 220,
|
||
background: 'var(--bg-2)',
|
||
border: '1px solid var(--border-hi)',
|
||
borderRadius: 8,
|
||
padding: 8,
|
||
boxShadow: '0 12px 32px rgba(0,0,0,.5)',
|
||
}}
|
||
>
|
||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
|
||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||
Signed in as
|
||
</div>
|
||
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{user.email}
|
||
</div>
|
||
<div className="mono" style={{ marginTop: 6, fontSize: 11, color: 'var(--g-a)', textTransform: 'uppercase' }}>
|
||
{tier} tier
|
||
</div>
|
||
</div>
|
||
<a href="/account" role="menuitem" style={menuItem}>Account</a>
|
||
<a href="/settings" role="menuitem" style={menuItem}>Settings</a>
|
||
{tier === 'free' && (
|
||
<a href="/pricing" role="menuitem" style={{ ...menuItem, color: 'var(--g-a)' }}>
|
||
Upgrade — $14.99/mo
|
||
</a>
|
||
)}
|
||
<button
|
||
onClick={() => {
|
||
void signOut();
|
||
setMenuOpen(false);
|
||
}}
|
||
role="menuitem"
|
||
style={{ ...menuItem, width: '100%', textAlign: 'left', background: 'transparent', border: 'none', cursor: 'pointer' }}
|
||
>
|
||
Log out
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<a
|
||
href="/login"
|
||
className="mono"
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 700,
|
||
padding: '7px 14px',
|
||
borderRadius: 7,
|
||
border: '1px solid var(--g-a)',
|
||
background: 'transparent',
|
||
color: 'var(--g-a)',
|
||
textDecoration: 'none',
|
||
letterSpacing: '0.04em',
|
||
}}
|
||
>
|
||
Sign In
|
||
</a>
|
||
)}
|
||
|
||
{/* Session 37 — the mobile bottom tab bar + More sheet now own
|
||
mobile navigation, so the hamburger is retired (display:none).
|
||
The mobile panel below is consequently dead code but harmless. */}
|
||
<button
|
||
className="nav-mobile-toggle"
|
||
aria-label="Toggle menu"
|
||
aria-expanded={mobileOpen}
|
||
onClick={() => setMobileOpen((o) => !o)}
|
||
style={{
|
||
display: 'none',
|
||
background: 'transparent',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 8,
|
||
padding: 6,
|
||
color: 'var(--text-0)',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{mobileOpen ? '×' : '≡'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{mobileOpen && (
|
||
<div className="nav-mobile-panel" style={{ borderTop: '1px solid var(--border)', background: 'var(--bg-1)', padding: 12 }}>
|
||
<div style={{ display: 'grid', gap: 2 }}>
|
||
{[...PRIMARY.map((p) => ({ label: p.label, href: p.href })), ...MORE].map((l) => (
|
||
<a
|
||
key={l.href}
|
||
href={l.href}
|
||
onClick={() => setMobileOpen(false)}
|
||
style={{
|
||
padding: '12px 14px',
|
||
fontFamily: 'var(--mono)',
|
||
fontSize: 12,
|
||
fontWeight: 700,
|
||
letterSpacing: '0.06em',
|
||
textTransform: 'uppercase',
|
||
color: isActive(pathname, l.href) ? 'var(--g-a)' : 'var(--text-0)',
|
||
textDecoration: 'none',
|
||
borderRadius: 8,
|
||
}}
|
||
>
|
||
{l.label}
|
||
</a>
|
||
))}
|
||
{user ? (
|
||
<button
|
||
onClick={() => {
|
||
void signOut();
|
||
setMobileOpen(false);
|
||
}}
|
||
style={{ textAlign: 'left', padding: '12px 14px', fontSize: 13, color: 'var(--text-1)', background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'var(--mono)', textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
||
>
|
||
Log out
|
||
</button>
|
||
) : (
|
||
<a href="/login" onClick={() => setMobileOpen(false)} style={{ marginTop: 6, padding: 12, textAlign: 'center', borderRadius: 7, border: '1px solid var(--g-a)', color: 'var(--g-a)', textDecoration: 'none', fontFamily: 'var(--mono)', fontWeight: 700, textTransform: 'uppercase' }}>
|
||
Sign In
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</nav>
|
||
|
||
{/* Ticker + heartbeat under the bar (§8 living layer) */}
|
||
<Ticker items={TICKER_ITEMS} height={32} />
|
||
<HeartbeatBar />
|
||
|
||
<style jsx>{`
|
||
@media (min-width: 768px) {
|
||
:global(.nav-desktop) {
|
||
display: flex !important;
|
||
}
|
||
:global(.nav-mobile-toggle) {
|
||
display: none !important;
|
||
}
|
||
:global(.nav-mobile-panel) {
|
||
display: none !important;
|
||
}
|
||
:global([data-search-trigger]) {
|
||
display: inline-flex !important;
|
||
}
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const menuItem: React.CSSProperties = {
|
||
display: 'block',
|
||
padding: '10px 12px',
|
||
fontSize: 13,
|
||
color: 'var(--text-1)',
|
||
textDecoration: 'none',
|
||
fontFamily: 'var(--sans)',
|
||
};
|