Files
vyndr/web/src/components/Nav.tsx
T

324 lines
11 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 from '@/components/Wordmark';
import NotificationBell from '@/components/NotificationBell';
import LocaleSwitcher from '@/components/LocaleSwitcher';
import { useT } from '@/contexts/LocaleContext';
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);
// 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.
const NAV_LINKS = [
{ label: t('nav.tracker'), href: '/tracker' },
{ label: t('nav.ledger'), href: '/ledger' },
{ label: t('nav.pricing'), href: '/pricing' },
{ label: 'Blog', href: '/blog' },
];
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
style={{
maxWidth: 1280,
margin: '0 auto',
padding: '0 24px',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 24,
}}
>
<a
href="/"
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 10 }}
aria-label="VYNDR — home"
>
<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}
</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)',
}}
>
{scansRemaining}/5 reads · MO
</span>
)}
<NotificationBell />
<LocaleSwitcher />
<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,
}}
>
<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"
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
background: 'transparent',
border: 'none',
color: 'var(--text-secondary)',
fontSize: 13,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
Log out
</button>
</div>
)}
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<LocaleSwitcher />
<a href="/login" className="btn-primary" style={{ padding: '8px 16px', fontSize: 13 }}>
{t('nav.login')}
</a>
</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. */}
<a
href="/scan"
onClick={() => setMobileOpen(false)}
style={{
padding: '12px 16px',
fontSize: 13,
color: 'var(--text-secondary, #8A8A9A)',
textDecoration: 'none',
borderRadius: 8,
borderTop: '1px solid var(--border)',
marginTop: 4,
paddingTop: 16,
}}
>
Scan manually
</a>
{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>
) : (
<a
href="/login"
className="btn-primary"
style={{ marginTop: 8, padding: 12 }}
onClick={() => setMobileOpen(false)}
>
Log In
</a>
)}
</div>
</div>
)}
<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>
</nav>
);
}