Files
vyndr/web/src/components/Nav.tsx
T
builtbykev 5d19660f8e Session E (night2): Phase 4 — scan + parlay polish
4.1 ROOT CAUSE of 'Ohtani returns nothing': no backend
    /api/players/search existed (MLB 404'd; NBA/WNBA hit the offline
    Python service). New Express route + mlbStatsAdapter.matchPlayers —
    canonical nameKey fuzzy match (exact > last-name prefix > folded
    substring). LIVE-VERIFIED vs the real 1,299-player list: Ohtani /
    Aaron Judge / Sánchez / sanchez / Chisholm Jr all resolve; accented
    and unaccented return identical results. Non-MLB matches the
    platform's cached names (rosterlogs + grades), cache-only.
4.2 Reveal choreography per §7: analyzing steps → DECLASSIFIED stamp →
    90ms-staggered context panels (entrance floors visible per the
    Phase-0 rule); prefers-reduced-motion skips straight to the card.
4.3 PRIOR READS chips on scan results — the model's public ledger
    history for the player (deferred-render, outcomes + pending, never
    invented). /api/ledger/model gains ?player= on entries.
4.4 Parlay Lab: humanized stat labels via the ONE shared formatter
    (lib/gradeAdapter.statLabel); 1-leg provisional grade ('Leg grade:
    B — add a leg for the combined read'); discoverable entry — Nav
    'Parlay Lab' item opens the drawer via window.__openParlay, and the
    open drawer now renders an honest empty state at 0 legs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:59:17 -04:00

430 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 / 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.
// Session 57 (Phase 0) — TERMINAL removed: the surface was fabricated sample
// data (audit verdict REBUILD). Its layouts live on as §12 content-engine
// templates in components/intel/TerminalTemplates.tsx; "The Terminal" survives
// as brand language only. Don't re-add the link until it's fed real data.
const PRIMARY = [
{ id: 'slate', label: 'Slate', href: '/dashboard' },
{ id: 'scan', label: 'Scan', href: '/scan' },
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
];
const MORE = [
{ label: 'Explore', href: '/explore' },
// Session 60 (4.4) — discoverable Parlay Lab entry (opens the drawer).
{ label: 'Parlay Lab', href: '#parlay', action: 'parlay' as const },
{ 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' },
];
// Session 57 (Phase 0) — the hardcoded fallback ticker items (fake Wembanyama
// signal, "NYK vs SA Q3", invented Tatum move) are DELETED. The ticker renders
// only real /api/ticker snapshot exhaust, and hides itself entirely below 4
// real items (spec §3). Never seed it with fabricated content again.
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={(e) => {
if ('action' in l && l.action === 'parlay') {
e.preventDefault();
if (typeof window !== 'undefined' && (window as Window & { __openParlay?: () => void }).__openParlay) {
(window as Window & { __openParlay?: () => void }).__openParlay!();
}
}
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 — bell, read meter / plan, avatar. Session 57 (Phase 0):
the "Query" pill was a duplicate link to /scan — deleted. A real
⌘K palette is a later nicety (spec §4), not a nav link. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{/* 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). No fallback
items — real snapshot exhaust or nothing. */}
<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;
}
}
`}</style>
</div>
);
}
const menuItem: React.CSSProperties = {
display: 'block',
padding: '10px 12px',
fontSize: 13,
color: 'var(--text-1)',
textDecoration: 'none',
fontFamily: 'var(--sans)',
};