324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
'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>
|
||
);
|
||
}
|