Session 34: Design system Phase C — app shell, nav, routing, auth gate, footer, 404 (1818 tests)
VYNDR 2.0 conversion, Phase C (the frame every page sits inside). Frontend-only; zero backend changes. - Nav rewritten: new .wm Wordmark, mono uppercase links, More dropdown, search/ bell/read-meter/avatar, Ticker under the bar. layout main paddingTop 64 -> 96. - Routing: web/src/lib/routes.js (GATED/OPEN/HASH_ALIASES, isGatedRoute, resolveHashAlias). Client AuthGate bounces signed-out users off personal routes to /login?next=. HashRedirect maps #scan/#terminal to real routes. - Footer rewritten to system voice + Detroit signature; mounted globally in layout (removed per-page dup). - 404 converted to the north star (scanlines, crt-sweep, glitch wordmark, amber). - Stub pages for terminal/compare/invite/help/about/notifications via RouteStub. Honest reconciliations: auth gate is client-side (no auth-helpers pkg; session is client-side Supabase); GATED narrowed to protect the free-scan funnel; did not stub over existing real pages; redirect param is ?next= (what /login reads). 26 new tests. Backend 1792 -> 1818, 142 suites, zero regressions. Web build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+347
-271
@@ -3,314 +3,378 @@
|
||||
import { useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
import { Wordmark, Ticker } from '@/components/vyndr';
|
||||
import NotificationBell from '@/components/NotificationBell';
|
||||
// Session 24 — LocaleSwitcher removed from the nav. The i18n
|
||||
// infrastructure (react-i18next, LocaleContext, useT) stays in place,
|
||||
// but a visible language toggle with no translations behind it is
|
||||
// worse than none. Re-add the switcher when translations land.
|
||||
import { useT } from '@/contexts/LocaleContext';
|
||||
// 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: 'Compare', href: '/compare' },
|
||||
{ label: 'Tracker', href: '/tracker' },
|
||||
{ label: 'The Report', href: '/blog' },
|
||||
{ label: 'Invite', href: '/invite' },
|
||||
{ label: 'Pricing', href: '/pricing' },
|
||||
{ label: 'Settings', href: '/settings/security' },
|
||||
];
|
||||
|
||||
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 t = useT();
|
||||
const pathname = usePathname() || '';
|
||||
// Session 17 — read counter sits in the global nav, but the audit
|
||||
// flagged it as noise outside the scan flow. Restrict it to /scan
|
||||
// and /dashboard (where the slate-scan lives) so it acts as a
|
||||
// quota indicator next to the action it gates, not a chrome pill.
|
||||
const showReadCounter = pathname === '/scan' || pathname.startsWith('/scan/')
|
||||
|| pathname === '/dashboard' || pathname.startsWith('/dashboard/');
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// Session 12 — translation labels resolved at render time so a
|
||||
// locale switch flips the nav without a code change.
|
||||
// Session 13 — "Scan" removed from the primary nav: The Slate on
|
||||
// /dashboard IS the scan surface (click [Read] on any prop). The
|
||||
// /scan page still exists as a fallback for custom props and is
|
||||
// reachable from the slate's "Scan manually" empty-state CTA.
|
||||
// Session 24 — paid users (analyst / desk) get "Account" where free
|
||||
// users and signed-out visitors see "Pricing". A subscriber shouldn't
|
||||
// be pitched a plan they already pay for.
|
||||
const isPaid = !!user && tier !== 'free';
|
||||
const NAV_LINKS = [
|
||||
{ label: t('nav.tracker'), href: '/tracker' },
|
||||
{ label: t('nav.ledger'), href: '/ledger' },
|
||||
isPaid
|
||||
? { label: 'Account', href: '/account' }
|
||||
: { label: t('nav.pricing'), href: '/pricing' },
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
];
|
||||
const showReadCounter =
|
||||
pathname.startsWith('/scan') || pathname.startsWith('/dashboard');
|
||||
const moreActive = MORE.some((l) => isActive(pathname, l.href));
|
||||
|
||||
return (
|
||||
<nav
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 50,
|
||||
height: 64,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'rgba(10, 10, 15, 0.85)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, zIndex: 50 }}>
|
||||
<nav
|
||||
style={{
|
||||
maxWidth: 1280,
|
||||
margin: '0 auto',
|
||||
padding: '0 24px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 24,
|
||||
height: 60,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'rgba(6, 6, 11, 0.86)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 10 }}
|
||||
aria-label="VYNDR — home"
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 1320,
|
||||
margin: '0 auto',
|
||||
padding: '0 24px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 18,
|
||||
}}
|
||||
>
|
||||
<Wordmark size={22} />
|
||||
{/* Session 8 — beta tag. Tiny, glitch-styled, sits next to
|
||||
the wordmark so it reads as part of the brand rather than
|
||||
a banner. Renders on every page that mounts Nav. */}
|
||||
<span
|
||||
className="mono"
|
||||
aria-label="Beta"
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.14em',
|
||||
padding: '2px 5px',
|
||||
color: 'var(--grade-a)',
|
||||
border: '1px solid var(--grade-a)',
|
||||
borderRadius: 3,
|
||||
textTransform: 'uppercase',
|
||||
opacity: 0.85,
|
||||
lineHeight: 1,
|
||||
position: 'relative',
|
||||
top: -2,
|
||||
}}
|
||||
>
|
||||
BETA
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div className="nav-desktop" style={{ display: 'none', gap: 28, alignItems: 'center' }}>
|
||||
{NAV_LINKS.map((l) => (
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)',
|
||||
textDecoration: 'none',
|
||||
transition: 'color 200ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
||||
>
|
||||
{l.label}
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
{user ? (
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{showReadCounter && scansRemaining != null && tier === 'free' && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: scansRemaining <= 1 ? 'var(--grade-c)' : 'var(--text-secondary)',
|
||||
<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)';
|
||||
}}
|
||||
>
|
||||
{scansRemaining}/5 reads · MO
|
||||
</span>
|
||||
)}
|
||||
<NotificationBell />
|
||||
<button
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-focus)',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{user.email?.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="surface-elevated"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 'calc(100% + 8px)',
|
||||
minWidth: 220,
|
||||
padding: 8,
|
||||
}}
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
{/* More dropdown */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setMoreOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={moreOpen}
|
||||
style={linkStyle(moreActive)}
|
||||
>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>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(--grade-a)', textTransform: 'uppercase' }}>
|
||||
{tier} tier
|
||||
</div>
|
||||
</div>
|
||||
{tier === 'free' && (
|
||||
<a
|
||||
href="/#pricing"
|
||||
role="menuitem"
|
||||
style={{ display: 'block', padding: '10px 12px', fontSize: 13, color: 'var(--text-primary)', textDecoration: 'none' }}
|
||||
>
|
||||
Upgrade — $14.99/mo
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
void signOut();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
More ▾
|
||||
</button>
|
||||
{moreOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
onMouseLeave={() => setMoreOpen(false)}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 'calc(100% + 8px)',
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{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 style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<a href="/login" className="btn-primary" style={{ padding: '8px 16px', fontSize: 13 }}>
|
||||
{t('nav.login')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="nav-mobile-toggle"
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={mobileOpen}
|
||||
onClick={() => setMobileOpen((o) => !o)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{mobileOpen ? '×' : '≡'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="nav-mobile-panel"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-primary)',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
{NAV_LINKS.map((l) => (
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontSize: 15,
|
||||
color: 'var(--text-primary)',
|
||||
textDecoration: 'none',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
{/* Session 14 — mobile-only "Scan manually" link. The Slate
|
||||
IS the scan surface on /dashboard, but power users on
|
||||
mobile may want a direct route to the form. Subtle
|
||||
tertiary styling so it doesn't compete with the
|
||||
primary nav links. */}
|
||||
{/* Right — search, bell, read meter / plan, avatar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<a
|
||||
href="/scan"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
title="Query the system"
|
||||
className="mono"
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontSize: 13,
|
||||
color: 'var(--text-secondary, #8A8A9A)',
|
||||
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',
|
||||
borderRadius: 8,
|
||||
borderTop: '1px solid var(--border)',
|
||||
marginTop: 4,
|
||||
paddingTop: 16,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
data-search-trigger
|
||||
>
|
||||
Scan manually →
|
||||
<span style={{ color: 'var(--g-a)' }}>›</span>
|
||||
<span>Query</span>
|
||||
</a>
|
||||
|
||||
{user && <NotificationBell />}
|
||||
|
||||
{user ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
void signOut();
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '12px 16px',
|
||||
fontSize: 15,
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{showReadCounter && scansRemaining != null && tier === 'free' && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: scansRemaining <= 1 ? 'var(--g-c)' : 'var(--text-1)',
|
||||
}}
|
||||
>
|
||||
{scansRemaining}/5 · MO
|
||||
</span>
|
||||
)}
|
||||
{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)',
|
||||
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/security" 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="btn-primary"
|
||||
style={{ marginTop: 8, padding: 12 }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
Log In
|
||||
Sign In
|
||||
</a>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="nav-mobile-toggle"
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={mobileOpen}
|
||||
onClick={() => setMobileOpen((o) => !o)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
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 under the bar — sample data; real feed wires in Session 38 */}
|
||||
<Ticker items={TICKER_ITEMS} height={32} />
|
||||
|
||||
<style jsx>{`
|
||||
@media (min-width: 768px) {
|
||||
@@ -323,8 +387,20 @@ export default function Nav() {
|
||||
:global(.nav-mobile-panel) {
|
||||
display: none !important;
|
||||
}
|
||||
:global([data-search-trigger]) {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const menuItem: React.CSSProperties = {
|
||||
display: 'block',
|
||||
padding: '10px 12px',
|
||||
fontSize: 13,
|
||||
color: 'var(--text-1)',
|
||||
textDecoration: 'none',
|
||||
fontFamily: 'var(--sans)',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user