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:
Kev
2026-06-15 23:27:58 -04:00
parent a74b5dd1ed
commit 907c7b17c1
19 changed files with 1020 additions and 430 deletions
+13
View File
@@ -0,0 +1,13 @@
import RouteStub from '@/components/vyndr/RouteStub';
export const metadata = { title: 'About' };
export default function AboutPage() {
return (
<RouteStub
title="About"
arriving="SESSION 36"
blurb="The books have every advantage. We built this to give it back. Built by Kevon Butler · Detroit."
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import RouteStub from '@/components/vyndr/RouteStub';
export const metadata = { title: 'Compare' };
export default function ComparePage() {
return (
<RouteStub
title="Compare"
arriving="SESSION 36"
blurb="Two players head-to-head — markets, form, and the verdict on who has the edge tonight."
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import RouteStub from '@/components/vyndr/RouteStub';
export const metadata = { title: 'Help' };
export default function HelpPage() {
return (
<RouteStub
title="Help & FAQ"
arriving="SESSION 36"
blurb="Searchable answers on grades, reads, plans, and how to read the signal."
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import RouteStub from '@/components/vyndr/RouteStub';
export const metadata = { title: 'Invite' };
export default function InvitePage() {
return (
<RouteStub
title="Invite"
arriving="SESSION 36"
blurb="Bring three friends onto the signal and your Analyst plan is on the house."
/>
);
}
+9 -1
View File
@@ -4,6 +4,9 @@ import AuthProvider from '@/contexts/AuthContext';
import ParlayProvider from '@/contexts/ParlayContext';
import ExplainModeProvider from '@/contexts/ExplainModeContext';
import Nav from '@/components/Nav';
import Footer from '@/components/Footer';
import AuthGate from '@/components/AuthGate';
import HashRedirect from '@/components/vyndr/HashRedirect';
import ParlayTray from '@/components/ParlayTray';
import BottomTabBar from '@/components/BottomTabBar';
import InstallPrompt from '@/components/InstallPrompt';
@@ -120,8 +123,13 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider>
<ExplainModeProvider>
<ParlayProvider>
<HashRedirect />
<Nav />
<main style={{ paddingTop: 64, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
{/* Header = 60px nav + 32px ticker; offset main so content clears it. */}
<AuthGate>
<main style={{ paddingTop: 96, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
</AuthGate>
<Footer />
<ParlayTray />
<BottomTabBar />
<InstallPrompt />
+43 -25
View File
@@ -1,50 +1,68 @@
import Link from 'next/link';
import Wordmark from '@/components/Wordmark';
import { Wordmark } from '@/components/vyndr';
import NotFoundActions from '@/components/vyndr/NotFoundActions';
export const metadata = {
title: '404 — Signal Lost',
};
/**
* The north star (§3). The brand distilled: full-page scanlines, the glitch
* wordmark, system language ("TRANSMISSION INTERRUPTED"), a giant glowing amber
* 404, and a CRT sweep on load. Every page should aspire to this intentionality.
*/
export default function NotFound() {
return (
<section
className="tex-scan"
className="scanlines"
style={{
minHeight: 'calc(100vh - 144px)',
minHeight: 'calc(100vh - 140px)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 24,
padding: '32px 16px',
gap: 0,
padding: '40px 24px',
textAlign: 'center',
position: 'relative',
}}
>
<Wordmark size={28} />
<p className="lbl" style={{ color: 'var(--grade-c)' }}>TRANSMISSION INTERRUPTED</p>
<h1
className="num"
{/* CRT sweep — fires its 0.7s animation once on mount */}
<div className="crt-sweep" />
<div style={{ marginBottom: 22 }}>
<Wordmark size="lg" cursor beta />
</div>
<div
className="mono amber-glow"
style={{ fontSize: 13, letterSpacing: '0.28em', color: 'var(--amber)', marginBottom: 22 }}
>
TRANSMISSION INTERRUPTED
</div>
<div
className="wm"
data-text="404"
style={{
fontSize: 'clamp(80px, 16vw, 160px)',
fontSize: 'clamp(96px, 16vw, 150px)',
fontWeight: 800,
color: 'var(--grade-c)',
textShadow: '0 0 24px rgba(255, 179, 71, 0.6), 0 0 48px rgba(255, 179, 71, 0.25)',
lineHeight: 0.9,
letterSpacing: '-0.04em',
color: 'var(--amber)',
textShadow: '0 0 24px rgba(255,179,71,.6), 0 0 60px rgba(255,179,71,.3)',
lineHeight: 1,
letterSpacing: '-0.02em',
}}
>
404
</h1>
<p style={{ fontSize: 18, fontWeight: 600, color: 'var(--text-0)', maxWidth: 480 }}>
This page doesn&apos;t exist. The signal was lost.
</p>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 480 }}>
Check the URL, head back to the slate, or open the Ledger to review past grades.
</p>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
<Link href="/dashboard" className="btn-primary">Back to Dashboard </Link>
<Link href="/ledger" className="btn-ghost">Open the Ledger</Link>
</div>
<h1 style={{ fontSize: 26, fontWeight: 800, letterSpacing: '-0.02em', margin: '26px 0 0', color: 'var(--text-0)' }}>
This page doesn&apos;t exist.
</h1>
<p className="mono" style={{ fontSize: 13.5, color: 'var(--text-1)', marginTop: 10 }}>
The signal was lost.
</p>
<NotFoundActions />
</section>
);
}
+14
View File
@@ -0,0 +1,14 @@
import RouteStub from '@/components/vyndr/RouteStub';
export const metadata = { title: 'Notifications' };
// Gated route (lib/routes.js) — AuthGate bounces signed-out visitors to /login.
export default function NotificationsPage() {
return (
<RouteStub
title="Notifications"
arriving="SESSION 36"
blurb="Your inbox of A+ signals, injury cascades, and graded results."
/>
);
}
+1 -2
View File
@@ -19,7 +19,7 @@ import Features from '@/components/Features';
import HowItWorks from '@/components/HowItWorks';
import Pricing from '@/components/Pricing';
import FAQ from '@/components/FAQ';
import Footer from '@/components/Footer';
// Footer is mounted globally in the root layout (Session 34) — no per-page import.
export default function Home() {
const { user, loading } = useAuth();
@@ -54,7 +54,6 @@ export default function Home() {
<HowItWorks />
<Pricing />
<FAQ />
<Footer />
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
import RouteStub from '@/components/vyndr/RouteStub';
export const metadata = { title: 'Terminal' };
export default function TerminalPage() {
return (
<RouteStub
title="The Terminal"
arriving="SESSION 35"
blurb="League intelligence: injury cascades, game-impact scores, gradeable leaders, factor pulse, matchup exploits."
/>
);
}
+33
View File
@@ -0,0 +1,33 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { isGatedRoute } from '@/lib/routes';
/**
* Client-side auth gate (§12). Our session lives in the Supabase client
* (localStorage), not an httpOnly cookie a server middleware could read — so
* the gate runs here, in the browser, on top of the existing Supabase auth.
*
* Gated routes (a user's own ledger / tracker / account / alerts — see
* lib/routes.js) bounce signed-out visitors to /login, remembering where they
* were headed via the `?next=` param the login page already consumes. We wait
* for auth to finish loading before deciding, so a logged-in user is never
* flashed to /login on a hard refresh.
*/
export default function AuthGate({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const pathname = usePathname() || '';
const router = useRouter();
useEffect(() => {
if (loading) return;
if (!user && isGatedRoute(pathname)) {
const next = encodeURIComponent(pathname);
router.replace(`/login?next=${next}`);
}
}, [user, loading, pathname, router]);
return <>{children}</>;
}
+72 -129
View File
@@ -1,151 +1,94 @@
const PRIMARY_LINKS = [
{ label: 'Read', href: '/scan' },
{ label: 'Tracker', href: '/tracker' },
{ label: 'Ledger', href: '/ledger' },
{ label: 'Pricing', href: '/#pricing' },
{ label: 'Blog', href: '/blog' },
import { Wordmark } from '@/components/vyndr';
// VYNDR 2.0 footer (§C.4). System language, not marketing — mono, uppercase
// labels, the Detroit signature, and the legal/21+ line. Server component
// (no interactivity); link hover is handled by the global .footer-link rule.
const COLUMNS: { head: string; links: { label: string; href: string }[] }[] = [
{
head: 'PRODUCT',
links: [
{ label: 'Slate', href: '/dashboard' },
{ label: 'The Terminal', href: '/terminal' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Invite friends', href: '/invite' },
],
},
{
head: 'COMPANY',
links: [
{ label: 'About', href: '/about' },
{ label: 'The Report', href: '/blog' },
{ label: 'Help & FAQ', href: '/help' },
],
},
{
head: 'LEGAL',
links: [
{ label: 'Responsible Play', href: '/responsible-gambling' },
{ label: 'Terms', href: '/terms' },
{ label: 'Privacy', href: '/privacy' },
],
},
];
const LEGAL_LINKS = [
{ label: 'Terms', href: '/terms' },
{ label: 'Privacy', href: '/privacy' },
{ label: 'Responsible Gambling', href: '/responsible-gambling' },
// Session 17 — support contact in the legal column. Audit found
// the platform had no support surface at all.
{ label: 'Support', href: 'mailto:support@vyndr.app' },
];
import Wordmark from '@/components/Wordmark';
const SOCIAL = [
{ label: 'Twitter', href: 'https://twitter.com/getvyndr' },
{ label: 'Discord', href: 'https://discord.gg/getvyndr' },
];
const mono = { fontFamily: 'var(--mono)' } as const;
export default function Footer() {
return (
<footer
style={{
borderTop: '1px solid var(--border)',
padding: '64px 24px 32px',
marginTop: 64,
}}
>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<div
style={{
display: 'grid',
gap: 48,
marginBottom: 48,
}}
className="footer-top"
>
<div style={{ maxWidth: 400 }}>
<a
href="/"
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}
aria-label="VYNDR — home"
>
<Wordmark size={24} />
<footer style={{ borderTop: '1px solid var(--border)', marginTop: 40, padding: '34px 24px' }}>
<div style={{ maxWidth: 1320, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 28 }}>
<div style={{ maxWidth: 300 }}>
<a href="/" aria-label="VYNDR — home" style={{ display: 'inline-flex' }}>
<Wordmark size="sm" beta />
</a>
<p
style={{
marginTop: 12,
fontSize: 14,
color: 'var(--text-secondary)',
lineHeight: 1.6,
}}
>
The books have every advantage. We built this to give it back.
</p>
<p className="mono" style={{ marginTop: 16, fontSize: 12, color: 'var(--text-tertiary)' }}>
Built in Detroit.
</p>
<div style={{ ...mono, fontSize: 11.5, color: 'var(--text-2)', lineHeight: 1.7, marginTop: 14 }}>
Prop intelligence the books don&apos;t want you to have. Analytics tool, not a sportsbook.
</div>
</div>
<FooterColumn title="Product" links={PRIMARY_LINKS} />
<FooterColumn title="Legal" links={LEGAL_LINKS} />
<FooterColumn title="Community" links={SOCIAL} external />
<div style={{ display: 'flex', gap: 48, flexWrap: 'wrap' }}>
{COLUMNS.map((col) => (
<div key={col.head}>
<div className="label" style={{ fontSize: 9.5, marginBottom: 12 }}>{col.head}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
{col.links.map((l) => (
<a key={l.label} href={l.href} className="footer-link" style={{ ...mono, fontSize: 12, color: 'var(--text-1)', textDecoration: 'none' }}>
{l.label}
</a>
))}
</div>
</div>
))}
</div>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
marginTop: 30,
paddingTop: 20,
borderTop: '1px solid var(--border)',
paddingTop: 24,
display: 'grid',
gap: 16,
}}
>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', lineHeight: 1.6 }}>
VYNDR is an analytics tool, not a sportsbook. We don&apos;t accept wagers. Gamble responsibly.
If you or someone you know has a gambling problem, call <strong style={{ color: 'var(--text-secondary)' }}>1-800-522-4700</strong>{' '}
or visit <a href="https://www.ncpgambling.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--grade-a)' }}>ncpgambling.org</a>.
</p>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
© 2026 VYNDR. All rights reserved.
</p>
<div style={{ ...mono, fontSize: 11, color: 'var(--text-2)' }}>
VYNDR is an analytics tool, not a sportsbook. Not financial advice. Gamble responsibly. 21+ ·{' '}
<a href="tel:18005224700" style={{ color: 'var(--amber)', textDecoration: 'none' }}>1-800-522-4700</a>
</div>
<div style={{ ...mono, fontSize: 11, color: 'var(--text-2)' }}>
BUILT BY KEVON BUTLER · DETROIT · © 2026 VYNDR
</div>
</div>
</div>
<style jsx>{`
:global(.footer-top) {
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
:global(.footer-top) {
grid-template-columns: 2fr 1fr 1fr 1fr;
}
}
<style>{`
.footer-link { transition: color .15s; }
.footer-link:hover { color: var(--text-0); }
`}</style>
</footer>
);
}
function FooterColumn({
title,
links,
external,
}: {
title: string;
links: { label: string; href: string }[];
external?: boolean;
}) {
return (
<div>
<h4
className="mono"
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--text-tertiary)',
marginBottom: 16,
}}
>
{title}
</h4>
<ul style={{ display: 'grid', gap: 8 }}>
{links.map((l) => (
<li key={l.label}>
<a
href={l.href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
style={{
color: 'var(--text-secondary)',
textDecoration: 'none',
fontSize: 14,
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>
</li>
))}
</ul>
</div>
);
}
+347 -271
View File
@@ -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)',
};
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { resolveHashAlias } from '@/lib/routes';
/**
* Hash deep-link bridge (§C.3.4). The VYNDR 2.0 prototype was a single HTML
* file routed by hash (#scan, #terminal, …). We keep Next file-based routing,
* so when a hash-style link lands here we translate it to the real route once
* on mount. No-op for ordinary in-page anchors (only known aliases redirect).
*/
export default function HashRedirect() {
const router = useRouter();
useEffect(() => {
if (typeof window === 'undefined') return;
const target = resolveHashAlias(window.location.hash);
if (target && window.location.pathname !== target) {
router.replace(target);
}
}, [router]);
return null;
}
@@ -0,0 +1,15 @@
'use client';
import { useRouter } from 'next/navigation';
import VBtn from '@/components/vyndr/VBtn';
/** Interactive CTAs for the 404 (kept client-side so the page stays server). */
export default function NotFoundActions() {
const router = useRouter();
return (
<div style={{ display: 'flex', gap: 10, marginTop: 28, flexWrap: 'wrap', justifyContent: 'center' }}>
<VBtn variant="primary" onClick={() => router.push('/dashboard')}> Back to the Slate</VBtn>
<VBtn variant="outline" onClick={() => router.push('/ledger')}>Open the Ledger</VBtn>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import SectionHead from '@/components/vyndr/SectionHead';
import { Wordmark } from '@/components/vyndr';
type RouteStubProps = {
/** Page name, e.g. "Terminal". */
title: string;
/** When real content lands, e.g. "SESSION 35". */
arriving: string;
/** One-line system-voice description of what's coming. */
blurb?: string;
};
/**
* Placeholder for a VYNDR 2.0 route whose real screen ships in a later session
* (§C.3.5). Uses the design system + system language so a "coming soon" still
* feels intentional, never a blank 404.
*/
export default function RouteStub({ title, arriving, blurb }: RouteStubProps) {
return (
<section
className="scanlines"
style={{
minHeight: 'calc(100vh - 200px)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 18,
padding: '40px 24px',
textAlign: 'center',
background: 'var(--bg-0)',
position: 'relative',
}}
>
<Wordmark size="md" cursor beta />
<SectionHead accent="var(--g-c)"> ROUTE UNDER CONSTRUCTION · {arriving}</SectionHead>
<h1 style={{ fontSize: 30, fontWeight: 800, letterSpacing: '-0.02em', margin: 0, color: 'var(--text-0)' }}>
{title}
</h1>
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)', maxWidth: 420, lineHeight: 1.6 }}>
{blurb || 'This surface is being built. The signal is live; the screen lands soon.'}
</p>
</section>
);
}
+91
View File
@@ -0,0 +1,91 @@
/* ============================================================
VYNDR 2.0 — app-shell routing config (§6, §12).
Plain CommonJS so the client AuthGate/HashRedirect import it
(allowJs) AND the Jest suite requires it directly (no transform).
AUTH NOTE: our auth is client-side Supabase (session in
localStorage via @supabase/supabase-js), and the existing
middleware.ts is locale-only. A Next server middleware can't read
that session, so the gate is enforced CLIENT-side by <AuthGate>.
This file is the single source of truth for which routes gate.
============================================================ */
/* Gated — require an authenticated user. Deliberately narrower than the
prototype's GATED set: the prototype also gated dashboard + scan, but those
are OUR free-scan acquisition funnel (anon/free users get 5 reads), so
gating them would be a monetization regression. We gate only the genuinely
personal surfaces (a user's own ledger, bets, account, alerts). */
const GATED_ROUTES = [
'/ledger',
'/tracker',
'/account',
'/profile',
'/settings',
'/notifications',
'/invite',
];
/* Open — reachable without auth (marketing + the free funnel + auth itself). */
const OPEN_ROUTES = [
'/',
'/dashboard',
'/slate',
'/scan',
'/terminal',
'/compare',
'/game',
'/pricing',
'/blog',
'/article',
'/responsible-gambling',
'/help',
'/about',
'/terms',
'/privacy',
'/login',
'/signup',
'/auth',
'/forgot-password',
'/verify',
'/welcome',
'/offline',
'/upgrade',
];
/* Hash deep-link aliases (§C.3.4). The prototype was a single HTML file using
#scan / #terminal; we keep Next file-based routing and treat these hashes as
redirects so old share links and PWA shortcuts still resolve. */
const HASH_ALIASES = {
'#slate': '/dashboard',
'#dashboard': '/dashboard',
'#scan': '/scan',
'#terminal': '/terminal',
'#compare': '/compare',
'#ledger': '/ledger',
'#tracker': '/tracker',
'#account': '/account',
'#pricing': '/pricing',
'#blog': '/blog',
'#invite': '/invite',
'#notifications': '/notifications',
};
/** True when `pathname` falls under a gated route (exact or nested). */
function isGatedRoute(pathname) {
if (!pathname) return false;
return GATED_ROUTES.some((r) => pathname === r || pathname.startsWith(r + '/'));
}
/** Resolve a window.location.hash (e.g. "#scan") to a real route, or null. */
function resolveHashAlias(hash) {
if (!hash) return null;
return HASH_ALIASES[hash] || null;
}
module.exports = {
GATED_ROUTES,
OPEN_ROUTES,
HASH_ALIASES,
isGatedRoute,
resolveHashAlias,
};